Skip to content

Commit

Permalink
Feature/mapsios 367 frame view annotations (#1634)
Browse files Browse the repository at this point in the history
* API to get camera options to fit a list of view annotations.
  • Loading branch information
maios authored Oct 28, 2022
1 parent d87a5af commit 6b0e373
Show file tree
Hide file tree
Showing 8 changed files with 339 additions and 2 deletions.
4 changes: 4 additions & 0 deletions Apps/Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
304AB3B527439287005B6D09 /* ViewAnnotationMarkerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 304AB3B427439287005B6D09 /* ViewAnnotationMarkerExample.swift */; };
30517C6A274BD4D300B706E5 /* ViewAnnotationBasicExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30517C69274BD4D300B706E5 /* ViewAnnotationBasicExample.swift */; };
3A3AF0032836499F0036F483 /* route.geojson in Resources */ = {isa = PBXBuildFile; fileRef = 3A3AF0022836499F0036F483 /* route.geojson */; };
3A44669E28F6EA1600664AF5 /* FrameViewAnnotationsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A44669D28F6EA1600664AF5 /* FrameViewAnnotationsExample.swift */; };
3A7432EF27F3096100E06485 /* DebugMapExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7432EE27F3096100E06485 /* DebugMapExample.swift */; };
3A7CE986282511C900C3A0B8 /* NavigationSimulatorExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7CE985282511C900C3A0B8 /* NavigationSimulatorExample.swift */; };
3A7CE98B282AB0DE00C3A0B8 /* NavigationSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7CE98A282AB0DE00C3A0B8 /* NavigationSimulator.swift */; };
Expand Down Expand Up @@ -190,6 +191,7 @@
304AB3B427439287005B6D09 /* ViewAnnotationMarkerExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewAnnotationMarkerExample.swift; sourceTree = "<group>"; };
30517C69274BD4D300B706E5 /* ViewAnnotationBasicExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewAnnotationBasicExample.swift; sourceTree = "<group>"; };
3A3AF0022836499F0036F483 /* route.geojson */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = route.geojson; sourceTree = "<group>"; };
3A44669D28F6EA1600664AF5 /* FrameViewAnnotationsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameViewAnnotationsExample.swift; sourceTree = "<group>"; };
3A7432EE27F3096100E06485 /* DebugMapExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMapExample.swift; sourceTree = "<group>"; };
3A7CE985282511C900C3A0B8 /* NavigationSimulatorExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSimulatorExample.swift; sourceTree = "<group>"; };
3A7CE98A282AB0DE00C3A0B8 /* NavigationSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSimulator.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -493,6 +495,7 @@
07A2E03C25CB64E20082BC31 /* SwiftUIExample.swift */,
17E28C5B2672A1160033DF0F /* SymbolClusteringExample.swift */,
30517C69274BD4D300B706E5 /* ViewAnnotationBasicExample.swift */,
3A44669D28F6EA1600664AF5 /* FrameViewAnnotationsExample.swift */,
74797C1928F5B72F0008BBB9 /* ViewAnnotationAnimationExample.swift */,
304AB3B427439287005B6D09 /* ViewAnnotationMarkerExample.swift */,
74A2313D27EE1C630065FB7D /* ViewAnnotationWithPointAnnotationExample.swift */,
Expand Down Expand Up @@ -769,6 +772,7 @@
0C52BA9825AF8C880054ECA8 /* Custom3DPuckExample.swift in Sources */,
CADCF7292584990E0065C51B /* FeaturesAtPointExample.swift in Sources */,
CADCF71E2584990E0065C51B /* ExternalVectorSourceExample.swift in Sources */,
3A44669E28F6EA1600664AF5 /* FrameViewAnnotationsExample.swift in Sources */,
7412CF6227E8DD1E00F03B1C /* AddOneMarkerSymbolExample.swift in Sources */,
74A2313E27EE1C640065FB7D /* ViewAnnotationWithPointAnnotationExample.swift in Sources */,
73694BD325D4B2CE0064F636 /* TrackingModeExample.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import UIKit
import MapboxMaps

final class FrameViewAnnotationsExample: UIViewController, ExampleProtocol {

private enum Animator {
case flyTo, easeTo, viewport
}

private var flyToButton: UIButton!
private var easeToButton: UIButton!
private var viewportButton: UIButton!
private var resetButton: UIButton!

private var mapView: MapView!
private let initialCamera = CameraOptions(
center: .random,
padding: UIEdgeInsets(top: .random(in: 0...20), left: .random(in: 0...20), bottom: .random(in: 0...20), right: .random(in: 0...20)),
zoom: 0,
bearing: 0,
pitch: 0
)

override func viewDidLoad() {
super.viewDidLoad()

view.backgroundColor = .white

mapView = MapView(frame: view.bounds, mapInitOptions: MapInitOptions(cameraOptions: initialCamera))
let buttonsView = makeButtonsView()

view.addSubview(mapView)
view.addSubview(buttonsView)

mapView.translatesAutoresizingMaskIntoConstraints = false
buttonsView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
buttonsView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
buttonsView.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor),
buttonsView.bottomAnchor.constraint(equalTo: mapView.ornaments.logoView.topAnchor, constant: -10),
])

addAnnotations()

mapView.mapboxMap.onNext(event: .mapLoaded) { [weak self] _ in
// The below line is used for internal testing purposes only.
self?.finish()
}
}

private func makeButtonsView() -> UIView {
func makeButton(title: String, selector: Selector) -> UIButton {
let button = UIButton()
button.setTitle(title, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 20, bottom: 10, right: 20)
button.backgroundColor = .black
button.addTarget(self, action: selector, for: .touchUpInside)
return button
}

flyToButton = makeButton(title: "FlyTo", selector: #selector(flyToButtonTapped(_:)))
easeToButton = makeButton(title: "EaseTo", selector: #selector(easeToButtonTapped(_:)))
viewportButton = makeButton(title: "Viewport", selector: #selector(viewportButtonTapped(_:)))
resetButton = makeButton(title: "Reset camera", selector: #selector(resetButtonTapped(_:)))

let buttonsView = UIStackView(arrangedSubviews: [flyToButton, easeToButton, viewportButton, resetButton])
buttonsView.axis = .horizontal
buttonsView.spacing = 10
buttonsView.distribution = .fillEqually

resetButton.isHidden = true

return buttonsView
}

@objc private func flyToButtonTapped(_ sender: UIButton) {
frameViewAnnotation(with: .flyTo, sender: sender)
}

@objc private func easeToButtonTapped(_ sender: UIButton) {
frameViewAnnotation(with: .easeTo, sender: sender)
}

@objc private func viewportButtonTapped(_ sender: UIButton) {
frameViewAnnotation(with: .viewport, sender: sender)
}

@objc private func resetButtonTapped(_ sender: UIButton) {
mapView.mapboxMap.setCamera(to: initialCamera)
resetButton.isHidden = true
flyToButton.isHidden = false
easeToButton.isHidden = false
viewportButton.isHidden = false
}

private func frameViewAnnotation(with animator: Animator, sender: UIButton) {
flyToButton.isHidden = true
easeToButton.isHidden = true
viewportButton.isHidden = true
resetButton.isHidden = false

let camera = self.mapView.viewAnnotations.camera(
forAnnotations: Array(self.coordinates.keys),
padding: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10),
bearing: nil,
pitch: nil
)!

switch animator {
case .flyTo:
mapView.camera.fly(to: camera, duration: 1)
case .easeTo:
mapView.camera.ease(to: camera, duration: 1)
case .viewport:
let bounds = mapView.mapboxMap.coordinateBounds(for: camera)
let overviewViewportStateOptions = OverviewViewportStateOptions(
geometry: MultiPoint([bounds.northeast, bounds.southeast, bounds.southwest, bounds.northwest]),
padding: .zero,
bearing: camera.bearing,
pitch: camera.pitch,
animationDuration: 1
)
let overviewViewportState = mapView.viewport.makeOverviewViewportState(options: overviewViewportStateOptions)
mapView.viewport.transition(to: overviewViewportState)
}
}

private func addAnnotations() {
for (id, point) in coordinates {
let options = ViewAnnotationOptions(
geometry: point.geometry,
width: 40,
height: 40,
allowOverlap: true,
anchor: .center,
offsetX: 0,
offsetY: 0)
let annotation = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 40))
annotation.backgroundColor = .green
try! mapView.viewAnnotations.add(annotation, id: id, options: options)
}
}

private let coordinates: [String: Point] = [
"Saigon": .init(LocationCoordinate2D(latitude: 10.823099, longitude: 106.629662)),
"Hanoi": .init(LocationCoordinate2D(latitude: 21.027763, longitude: 105.834160)),
"Tokyo": .init(LocationCoordinate2D(latitude: 35.689487, longitude: 139.691711)),
"Bangkok": .init(LocationCoordinate2D(latitude: 13.756331, longitude: 100.501762)),
"Jakarta": .init(LocationCoordinate2D(latitude: -6.175110, longitude: 106.865036)),
]
}
3 changes: 3 additions & 0 deletions Apps/Examples/Examples/Models/Examples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ struct Examples {
Example(title: "View annotations: advanced example",
description: "Add view annotations anchored to a symbol layer feature.",
type: ViewAnnotationMarkerExample.self),
Example(title: "View annotations: Frame list of annotations",
description: "Animates to camera framing the list of selected view annotations.",
type: FrameViewAnnotationsExample.self),
Example(title: "View annotations: animation",
description: "Animate a view annotation along a route",
testTimeout: 60,
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Mapbox welcomes participation and contributions from everyone.

## main

* Animates to camera that fit a list of view annotations. ([#1634](https://github.com/mapbox/mapbox-maps-ios/pull/1634))
* Prevent view annotation being shown erroneously after options update.([#1627](https://github.com/mapbox/mapbox-maps-ios/pull/1627))
* Add an example animating a view annotation along a route line. ([#1639](https://github.com/mapbox/mapbox-maps-ios/pull/1639))
* Enable clustering of point annotations, add example of feature. ([#1475](https://github.com/mapbox/mapbox-maps-ios/issues/1475))
Expand Down
48 changes: 48 additions & 0 deletions Sources/MapboxMaps/Annotations/ViewAnnotationManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// swiftlint:disable file_length
import UIKit
@_implementationOnly import MapboxCommon_Private
@_implementationOnly import MapboxCoreMaps_Private
import Turf

public enum ViewAnnotationManagerError: Error {
case viewIsAlreadyAdded
Expand Down Expand Up @@ -294,6 +296,52 @@ public final class ViewAnnotationManager {
observers.removeValue(forKey: ObjectIdentifier(observer))
}

// MARK: Framing

/// Calculates ``CameraOptions`` to fit the list of view annotations.
///
/// - Important: This API isn't supported by Globe projection.
///
/// - Parameter ids: The list of annotations ids to be framed.
/// - Parameter padding: See ``CameraOptions/padding``.
/// - Parameter bearing: See ``CameraOptions/bearing``.
/// - Parameter pitch: See ``CameraOptions/pitch``.
public func camera(forAnnotations ids: [String], padding: UIEdgeInsets = .zero, bearing: CGFloat? = nil, pitch: CGFloat? = nil) -> CameraOptions? {
let options = ids.compactMap { try? mapboxMap.options(forViewAnnotationWithId: $0) }
guard !options.isEmpty else { return nil }

var north, east, south, west: CLLocationDegrees!
var accumulatedPadding = UIEdgeInsets.zero

for annotationOption in options where annotationOption.visible != false {
guard case .point(let point) = annotationOption.geometry else { continue }

let annotationFrame = annotationOption.frame
if north == nil || north > point.coordinates.latitude {
north = point.coordinates.latitude
accumulatedPadding.top = padding.top + abs(annotationFrame.minY)
}
if east == nil || east < point.coordinates.longitude {
east = point.coordinates.longitude
accumulatedPadding.right = padding.right + annotationFrame.maxX
}
if south == nil || south < point.coordinates.latitude {
south = point.coordinates.latitude
accumulatedPadding.bottom = padding.bottom + annotationFrame.maxY
}
if west == nil || west > point.coordinates.longitude {
west = point.coordinates.longitude
accumulatedPadding.left = padding.left + abs(annotationFrame.minX)
}
}

let points = MultiPoint([
CLLocationCoordinate2D(latitude: north, longitude: east),
CLLocationCoordinate2D(latitude: south, longitude: west),
])
return mapboxMap.camera(for: .multiPoint(points), padding: accumulatedPadding, bearing: bearing, pitch: pitch)
}

// MARK: - Private functions

private func placeAnnotations(positions: [ViewAnnotationPositionDescriptor]) {
Expand Down
36 changes: 35 additions & 1 deletion Sources/MapboxMaps/Annotations/ViewAnnotationOptions.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import UIKit
import MapboxCoreMaps
import Turf

/// Stores layout and visibilty settings for a `ViewAnnotation`
public struct ViewAnnotationOptions: Hashable {
Expand Down Expand Up @@ -100,6 +101,39 @@ public struct ViewAnnotationOptions: Hashable {
hasher.combine(offsetY)
hasher.combine(selected)
}

internal var frame: CGRect {
guard let width = width, let height = height else { return .zero }

let offset: (x: CGFloat, y: CGFloat) = (width * 0.5, height * 0.5)
var frame = CGRect(x: -offset.x, y: -offset.y, width: width, height: height)
let anchor = anchor ?? .center

switch anchor {
case .top:
frame = frame.offsetBy(dx: 0, dy: offset.y)
case .topLeft:
frame = frame.offsetBy(dx: offset.x, dy: offset.y)
case .topRight:
frame = frame.offsetBy(dx: -offset.x, dy: offset.y)
case .bottom:
frame = frame.offsetBy(dx: 0, dy: -offset.y)
case .bottomLeft:
frame = frame.offsetBy(dx: offset.x, dy: -offset.y)
case .bottomRight:
frame = frame.offsetBy(dx: -offset.x, dy: -offset.y)
case .left:
frame = frame.offsetBy(dx: offset.x, dy: 0)
case .right:
frame = frame.offsetBy(dx: -offset.x, dy: 0)
case .center:
fallthrough
@unknown default:
break
}

return frame.offsetBy(dx: offsetX ?? 0, dy: offsetY ?? 0)
}
}

extension MapboxCoreMaps.ViewAnnotationOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ final class ViewAnnotationManagerTests: XCTestCase {
super.setUp()
container = UIView()
mapboxMap = MockMapboxMap()
manager = ViewAnnotationManager(containerView: container, mapboxMap: mapboxMap)
manager = ViewAnnotationManager(
containerView: container,
mapboxMap: mapboxMap)
}

override func tearDown() {
Expand Down Expand Up @@ -412,6 +414,35 @@ final class ViewAnnotationManagerTests: XCTestCase {
XCTAssertTrue(observer.visibilityDidChangeStub.invocations.isEmpty)
}

func testCameraForAnnotations() throws {
let points: [CLLocationCoordinate2D] = .random(withLength: 4, generator: CLLocationCoordinate2D.random)
for (index, point) in points.enumerated() {
let options = ViewAnnotationOptions(geometry: Point(point).geometry, width: 40, height: 40)
try manager.add(UIView(), id: "\(index)", options: options)
mapboxMap.optionsForViewAnnotationWithIdStub.returnValueQueue.insert(options, at: 0)
}

let padding = UIEdgeInsets.random()
let bearing = CGFloat.random(in: -180...180)
let pitch = CGFloat.random(in: 0...90)
_ = manager.camera(forAnnotations: ["0", "1", "2", "3"], padding: padding, bearing: bearing, pitch: pitch)

let parameters = try XCTUnwrap(mapboxMap.cameraForGeometryStub.invocations.last).parameters
XCTAssertEqual(parameters.bearing, bearing)
XCTAssertEqual(parameters.pitch, pitch)

let coordinates = try XCTUnwrap(MapboxCommon.Geometry(parameters.geometry).extractLocationsArray()).map(\.mkCoordinateValue)
let north = try XCTUnwrap(coordinates.max(by: { $0.latitude < $1.latitude })).latitude
let east = try XCTUnwrap(coordinates.max(by: { $0.longitude < $1.longitude })).longitude
let south = try XCTUnwrap(coordinates.min(by: { $0.latitude < $1.latitude })).latitude
let west = try XCTUnwrap(coordinates.min(by: { $0.longitude < $1.longitude })).longitude

XCTAssertFalse(points.contains(where: { $0.latitude > north }))
XCTAssertFalse(points.contains(where: { $0.longitude > east }))
XCTAssertFalse(points.contains(where: { $0.latitude < south }))
XCTAssertFalse(points.contains(where: { $0.longitude < west }))
}

// MARK: - Helper functions

private func addTestAnnotationView(id: String? = nil, featureId: String? = nil) -> UIView {
Expand Down
Loading

0 comments on commit 6b0e373

Please sign in to comment.