/
AnnotationManager.swift
627 lines (517 loc) · 23.3 KB
/
AnnotationManager.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
import Foundation
import UIKit
import MapboxCoreMaps
import MapboxCommon
import Turf
import CoreLocation
#if canImport(MapboxMaps)
#else
import MapboxMapsStyle
import MapboxMapsFoundation
#endif
//swiftlint:disable file_length type_body_length
/**
Manages the addition, update, and the deletion of annotations to a map.
All annotations added with this class belong to a single source and style layer.
*/
public class AnnotationManager: Observer {
public var peer: MBXPeerWrapper?
// MARK: - Public properties
/**
A dictionary of key/value pairs being managed by the
`AnnotationManager`, where the key represents the unique identifier
of the annotation and the value refers to the `Annotation` itself.
- Note: This property is get-only, so it cannot be used to add or
remove annotations. Instead, use the `addAnnotation` or `removeAnnotation`
related methods update annotations belonging to the map view.
*/
public private(set) var annotations: [String: Annotation]
/**
The delegate to notify of changes in annotations.
*/
public weak var interactionDelegate: AnnotationInteractionDelegate?
/**
An array of annotations that have currently been selected by the annotation manager.
*/
public var selectedAnnotations: [Annotation] {
return annotations.values.filter { ( annotation ) -> Bool in
return annotation.isSelected
}
}
/**
A `Bool` value that indicates whether users can interact with the annotations
managed by the annotation manager. The default value is `true`. Setting this property
to `false` will deinitialize the annotation manager's tap gesture recognizer.
*/
public var userInteractionEnabled: Bool {
didSet {
if !userInteractionEnabled {
self.tapGesture = nil
} else if userInteractionEnabled && self.tapGesture == nil {
configureTapGesture()
}
}
}
// MARK: - Internal properties
/**
The `FeatureCollection` that contains all annotations.
*/
internal var annotationFeatures: FeatureCollection
/**
The `AnnotationStyleDelegate` that will be used to handle the addition
of annotation sources and style layers.
*/
// swiftlint:disable weak_delegate
internal var styleDelegate: AnnotationStyleDelegate
/**
The map object this class will use when querying for annotations.
*/
internal weak var mapView: AnnotationSupportableMap?
/**
The source layer used by the annotation manager.
*/
internal var annotationSource: GeoJSONSource?
/**
The default source layer identifier to be used by the `AnnotationManager`.
*/
internal let defaultSourceId = "com.mapbox.AnnotationManager.DefaultSourceLayer"
/**
The default style layer identifiers to be used by the point, line, and polygon
style layers managed by this class.
*/
internal let defaultSymbolLayerId = "com.mapbox.AnnotationManager.DefaultSymbolStylelayer"
internal let defaultLineLayerId = "com.mapbox.AnnotationManager.DefaultLineStylelayer"
internal let defaultPolygonLayerId = "com.mapbox.AnnotationManager.DefaultPolygonStylelayer"
/**
The default style layers used to render point, line, and polygon
annotations on the map view.
*/
internal var defaultSymbolLayer: SymbolLayer?
internal var defaultLineLayer: LineLayer?
internal var defaultPolygonLayer: FillLayer?
/**
The tap gesture recognizer used to respond to tap events.
Used to process annotation selection.
*/
internal var tapGesture: UITapGestureRecognizer?
// MARK: - Initialization
deinit {
self.tapGesture = nil
try! self.mapView?.observable?.unsubscribe(for: self, events: [MapEvents.mapLoadingStarted])
}
/**
Creates a new `AnnotationManager` object. To manages the addition, update,
and deletion of annotations (or "markers") to a map.
- Parameter mapView: A conformer to AnnotationSupportableMap
- Parameter styleDelegate: Delegate responsible for applying the style to a map
- Parameter interactionDelegate: Delegate responsible for handling annotation interaction events
*/
internal init(for mapView: AnnotationSupportableMap,
with styleDelegate: AnnotationStyleDelegate,
interactionDelegate: AnnotationInteractionDelegate? = nil) {
self.mapView = mapView
self.styleDelegate = styleDelegate
self.interactionDelegate = interactionDelegate
self.annotations = [:]
self.annotationFeatures = FeatureCollection(features: [])
self.userInteractionEnabled = true
configureTapGesture()
try! mapView.observable?.subscribe(for: self, events: [MapEvents.mapLoadingStarted])
}
// MARK: - Public functions
/**
Adds a given annotation to the `MapView`.
If the given annotation has already been added to the `MapView`, this returns an error.
- Parameter annotation: Annotation to add to the `MapView`.
- Returns: If operation successful, returns a `true` as part of the `Result` success case.
Else, returns a `AnnotationError` in the `Result` failure case.
*/
@discardableResult public func addAnnotation(_ annotation: Annotation) -> Result<Bool, AnnotationError> {
if annotations[annotation.identifier] != nil {
return .failure(.annotationAlreadyExists("Annotation has already been added."))
}
// Add to annotations dictionary, and create a `Feature` for it.
annotations[annotation.identifier] = annotation
// Create geoJSON source data from feature collection
do {
try updateFeatureCollection(for: annotation)
try updateLayers(for: annotation)
} catch let error {
return .failure(AnnotationError.addAnnotationFailed(error))
}
return .success(true)
}
/**
Adds a given array of annotations to the `MapView`.
The method is equivalent to calling `addAnnotation(_ annotation:)` method for each annotation within the group.
- Parameter annotations: Annotations to add to the `MapView`.
*/
@discardableResult public func addAnnotations(_ annotations: [Annotation]) -> Result<Bool, AnnotationError> {
for annotation in annotations {
switch addAnnotation(annotation) {
case .success:
break
case .failure(let annotationError):
return .failure(annotationError)
}
}
return .success(true)
}
/**
Updates the annotation registered with the annotation manager.
- Parameter annotation: The annotation that should be updated.
- Throws: `AnnotationError.annotationDoesNotExist` if the annotation
hasn't been added, otherwise throws `AnnotationError.updateAnnotationFailed`.
*/
public func updateAnnotation(_ annotation: Annotation) throws {
guard let existingAnnotation = annotations[annotation.identifier] else {
throw AnnotationError.annotationDoesNotExist(nil)
}
do {
annotations[existingAnnotation.identifier] = annotation
try updateFeatureCollection(for: annotation)
try updateLayers(for: annotation)
} catch let error {
throw AnnotationError.updateAnnotationFailed(error)
}
}
/**
Removes a given annotation from the `MapView`.
If the given annotation has already been removed from the `MapView`, this returns an error.
- Parameter annotation: Annotation to remove from the `MapView`.
- Returns: If operation successful, returns a `true` as part of the `Result` success case.
Else, returns a `AnnotationError` in the `Result` failure case.
*/
@discardableResult public func removeAnnotation(_ annotation: Annotation) -> Result<Bool, AnnotationError> {
guard annotations[annotation.identifier] != nil else {
return .failure(.removeAnnotationFailed("Annotation has already been removed"))
}
annotations[annotation.identifier] = nil
annotationFeatures.features.removeAll { (feature) -> Bool in
guard let featureIdentifier = feature.identifier?.value as? String else { return false }
return featureIdentifier == annotation.identifier ? true : false
}
// Create geoJSON source data from feature collection
guard let geoJSONDictionary = try? GeoJSONManager.dictionaryFrom(annotationFeatures) else {
return .failure(.removeAnnotationFailed("Failed to parse data from FeatureCollection"))
}
let updateSourceExpectation = styleDelegate.updateSourceProperty(id: defaultSourceId,
property: "data",
value: geoJSONDictionary)
switch updateSourceExpectation {
case .success(let bool):
return .success(bool)
case .failure(let sourceError):
return .failure(.removeAnnotationFailed(sourceError.localizedDescription))
}
}
/**
Removes a given array of annotations from the `MapView`.
The method is equivalent to calling `removeAnnotation(_ annotation:)` method for each annotation within the group.
- Parameter annotations: Annotations to remove from the `MapView`.
*/
@discardableResult public func removeAnnotations(_ annotations: [Annotation]) -> Result<Bool, AnnotationError> {
for annotation in annotations {
switch removeAnnotation(annotation) {
case .success:
break
case .failure(let annotationError):
return .failure(annotationError)
}
}
return .success(true)
}
/**
Toggles the annotation's selection state.
If the annotation is deselected, it becomes selected.
If the annotation is selected, it becomes deselected.
- Parameter Annotations: The annotation to select.
*/
public func selectAnnotation(_ annotation: Annotation) {
if var annotation = annotations[annotation.identifier] {
annotation.isSelected.toggle()
annotations[annotation.identifier] = annotation
switch annotation.isSelected {
case true:
self.interactionDelegate?.didSelectAnnotation(annotation: annotation)
case false:
self.interactionDelegate?.didDeselectAnnotation(annotation: annotation)
}
}
}
// MARK: - Internal functions
/**
Adds a new source layer for all annotations.
*/
internal func createAnnotationSource() -> Result<Bool, AnnotationError> {
self.annotationSource = GeoJSONSource()
guard var sourceLayer = self.annotationSource else {
return .failure(.addAnnotationFailed(nil))
}
sourceLayer.data = .featureCollection(annotationFeatures)
let addSourceExpectation = styleDelegate.addSource(source: sourceLayer, identifier: defaultSourceId)
switch addSourceExpectation {
case .success(let bool):
return .success(bool)
case .failure(let sourceError):
return .failure(.addAnnotationFailed(sourceError))
}
}
/**
Creates a turf `Feature` based off an `Annotation`.
*/
internal func makeFeature(for annotation: Annotation) throws -> Feature {
var feature: Feature
switch annotation {
case let point as PointAnnotation:
feature = Feature(Point(point.coordinate))
case let line as LineAnnotation:
feature = Feature(LineString(line.coordinates))
case let polygon as PolygonAnnotation:
var turfPolygon: Polygon
if let holes = polygon.interiorPolygons {
let outerRing = Ring(coordinates: polygon.coordinates)
let innerRings = holes.map({ Ring(coordinates: $0) })
turfPolygon = Polygon(outerRing: outerRing, innerRings: innerRings)
} else {
let outerRing = Ring(coordinates: polygon.coordinates)
turfPolygon = Polygon(outerRing: outerRing)
}
feature = Feature(turfPolygon)
default:
throw AnnotationError.featureGenerationFailed("Could not generate Feature from annotation")
}
feature.identifier = FeatureIdentifier.string(annotation.identifier)
feature.properties = annotation.properties
return feature
}
/**
Updates the internal `FeatureCollection` with the given `Annotation`.
If the annotation already exists in the `FeatureCollection`, it is updated at
its given index. If it does not exist yet within the `FeatureCollection`, it
is appended to the `FeatureCollection`.
*/
internal func updateFeatureCollection(for annotation: Annotation) throws {
let existingAnnotationIndex = annotationFeatures.features.firstIndex(where: { (feature) -> Bool in
guard let featureIdentifier = feature.identifier?.value as? String else { return false }
return featureIdentifier == annotation.identifier
})
let feature = try makeFeature(for: annotation)
if let index = existingAnnotationIndex {
annotationFeatures.features[index] = feature
} else {
annotationFeatures.features.append(feature)
}
}
/**
Updates the source and style layers if needed.
*/
internal func updateLayers(for annotation: Annotation) throws {
let geoJSONDictionary = try GeoJSONManager.dictionaryFrom(annotationFeatures)
try updateSourceLayer(geoJSONDictionary: geoJSONDictionary)
try updateStyleLayer(for: annotation)
}
/**
Creates or updates the data source layer for the annotations.
*/
internal func updateSourceLayer(geoJSONDictionary: [String: Any]?) throws {
guard let geoJSON = geoJSONDictionary else {
throw AnnotationError.addAnnotationFailed(nil)
}
if self.annotationSource == nil {
if case .failure(let sourceError) = createAnnotationSource() {
throw AnnotationError.addAnnotationFailed(sourceError)
}
} else {
let updateSourceExpectation = styleDelegate.updateSourceProperty(id: defaultSourceId,
property: "data",
value: geoJSON)
if case .failure(let sourceError) = updateSourceExpectation {
throw AnnotationError.addAnnotationFailed(sourceError)
}
}
}
/**
Creates a style layer for a given annotation type,
if the annotation type hasn't been added yet.
*/
internal func updateStyleLayer(for annotation: Annotation) throws {
switch annotation {
case let pointAnnotation as PointAnnotation:
try updateSymbolStyleLayer(for: pointAnnotation)
case _ as LineAnnotation:
try updateLineStyleLayer()
case _ as PolygonAnnotation:
try updateFillStyleLayer()
default:
throw AnnotationError.styleLayerGenerationFailed(nil)
}
}
internal func updateSymbolStyleLayer(for pointAnnotation: PointAnnotation) throws {
// If the point annotation has a custom image, add it to the sprite.
if let customImage = pointAnnotation.image {
let expectedCustomImage = styleDelegate.setStyleImage(image: customImage,
with: pointAnnotation.identifier,
sdf: false,
stretchX: [],
stretchY: [],
scale: 3.0,
imageContent: nil)
if case .failure(let imageError) = expectedCustomImage {
throw AnnotationError.addAnnotationFailed(imageError)
}
}
// Add the default symbol layer image.
if defaultSymbolLayer == nil {
// Add the default icon image to the sprite, but only once.
if styleDelegate.getStyleImage(with: pointAnnotation.defaultIconImageIdentifier) == nil {
let expectedDefaultImage = styleDelegate.setStyleImage(image: pointAnnotation.defaultAnnotationImage(),
with: pointAnnotation.defaultIconImageIdentifier,
sdf: false,
stretchX: [],
stretchY: [],
scale: 3.0,
imageContent: nil)
if case .failure(let imageError) = expectedDefaultImage {
throw AnnotationError.addAnnotationFailed(imageError)
}
}
// Make the style layer for the first time.
var symbolLayer = SymbolLayer(id: defaultSymbolLayerId)
symbolLayer.source = defaultSourceId
/**
Create an expression that will use the `icon-image`
property associated with an annotation's `Feature`
to set the image.
*/
symbolLayer.layout?.iconImage = .expression(Exp(.get) {
"icon-image"
})
/**
Since all annotation geometries share the same source,
render only the point geometries within the symbol layer.
*/
symbolLayer.filter = Exp(.eq) {
"$type"
"Point"
}
let expectedLayer = styleDelegate.addLayer(layer: symbolLayer, layerPosition: nil)
if case .failure(let layerError) = expectedLayer {
throw AnnotationError.addAnnotationFailed(layerError)
}
defaultSymbolLayer = symbolLayer
}
}
internal func updateLineStyleLayer() throws {
if defaultLineLayer == nil {
var lineLayer = LineLayer(id: defaultLineLayerId)
lineLayer.source = defaultSourceId
/**
Since all annotation geometries share the same source,
render only the line geometries within the line layer.
*/
lineLayer.filter = Exp(.eq) {
"$type"
"LineString"
}
if case .failure(let layerError) = styleDelegate.addLayer(layer: lineLayer, layerPosition: nil) {
throw AnnotationError.addAnnotationFailed(layerError)
}
defaultLineLayer = lineLayer
}
}
internal func updateFillStyleLayer() throws {
if defaultPolygonLayer == nil {
var fillLayer = FillLayer(id: defaultPolygonLayerId)
fillLayer.source = defaultSourceId
/**
Since all annotation geometries share the same source,
render only the polygon geometries within the fill layer.
*/
fillLayer.filter = Exp(.eq) {
"$type"
"Polygon"
}
if case .failure(let layerError) = styleDelegate.addLayer(layer: fillLayer, layerPosition: nil) {
throw AnnotationError.addAnnotationFailed(layerError)
}
defaultPolygonLayer = fillLayer
}
}
// MARK: - Annotation selection
internal func configureTapGesture() {
guard let mapView = mapView else {
assertionFailure("MapView is nil")
return
}
tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
if let tapGesture = self.tapGesture {
mapView.addGestureRecognizer(tapGesture)
}
}
@objc internal func handleTap(sender: UITapGestureRecognizer) {
guard let mapView = mapView else {
assertionFailure("MapView is nil")
return
}
let point = sender.location(in: mapView)
/**
Using 44 x 44 points, the recommended touch target size from
Apple Human Interface Guidelines.
*/
let hitRect = CGRect(x: point.x - 22,
y: point.y - 22,
width: 44,
height: 44)
let annotationLayers: Set<String> = [defaultSymbolLayerId, defaultLineLayerId, defaultPolygonLayerId]
mapView.visibleFeatures(in: hitRect,
styleLayers: annotationLayers,
filter: nil,
completion: { [weak self] result in
guard let validSelf = self else { return }
if case .success(let features) = result {
if features.count == 0 { return }
guard let featureIdentifier = features[0].identifier?.value as? String else { return }
/**
If the found feature identifier exists in the internal
annotations dictionary, then we know we've found an annotation
and can notify the delegate.
*/
if let annotation = validSelf.annotations[featureIdentifier] {
validSelf.selectAnnotation(annotation)
}
}
})
}
// MARK: - Errors
// Annotation-related errors
public enum AnnotationError: Error {
// The Turf `Feature` could not be generated for the given annotation.
case featureGenerationFailed(String?)
// The annotation being added already exists.
case annotationAlreadyExists(String?)
// The annotation does not exist
case annotationDoesNotExist(String?)
// Generating the style layer for the annotation failed.
case styleLayerGenerationFailed(Error?)
// Adding the annotation failed.
case addAnnotationFailed(Error?)
// The annotation being removed does not exist.
case annotationAlreadyRemoved(String?)
// Removing the annotation failed.
case removeAnnotationFailed(String?)
// Updating the annotation failed.
case updateAnnotationFailed(Error?)
}
public func notify(for event: MapboxCoreMaps.Event) {
guard event.type == MapEvents.mapLoadingStarted else {
return
}
// Reset the annotation source and default layers.
annotations = [:]
annotationSource = nil
defaultSymbolLayer = nil
defaultLineLayer = nil
defaultPolygonLayer = nil
}
}