Dynamic view annotations
This example adds a dynamic view annotation to a MapView using the Mapbox Maps SDK for iOS. A dynamic view annotation is a movable marker that can change dynamically on screen. In this example, the annotation follows the path of a lineString, which generates multiple routes starting in San Francisco, CA and leading to the Stanford Shopping Center in Palo Alto, CA.
UI options are also created to switch between a "Drive" mode that shows the actual route and "Overview" mode of all the directions. Parking rates and ETA information are also included in the UI.
User interactions are also included, by using Gestures as well as route management, such as loading, displaying, updating progress, and removal based on user interactions.
This example uses AnnotationView. To use this code snippet, you must also add the AnnotationView class, a custom class defined in the Maps SDK for the iOS Examples App.
This example code is part of the Maps SDK for iOS Examples App, a working iOS project available on Github. iOS developers are encouraged to run the examples app locally to interact with this example in an emulator and explore other features of the Maps SDK.
See our Run the Maps SDK for iOS Examples App tutorial for step-by-step instructions.
import UIKit
@_spi(Experimental) import MapboxMaps
import CoreLocation
import MapboxCoreMaps
private let simulatedCoordinate = CLLocationCoordinate2D(latitude: 37.6421, longitude: -122.4062)
final class ViewController: UIViewController {
    private var mapView: MapView!
    private var cancelables = Set<AnyCancelable>()
    private var puckRenderCancellable: AnyCancelable?
    private var routes = [Route]() {
        didSet {
            oldValue.forEach { $0.remove() }
            // prevent eta labels from showing up on overlapped parts of the route
            mapView.viewAnnotations.viewAnnotationAvoidLayers = Set(routes.map(\.layerId))
            routes.forEach { route in
                route.mapView = mapView
                route.display()
                route.onTap = { [unowned route, weak self] in
                    self?.select(route: route)
                }
            }
            if let first = routes.first {
                select(route: first, animated: false)
            }
        }
    }
    private lazy var modeButton = {
        let button = UIButton(type: .system)
        button.backgroundColor = .white
        button.tintColor = .black
        button.translatesAutoresizingMaskIntoConstraints = false
        button.layer.cornerRadius = 20
        button.layer.shadowColor = UIColor(red: 0.084, green: 0.176, blue: 0.283, alpha: 0.25).cgColor
        button.layer.shadowOpacity = 1
        button.layer.shadowRadius = 8
        button.layer.shadowOffset = CGSize(width: 0, height: 2)
        button.addTarget(self, action: #selector(changeMode), for: .touchUpInside)
        NSLayoutConstraint.activate([
            button.widthAnchor.constraint(equalToConstant: 200),
            button.heightAnchor.constraint(equalToConstant: 40)
        ])
        return button
    }()
    private var driveMode = false
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView = MapView(frame: view.bounds)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(mapView)
        updateModeButton()
        mapView.location.options = LocationOptions(puckType: .puck2D(.init(topImage: UIImage(named: "dash-puck"))), puckBearing: .heading, puckBearingEnabled: true)
        mapView.viewport.options.usesSafeAreaInsetsAsPadding = true
        mapView.mapboxMap.onStyleLoaded.observeNext { [weak self] _ in
            guard let self = self else { return }
            loadRoutes()
            
        }.store(in: &cancelables)
        self.toolbarItems = [
            UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
            UIBarButtonItem(customView: modeButton),
            UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        ]
        addParkingAnnotation(
            coordinate: CLLocationCoordinate2D(latitude: 37.445, longitude: -122.1704),
            text: "$6.99/hr",
            minZoom: 12)
        addParkingAnnotation(
            coordinate: CLLocationCoordinate2D(latitude: 37.4441, longitude: -122.1691),
            text: "$5.99/hr",
            minZoom: 10)
    }
    private func loadRoutes() {
        DispatchQueue.global(qos: .userInitiated).async {
            let route1 = Route.load(name: "route-sf-1", time: "52 min")
            let route2 = Route.load(name: "route-sf-2", time: "55 min")
            route1.hint = ETAHint(text: "Avoid traffic", icon: "maneuver-straight")
            route2.hint = ETAHint(text: "On highway", icon: "maneuver-turn-right")
            DispatchQueue.main.async {
                self.routes = [route1, route2]
            }
        }
    }
    private func setupLocationSimulation(route: Route) {
        guard case Turf.Geometry.lineString(let line) = route.feature.geometry! else {
            return
        }
        var coordinates = line.coordinates
        var lastLocation: CLLocationCoordinate2D = coordinates.first!
        var locationHandler: (([Location]) -> Void)?
        var headingHandler: ((Heading) -> Void)?
        var timer: Timer?
        let locationSignal: Signal<[Location]> = Signal { handler in
            locationHandler = handler
            return AnyCancelable {
                timer?.invalidate()
            }
        }
        let headingSignal: Signal<Heading> = Signal { handler in
            headingHandler = handler
            return AnyCancelable {}
        }
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            guard !coordinates.isEmpty else {
                timer.invalidate()
                return
            }
            let currentLocation = coordinates.removeFirst()
            let currentDirection = lastLocation.direction(to: currentLocation)
            locationHandler?([Location(coordinate: lastLocation)])
            headingHandler?(Heading(direction: currentDirection, accuracy: 0))
            lastLocation = currentLocation
        }
        mapView.location.override(locationProvider: locationSignal, headingProvider: headingSignal)
        puckRenderCancellable = mapView.location.onPuckRender.observe { data in
            route.updateProgress(with: data.location.coordinate)
        }
    }
    private func select(route: Route, animated: Bool = true) {
        // Move selected route layer on top of unselected route layers
        let routeLayersIds = Set(routes.map(\.layerId))
        let lastUnselected = mapView.mapboxMap.allLayerIdentifiers.last { info in
            routeLayersIds.contains(info.id)
        }
        if let lastUnselected, lastUnselected.id != route.layerId {
            try? mapView.mapboxMap.moveLayer(withId: route.layerId, to: .above(lastUnselected.id))
        }
        for r in routes {
            // Update layer color
            r.selected = r === route
        }
        if !driveMode {
            updateViewport(animated: animated)
        }
        setupLocationSimulation(route: route)
    }
    @objc private func changeMode() {
        self.driveMode.toggle()
        updateModeButton()
        updateViewport(animated: true) { [weak self] in
            guard let self else { return }
            self.hideInactiveRoutes(self.driveMode)
        }
    }
    private func updateModeButton() {
        modeButton.setTitle("Mode: \(driveMode ? "Overview" : "Drive")", for: .normal)
    }
    private func hideInactiveRoutes(_ hidden: Bool) {
        routes.forEach {
            $0.visible = $0.selected || hidden
        }
    }
    private func updateViewport(animated: Bool, completion: (() -> Void)? = nil) {
        var viewportState: ViewportState?
        if driveMode {
            viewportState = mapView.viewport.makeFollowPuckViewportState(options:
                    .init(padding: .init(top: 100, left: 100, bottom: 100, right: 100), zoom: 18, bearing: .heading, pitch: 70)
            )
        } else {
            if let route = routes.first(where: \.selected), let geometry = route.feature.geometry {
                let coordPadding = UIEdgeInsets(allEdges: 20)
                let options = OverviewViewportStateOptions(geometry: geometry, geometryPadding: coordPadding)
                viewportState = mapView.viewport.makeOverviewViewportState(options: options)
            }
        }
        if let viewportState {
            mapView.viewport.transition(
                to: viewportState,
                transition: animated ? mapView.viewport.makeDefaultViewportTransition() : mapView.viewport.makeImmediateViewportTransition()
            ) { _ in completion?() }
        } else {
            completion?()
        }
    }
    private func addParkingAnnotation(coordinate: CLLocationCoordinate2D, text: String, minZoom: Double) {
        let view = ParkingAnnotationView(text: text)
        let annotation = ViewAnnotation(coordinate: coordinate, view: view)
        annotation.allowOverlap = true
        annotation.minZoom = minZoom
        mapView.viewAnnotations.add(annotation)
        view.onTap = { [unowned view, unowned annotation] in
            view.selected.toggle()
            annotation.priority = view.selected ? 1 : 0
            annotation.setNeedsUpdateSize()
        }
    }
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.setToolbarHidden(false, animated: false)
    }
}
struct ETAHint {
    var text: String
    var icon: String
}
private final class Route {
    let name: String
    let time: String
    let feature: Feature
    var hint: ETAHint?
    var selected: Bool = false {
        didSet { updateSelected() }
    }
    var visible = true {
        didSet { updateVisible() }
    }
    var layerId: String { "route-\(name)" }
    private(set) var etaAnnotation: ViewAnnotation?
    private var etaView: ETAView?
    private var displayed = false
    private var tokens = Set<AnyCancelable>()
    var onTap: (() -> Void)?
    weak var mapView: MapView?
    init(name: String, time: String, feature: Feature) {
        self.name = name
        self.time = time
        self.feature = feature
    }
    func updateProgress(with coordinate: CLLocationCoordinate2D?) {
        var progress = 0.0
        if let coordinate,
           case let .lineString(s) = feature.geometry,
           let doneDistance = s.distance(to: coordinate),
           let length = s.distance() {
            progress = doneDistance / length
        }
        try? mapView?.mapboxMap.setLayerProperty(for: layerId, property: "line-trim-offset", value: [0, progress])
    }
    func display() {
        guard !displayed, let mapView else { return }
        displayed = true
        func colorExpression(normal: String, selected: String) -> Exp {
            Exp(.switchCase) {
                Exp(.boolean) {
                    Exp(.featureState) { "selected" }
                    false
                }
                selected
                normal
            }
        }
        // Routes data source and layer
        var source = GeoJSONSource(id: layerId)
        source.data = .feature(feature)
        source.lineMetrics = true
        try! mapView.mapboxMap.addSource(source)
        var routeLayer = LineLayer(id: layerId, source: layerId)
        routeLayer.lineCap = .constant(.round)
        routeLayer.lineJoin = .constant(.round)
        routeLayer.lineWidth = .constant(10.0)
        routeLayer.lineColor = .expression(colorExpression(normal: "#999999", selected: "#57A9FB"))
        routeLayer.lineBorderWidth = .constant(2)
        routeLayer.lineBorderColor = .expression(colorExpression(normal: "#666666", selected: "#327AC2"))
        routeLayer.slot = .middle
        try! mapView.mapboxMap.addLayer(routeLayer)
        // Annotation
        let etaView = ETAView(text: time)
        self.etaView = etaView
        let etaAnnotation = ViewAnnotation(layerId: layerId, view: etaView)
        etaAnnotation.onAnchorChanged = { config in
            etaView.anchor = config.anchor
        }
        etaAnnotation.variableAnchors = .all
        etaAnnotation.minZoom = 8
        etaView.onTap = { [weak self] in self?.onTap?() }
        self.etaAnnotation = etaAnnotation
        updateSelected()
        mapView.viewAnnotations.add(etaAnnotation)
        mapView.gestures.onLayerTap(layerId) { [weak self] feature, _ in
            guard let self,
                  let onTap = onTap,
                  let identifier = feature.feature.identifier,
                  case let .string(id) = identifier,
                  id == self.name else { return false }
            onTap()
            return true
        }.store(in: &tokens)
    }
    func remove() {
        try? mapView?.mapboxMap.removeLayer(withId: self.layerId)
        try? mapView?.mapboxMap.removeSource(withId: self.layerId)
        self.etaAnnotation?.remove()
        self.etaAnnotation = nil
        self.etaView = nil
        mapView = nil
        onTap = nil
        tokens.removeAll()
    }
    private func updateSelected() {
        etaAnnotation?.priority = selected ? 1 : 0
        etaView?.selected = selected
        mapView?.mapboxMap.setFeatureState(sourceId: layerId, featureId: name, state: ["selected": selected]) {_ in}
        etaView?.hint = selected ? nil : hint
        etaAnnotation?.setNeedsUpdateSize()
    }
    private func updateVisible() {
        try? mapView?.mapboxMap.setLayerProperty(for: layerId, property: "visibility", value: visible ? "visible" : "none")
    }
    static func load(name: String, time: String) -> Route {
        let data = NSDataAsset(name: name)!.data
        let feature = try! JSONDecoder().decode(Feature.self, from: data)
        return .init(name: name, time: time, feature: feature)
    }
}
private final class ParkingAnnotationView: UIView {
    private let label = UILabel()
    private let icon = UIImageView()
    private let stack = UIStackView()
    var text: String {
        didSet { label.text = text }
    }
    var selected: Bool = false {
        didSet { updateSelection() }
    }
    var onTap: (() -> Void)?
    init(text: String) {
        self.text = text
        super.init(frame: .zero)
        icon.image = UIImage(named: "parking-icon")
        stack.axis = .horizontal
        stack.spacing = 3
        stack.addArrangedSubview(icon)
        stack.addArrangedSubview(label)
        stack.translatesAutoresizingMaskIntoConstraints = false
        addSubview(stack)
        NSLayoutConstraint.activate([
            stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 3),
            stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            stack.topAnchor.constraint(equalTo: topAnchor, constant: 3),
            stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3),
            icon.widthAnchor.constraint(equalToConstant: 24),
            icon.heightAnchor.constraint(equalToConstant: 24)
        ])
        layer.shadowColor = UIColor(red: 0.084, green: 0.176, blue: 0.283, alpha: 0.25).cgColor
        layer.shadowOpacity = 1
        layer.shadowRadius = 8
        layer.shadowOffset = CGSize(width: 0, height: 2)
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
        updateSelection()
        updateText()
    }
    func updateText() {
        label.attributedText = .labelText(text, size: 16, color: .black)
    }
    func updateSelection() {
        backgroundColor = selected ? .systemBlue : .white
        label.textColor = selected ? .white : .black
    }
    @objc private func handleTap() {
        onTap?()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        layer.cornerRadius = bounds.size.height / 2
    }
}
final class ETAView: UIView {
    private let label = UILabel()
    private let iconView = UIImageView()
    private var tail = UIView()
    private let backgroundShape = CAShapeLayer()
    var hint: ETAHint? {
        didSet { update() }
    }
    var padding = UIEdgeInsets(allEdges: 10)
    var tailSize = 8.0
    var cornerRadius = 8.0
    var selected: Bool = false {
        didSet { update() }
    }
    var onTap: (() -> Void)?
    var text: String {
        didSet { update() }
    }
    var anchor: ViewAnnotationAnchor? {
        didSet { setNeedsLayout() }
    }
    init(text: String) {
        self.text = text
        super.init(frame: .zero)
        self.layer.addSublayer(backgroundShape)
        backgroundShape.shadowRadius = 1.4
        backgroundShape.shadowOffset = CGSize(width: 0, height: 0.7)
        backgroundShape.shadowColor = UIColor.black.cgColor
        backgroundShape.shadowOpacity = 0.3
        iconView.contentMode = .scaleAspectFit
        iconView.tintColor = UIColor(red: 0.04, green: 0.66, blue: 0.45, alpha: 1)
        label.numberOfLines = 0
        label.textAlignment = .left
        addSubview(label)
        addSubview(iconView)
        update()
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
    }
    @objc private func handleTap() {
        onTap?()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    private var attributedText: NSAttributedString {
        let text = NSMutableAttributedString(attributedString:
                .labelText(text, size: 16, color: selected ? .white : .black, bold: true))
        if let hint {
            text.append(NSAttributedString(string: "\n"))
            text.append(.labelText(hint.text, size: 12, color: .gray))
        }
        return text
    }
    private func update() {
        self.backgroundShape.fillColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.cgColor
        self.label.attributedText = attributedText
        self.iconView.image = hint.flatMap {
            UIImage(named: $0.icon)?.withRenderingMode(.alwaysTemplate)
        }
    }
    struct Layout {
        var label: CGRect
        var bubble: CGRect
        var icon: CGRect
        var size: CGSize
        init(availableSize: CGSize, text: NSAttributedString, showIcon: Bool, tailSize: CGFloat, padding: UIEdgeInsets) {
            let tailPadding = UIEdgeInsets(allEdges: tailSize)
            var iconToText = 0.0
            var iconFrame = CGRect.zero
            if showIcon {
                iconFrame = CGRect(padding: padding + tailPadding, size: CGSize(width: 24, height: 24))
                iconToText = 5.0
            }
            let textPadding = padding + tailPadding + UIEdgeInsets(top: 0, left: iconFrame.width + iconToText, bottom: 0, right: 0)
            let textAvailableSize = availableSize - textPadding
            var textSize = text.boundingRect(
                with: textAvailableSize,
                options: .usesLineFragmentOrigin, context: nil
            ).size.roundedUp()
            textSize.height = max(textSize.height, iconFrame.height)
            iconFrame.size.height = textSize.height
            icon = iconFrame
            label = CGRect(padding: textPadding, size: textSize)
            bubble = CGRect(padding: tailPadding, size: textSize + textPadding - tailPadding)
            size = bubble.size + tailPadding
        }
    }
    override func sizeThatFits(_ size: CGSize) -> CGSize {
        Layout(availableSize: size, text: attributedText, showIcon: hint != nil, tailSize: tailSize, padding: padding).size
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        let layout = Layout(availableSize: bounds.size, text: attributedText, showIcon: hint != nil, tailSize: tailSize, padding: padding)
        label.frame = layout.label
        iconView.frame = layout.icon
        let calloutPath = UIBezierPath.calloutPath(size: bounds.size, tailSize: tailSize, cornerRadius: cornerRadius, anchor: anchor ?? .center)
        backgroundShape.path = calloutPath.cgPath
        backgroundShape.frame = bounds
    }
}
func + (lhs: UIEdgeInsets, rhs: UIEdgeInsets) -> UIEdgeInsets {
    return UIEdgeInsets(top: lhs.top + rhs.top, left: lhs.left + rhs.left, bottom: lhs.bottom + rhs.bottom, right: lhs.right + rhs.right)
}
func + (lhs: CGSize, rhs: UIEdgeInsets) -> CGSize {
    return CGSize(width: lhs.width + rhs.left + rhs.right, height: lhs.height + rhs.top + rhs.bottom)
}
func - (lhs: CGSize, rhs: UIEdgeInsets) -> CGSize {
    return lhs + -rhs
}
func + (lhs: CGSize, rhs: CGSize) -> CGSize {
    return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
}
func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
func * (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    CGPoint(x: lhs.x * rhs.x, y: lhs.y * rhs.y)
}
prefix func - (p: CGPoint) -> CGPoint {
    p * CGPoint(x: -1, y: -1)
}
prefix func - (ins: UIEdgeInsets) -> UIEdgeInsets {
    return UIEdgeInsets(top: -ins.top, left: -ins.left, bottom: -ins.bottom, right: -ins.right)
}
extension CGSize {
    func roundedUp() -> CGSize {
        CGSize(width: width.rounded(.up), height: height.rounded(.up))
    }
}
extension CGRect {
    init(padding: UIEdgeInsets, size: CGSize) {
        self.init(origin: CGPoint(x: padding.left, y: padding.top), size: size)
    }
}
extension UIEdgeInsets {
    init(allEdges value: CGFloat) {
        self.init(top: value, left: value, bottom: value, right: value)
    }
}
extension NSAttributedString {
    static func labelText(_ string: String, size: CGFloat, color: UIColor, bold: Bool = false) -> NSAttributedString {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineHeightMultiple = 1.11
        paragraphStyle.lineSpacing = 4
        paragraphStyle.alignment = .left
        let attributes = [
            NSAttributedString.Key.paragraphStyle: paragraphStyle,
            .font: bold ? UIFont.boldSystemFont(ofSize: size) : .systemFont(ofSize: size),
            .foregroundColor: color,
        ]
        return NSAttributedString(string: string, attributes: attributes)
    }
}