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 routes = [Route]() {
didSet {
oldValue.forEach { $0.remove() }
routes.forEach { route in
route.mapView = mapView
route.display()
route.onTap = { [unowned route, weak self] in
self?.select(route: route)
}
}
if let last = routes.last {
select(route: last, 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.override(
locationProvider: Signal(just: [
Location(
coordinate: simulatedCoordinate,
bearing: 168.8)
]),
headingProvider: Signal(just: Heading(direction: 180, accuracy: 0)))
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")
addParkingAnnotation(
coordinate: CLLocationCoordinate2D(latitude: 37.4441, longitude: -122.1691),
text: "$5.99/hr")
}
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 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)
}
}
@objc private func changeMode() {
self.driveMode.toggle()
updateModeButton()
hideAnnotations(true)
updateViewport(animated: true) {
[weak self] in self?.hideAnnotations(false)
}
routes.forEach {
$0.updateProgress(with: driveMode ? simulatedCoordinate : nil)
}
}
private func updateModeButton() {
modeButton.setTitle("Mode: \(driveMode ? "Drive" : "Overview")", for: .normal)
}
private func hideAnnotations(_ hidden: Bool) {
routes.forEach {
$0.etaAnnotation?.visible = !hidden
}
}
private func updateViewport(animated: Bool, completion: (() -> Void)? = nil) {
var viewportState: ViewportState?
if driveMode {
viewportState = mapView.viewport.makeFollowPuckViewportState(options: .init(zoom: 17, bearing: .course, pitch: 49))
} 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) {
let view = ParkingAnnotationView(text: text)
let annotation = ViewAnnotation(coordinate: coordinate, view: view)
annotation.allowOverlap = true
mapView.viewAnnotations.add(annotation)
view.onTap = { [unowned view, unowned annotation] in
annotation.selected.toggle()
view.selected = annotation.selected
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 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 + 0.0005
}
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
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?.selected = selected
etaView?.selected = selected
mapView?.mapboxMap.setFeatureState(sourceId: layerId, featureId: name, state: ["selected": selected]) {_ in}
etaView?.hint = selected ? nil : hint
etaAnnotation?.setNeedsUpdateSize()
}
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)
}
}