diff --git a/Apps/Examples/Examples/All Examples/CameraAnimatorsExample.swift b/Apps/Examples/Examples/All Examples/CameraAnimatorsExample.swift index 8adbbf15579..2df3a67f4c3 100644 --- a/Apps/Examples/Examples/All Examples/CameraAnimatorsExample.swift +++ b/Apps/Examples/Examples/All Examples/CameraAnimatorsExample.swift @@ -7,34 +7,39 @@ public class CameraAnimatorsExample: UIViewController, ExampleProtocol { internal var mapView: MapView! + // Coordinate in New York City + let newYork = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060) + // Store the CameraAnimators so that the do not fall out of scope. - lazy var zoomAnimator: CameraAnimator = { - let animator = mapView.camera.makeCameraAnimator(duration: 4, curve: .easeInOut) { [unowned self] in - self.mapView.zoom = 14 + lazy var zoomAnimator: BasicCameraAnimator = { + let animator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in + transition.zoom.toValue = 14 } animator.addCompletion { [unowned self] (_) in + print("Animating camera pitch from 0 degrees -> 55 degrees") self.pitchAnimator.startAnimation() } return animator }() - lazy var pitchAnimator: CameraAnimator = { - let animator = mapView.camera.makeCameraAnimator(duration: 2, curve: .easeInOut) { [unowned self] in - self.mapView.pitch = 55 + lazy var pitchAnimator: BasicCameraAnimator = { + let animator = mapView.camera.makeAnimator(duration: 2, curve: .easeInOut) { (transition) in + transition.pitch.toValue = 55 } animator.addCompletion { [unowned self] (_) in + print("Animating camera bearing from 0 degrees -> 45 degrees") self.bearingAnimator.startAnimation() } return animator }() - lazy var bearingAnimator: CameraAnimator = { - let animator = mapView.camera.makeCameraAnimator(duration: 4, curve: .easeInOut) { [unowned self] in - self.mapView.bearing = -45 + lazy var bearingAnimator: BasicCameraAnimator = { + let animator = mapView.camera.makeAnimator(duration: 4, curve: .easeInOut) { (transition) in + transition.bearing.toValue = -45 } animator.addCompletion { (_) in @@ -51,13 +56,17 @@ public class CameraAnimatorsExample: UIViewController, ExampleProtocol { mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(mapView) - // Center the map over New York City. - let newYork = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060) - mapView.camera.setCamera(to: CameraOptions(center: newYork)) + mapView.on(.styleLoaded) { [weak self] _ in + guard let self = self else { return } + // Center the map over New York City. + self.mapView.camera.setCamera(to: CameraOptions(center: self.newYork)) + } // Allows the delegate to receive information about map events. mapView.on(.mapLoaded) { [weak self] _ in guard let self = self else { return } + + print("Animating zoom from zoom lvl 3 -> zoom lvl 14") self.zoomAnimator.startAnimation(afterDelay: 1) self.finish() } diff --git a/Apps/Examples/Examples/All Examples/FlyToExample.swift b/Apps/Examples/Examples/All Examples/FlyToExample.swift index 0e2c3a85c4e..4962c664c83 100644 --- a/Apps/Examples/Examples/All Examples/FlyToExample.swift +++ b/Apps/Examples/Examples/All Examples/FlyToExample.swift @@ -6,7 +6,6 @@ import MapboxMaps public class FlyToExample: UIViewController, ExampleProtocol { internal var mapView: MapView! - internal var flyToAnimator: CameraAnimator? override public func viewDidLoad() { super.viewDidLoad() @@ -35,10 +34,9 @@ public class FlyToExample: UIViewController, ExampleProtocol { bearing: 180, pitch: 50) - flyToAnimator = self.mapView.camera.fly(to: end) { [weak self] _ in + mapView.camera.fly(to: end) { [weak self] _ in print("Camera fly-to finished") // The below line is used for internal testing purposes only. - self?.flyToAnimator = nil self?.finish() } diff --git a/Apps/Examples/Examples/All Examples/GeoJSONSourceExample.swift b/Apps/Examples/Examples/All Examples/GeoJSONSourceExample.swift index 6b80b67f8b1..3e72edcd672 100644 --- a/Apps/Examples/Examples/All Examples/GeoJSONSourceExample.swift +++ b/Apps/Examples/Examples/All Examples/GeoJSONSourceExample.swift @@ -17,8 +17,9 @@ public class GeoJSONSourceExample: UIViewController, ExampleProtocol { // Set the center coordinate and zoom level. let centerCoordinate = CLLocationCoordinate2D(latitude: 18.239785, longitude: -66.302490) - mapView.centerCoordinate = centerCoordinate - mapView.zoom = 6.9 + + mapView.camera.setCamera(to: CameraOptions(center: centerCoordinate, + zoom: 6.9)) // Allow the view controller to receive information about map events. mapView.on(.mapLoaded) { [weak self] _ in diff --git a/CHANGELOG.md b/CHANGELOG.md index bbee2aea293..5e3271c9de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,16 @@ Mapbox welcomes participation and contributions from everyone. * `Snapshotter` no longer conforms to `Observer`, and the method it required is now internal. * The `BaseMapView.__map` property has been moved to `BaseMapView.mapboxMap.__map`. ([#280](https://github.com/mapbox/mapbox-maps-ios/pull/280)) * A `CameraOptions` struct has been introduced. This shadows the class of the same name from MapboxCoreMaps and. This avoids unintended sharing and better reflects the intended value semantics of the `CameraOptions` concept. ([#284](https://github.com/mapbox/mapbox-maps-ios/pull/284)) + + #### Camera Animations + * A new `CameraTransition` struct has been introduced to allow better control on the "from" and "to" values of a camera animation ([#282](https://github.com/mapbox/mapbox-maps-ios/pull/282)) + * A mutable version of the `CameraTransition` struct is passed into every animation block. + * Animations can only be constructor injected into `CameraAnimator` as part of the `makeAnimator*` methods on `mapView.camera`. + * The `makeCameraAnimator*` methods have been renamed to `makeAnimator*` methods + + #### Gestures + - Gestures now directly call `__map.setCamera()` instead of using CoreAnimation + - #### Dependencies * Updated dependencies to MapboxCoreMaps 10.0.0-beta.20 and MapboxCommon 11.0.1 diff --git a/Mapbox/MapboxMaps.xcodeproj/project.pbxproj b/Mapbox/MapboxMaps.xcodeproj/project.pbxproj index 2d33aad4334..9f32702eddf 100644 --- a/Mapbox/MapboxMaps.xcodeproj/project.pbxproj +++ b/Mapbox/MapboxMaps.xcodeproj/project.pbxproj @@ -33,6 +33,10 @@ 07D7150425798CD70025BF61 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D7150325798CD70025BF61 /* UIImage.swift */; }; 07E816DA256D72E500ACFA73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07182014256C1F6100F22489 /* Assets.xcassets */; }; 0C01C0B925486E7200E4AA46 /* ExpressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C01C08825486E6100E4AA46 /* ExpressionTests.swift */; }; + 0C088AC026386D2700107B5E /* WeakCameraAnimatorSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C088ABF26386D2700107B5E /* WeakCameraAnimatorSet.swift */; }; + 0C088AC526387EA400107B5E /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C088AC426387EA400107B5E /* DateProvider.swift */; }; + 0C088ACB26388E3400107B5E /* CameraAnimationsManager+CameraAnimatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C088AC926388E3400107B5E /* CameraAnimationsManager+CameraAnimatorDelegate.swift */; }; + 0C088ACC26388E3400107B5E /* CameraAnimationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C088ACA26388E3400107B5E /* CameraAnimationsManager.swift */; }; 0C1AF560244E47C1008D2A10 /* GestureOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1AF559244DF4DF008D2A10 /* GestureOptions.swift */; }; 0C26425524EECD14001FE2E3 /* AllExpressions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C26425424EECD14001FE2E3 /* AllExpressions.swift */; }; 0C2CD9F625D19D75006D068F /* OptionsIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2CD9E225D19D30006D068F /* OptionsIntegrationTests.swift */; }; @@ -48,6 +52,14 @@ 0C32CA2425F982300057ED31 /* GeoJsonSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32CA1A25F982300057ED31 /* GeoJsonSourceTests.swift */; }; 0C32CA2725F982300057ED31 /* VectorSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32CA1B25F982300057ED31 /* VectorSourceTests.swift */; }; 0C32CA2A25F982300057ED31 /* ImageSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32CA1C25F982300057ED31 /* ImageSourceTests.swift */; }; + 0C350D83263278090090FA74 /* FlyToCameraAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D80263278090090FA74 /* FlyToCameraAnimator.swift */; }; + 0C350D84263278090090FA74 /* BasicCameraAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D81263278090090FA74 /* BasicCameraAnimator.swift */; }; + 0C350D85263278090090FA74 /* CameraTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D82263278090090FA74 /* CameraTransition.swift */; }; + 0C350D8E263278420090FA74 /* CameraTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D89263278420090FA74 /* CameraTransitionTests.swift */; }; + 0C350D8F263278420090FA74 /* CameraViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D8A263278420090FA74 /* CameraViewTests.swift */; }; + 0C350D90263278420090FA74 /* FlyToAnimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D8B263278420090FA74 /* FlyToAnimatorTests.swift */; }; + 0C350D91263278420090FA74 /* CameraViewMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D8C263278420090FA74 /* CameraViewMock.swift */; }; + 0C350D92263278420090FA74 /* UIViewPropertyAnimatorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C350D8D263278420090FA74 /* UIViewPropertyAnimatorMock.swift */; }; 0C35750225DD8D960085D775 /* StyleErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C3574EE25DD8D5C0085D775 /* StyleErrors.swift */; }; 0C35751725DD8E010085D775 /* StyleIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35751525DD8E010085D775 /* StyleIntegrationTests.swift */; }; 0C37B1E425CB05F000DCDD3D /* ColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C37B1E225CB05F000DCDD3D /* ColorTests.swift */; }; @@ -133,7 +145,6 @@ 0CD62F1024588530006421D1 /* GestureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC8BD2C242405AA00288A9B /* GestureManager.swift */; }; 0CD62F1124588532006421D1 /* GestureHandlerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC8BD2D242405AA00288A9B /* GestureHandlerDelegate.swift */; }; 0CD62F15245885B6006421D1 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F11C12E2421B23700F8397B /* MapView.swift */; }; - 0CD62F1624588647006421D1 /* CameraManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC8DA51243BECC400A19318 /* CameraManager.swift */; }; 0CD62F182458865A006421D1 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC8DA55243BED0500A19318 /* CameraView.swift */; }; 0CD62F1924588661006421D1 /* MapCameraOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C07948D2452331A0006A9C4 /* MapCameraOptions.swift */; }; 0CD62F1A245886E7006421D1 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF65A67244E34000069B561 /* LocationManager.swift */; }; @@ -188,10 +199,8 @@ B5A6921E262754DF00A03412 /* DelegatingMapClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A69219262754DF00A03412 /* DelegatingMapClientTests.swift */; }; B5A6921F262754DF00A03412 /* CredentialsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A6921A262754DF00A03412 /* CredentialsManagerTests.swift */; }; B5A6922B2627566A00A03412 /* MapInitOptionsTests.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5A6922A2627566A00A03412 /* MapInitOptionsTests.xib */; }; - B5B55D44260E4D1500EBB589 /* CameraAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D40260E4D1500EBB589 /* CameraAnimator.swift */; }; B5B55D45260E4D1500EBB589 /* AnimationOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D41260E4D1500EBB589 /* AnimationOwner.swift */; }; B5B55D46260E4D1500EBB589 /* CameraAnimationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D42260E4D1500EBB589 /* CameraAnimationDelegate.swift */; }; - B5B55D47260E4D1500EBB589 /* CameraManager+CameraAnimatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D43260E4D1500EBB589 /* CameraManager+CameraAnimatorDelegate.swift */; }; B5B55D4C260E4D2900EBB589 /* MBXEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D4B260E4D2900EBB589 /* MBXEdgeInsets.swift */; }; B5B55D51260E4D4500EBB589 /* GestureManager+GestureHandlerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D50260E4D4500EBB589 /* GestureManager+GestureHandlerDelegate.swift */; }; B5B55D5F260E4D7B00EBB589 /* CameraAnimatorDelegateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B55D56260E4D7500EBB589 /* CameraAnimatorDelegateMock.swift */; }; @@ -420,6 +429,10 @@ 07EEFA8C25018B8800352933 /* AnnotationManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnotationManagerTests.swift; sourceTree = ""; }; 0C01C08825486E6100E4AA46 /* ExpressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionTests.swift; sourceTree = ""; }; 0C07948D2452331A0006A9C4 /* MapCameraOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapCameraOptions.swift; sourceTree = ""; }; + 0C088ABF26386D2700107B5E /* WeakCameraAnimatorSet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakCameraAnimatorSet.swift; sourceTree = ""; }; + 0C088AC426387EA400107B5E /* DateProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateProvider.swift; sourceTree = ""; }; + 0C088AC926388E3400107B5E /* CameraAnimationsManager+CameraAnimatorDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraAnimationsManager+CameraAnimatorDelegate.swift"; sourceTree = ""; }; + 0C088ACA26388E3400107B5E /* CameraAnimationsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraAnimationsManager.swift; sourceTree = ""; }; 0C1AF559244DF4DF008D2A10 /* GestureOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureOptions.swift; sourceTree = ""; }; 0C26425424EECD14001FE2E3 /* AllExpressions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AllExpressions.swift; sourceTree = ""; }; 0C2CD9E225D19D30006D068F /* OptionsIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsIntegrationTests.swift; sourceTree = ""; }; @@ -435,6 +448,14 @@ 0C32CA1A25F982300057ED31 /* GeoJsonSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJsonSourceTests.swift; sourceTree = ""; }; 0C32CA1B25F982300057ED31 /* VectorSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VectorSourceTests.swift; sourceTree = ""; }; 0C32CA1C25F982300057ED31 /* ImageSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSourceTests.swift; sourceTree = ""; }; + 0C350D80263278090090FA74 /* FlyToCameraAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlyToCameraAnimator.swift; sourceTree = ""; }; + 0C350D81263278090090FA74 /* BasicCameraAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasicCameraAnimator.swift; sourceTree = ""; }; + 0C350D82263278090090FA74 /* CameraTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraTransition.swift; sourceTree = ""; }; + 0C350D89263278420090FA74 /* CameraTransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraTransitionTests.swift; sourceTree = ""; }; + 0C350D8A263278420090FA74 /* CameraViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraViewTests.swift; sourceTree = ""; }; + 0C350D8B263278420090FA74 /* FlyToAnimatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlyToAnimatorTests.swift; sourceTree = ""; }; + 0C350D8C263278420090FA74 /* CameraViewMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraViewMock.swift; sourceTree = ""; }; + 0C350D8D263278420090FA74 /* UIViewPropertyAnimatorMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPropertyAnimatorMock.swift; sourceTree = ""; }; 0C3574EE25DD8D5C0085D775 /* StyleErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleErrors.swift; sourceTree = ""; }; 0C35751525DD8E010085D775 /* StyleIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleIntegrationTests.swift; sourceTree = ""; }; 0C37B1E225CB05F000DCDD3D /* ColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTests.swift; sourceTree = ""; }; @@ -536,7 +557,6 @@ 1F11C12E2421B23700F8397B /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 1F2AD73C2421C462006592F4 /* GestureManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureManagerTests.swift; sourceTree = ""; }; 1FC8DA47243BE4A400A19318 /* MapboxMapsCameraTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapboxMapsCameraTests.swift; sourceTree = ""; }; - 1FC8DA51243BECC400A19318 /* CameraManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManager.swift; sourceTree = ""; }; 1FC8DA55243BED0500A19318 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; 1FECC9DF2474519D00B63910 /* Expression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Expression.swift; sourceTree = ""; }; 1FF65A67244E34000069B561 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; @@ -604,10 +624,8 @@ B5A69219262754DF00A03412 /* DelegatingMapClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DelegatingMapClientTests.swift; sourceTree = ""; }; B5A6921A262754DF00A03412 /* CredentialsManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManagerTests.swift; sourceTree = ""; }; B5A6922A2627566A00A03412 /* MapInitOptionsTests.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MapInitOptionsTests.xib; sourceTree = ""; }; - B5B55D40260E4D1500EBB589 /* CameraAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraAnimator.swift; sourceTree = ""; }; B5B55D41260E4D1500EBB589 /* AnimationOwner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationOwner.swift; sourceTree = ""; }; B5B55D42260E4D1500EBB589 /* CameraAnimationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraAnimationDelegate.swift; sourceTree = ""; }; - B5B55D43260E4D1500EBB589 /* CameraManager+CameraAnimatorDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraManager+CameraAnimatorDelegate.swift"; sourceTree = ""; }; B5B55D4B260E4D2900EBB589 /* MBXEdgeInsets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MBXEdgeInsets.swift; sourceTree = ""; }; B5B55D50260E4D4500EBB589 /* GestureManager+GestureHandlerDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GestureManager+GestureHandlerDelegate.swift"; sourceTree = ""; }; B5B55D55260E4D7500EBB589 /* CameraAnimatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraAnimatorTests.swift; sourceTree = ""; }; @@ -1147,6 +1165,7 @@ 1F11C1102421B05600F8397B /* MapboxMapsFoundation */ = { isa = PBXGroup; children = ( + 0C088AC426387EA400107B5E /* DateProvider.swift */, B521FC92262F626900B9A446 /* MapboxMap.swift */, B529341A2624E9D6003B181C /* DelegatingMapClient.swift */, B529341B2624E9D6003B181C /* DelegatingObserver.swift */, @@ -1222,13 +1241,16 @@ 1F4B7D7A2464C36E00745F05 /* Camera */ = { isa = PBXGroup; children = ( + 0C088ACA26388E3400107B5E /* CameraAnimationsManager.swift */, + 0C088AC926388E3400107B5E /* CameraAnimationsManager+CameraAnimatorDelegate.swift */, + 0C088ABF26386D2700107B5E /* WeakCameraAnimatorSet.swift */, + 0C350D81263278090090FA74 /* BasicCameraAnimator.swift */, + 0C350D82263278090090FA74 /* CameraTransition.swift */, + 0C350D80263278090090FA74 /* FlyToCameraAnimator.swift */, B5B55D41260E4D1500EBB589 /* AnimationOwner.swift */, B5B55D42260E4D1500EBB589 /* CameraAnimationDelegate.swift */, - B5B55D40260E4D1500EBB589 /* CameraAnimator.swift */, - B5B55D43260E4D1500EBB589 /* CameraManager+CameraAnimatorDelegate.swift */, 1FC8DA55243BED0500A19318 /* CameraView.swift */, 07729A5724623E9B00440187 /* CameraOptions.swift */, - 1FC8DA51243BECC400A19318 /* CameraManager.swift */, 0C07948D2452331A0006A9C4 /* MapCameraOptions.swift */, CA355C06256EEA7C00FD1DB7 /* FlyToInterpolator.swift */, ); @@ -1393,6 +1415,11 @@ A4FE887B255F364600FBF117 /* Camera */ = { isa = PBXGroup; children = ( + 0C350D89263278420090FA74 /* CameraTransitionTests.swift */, + 0C350D8C263278420090FA74 /* CameraViewMock.swift */, + 0C350D8A263278420090FA74 /* CameraViewTests.swift */, + 0C350D8B263278420090FA74 /* FlyToAnimatorTests.swift */, + 0C350D8D263278420090FA74 /* UIViewPropertyAnimatorMock.swift */, B5B8B74E26308AC300D936E5 /* CameraOptionsTests.swift */, B5B55D56260E4D7500EBB589 /* CameraAnimatorDelegateMock.swift */, B5B55D55260E4D7500EBB589 /* CameraAnimatorTests.swift */, @@ -1875,7 +1902,6 @@ CABCDF422620E09E00D61635 /* MapConfig.swift in Sources */, C64994AB258D5ADE0052C21C /* Puck.swift in Sources */, 0CD62F24245887CD006421D1 /* OrnamentSupportableView.swift in Sources */, - 0CD62F1624588647006421D1 /* CameraManager.swift in Sources */, CA355C07256EEA7C00FD1DB7 /* FlyToInterpolator.swift in Sources */, 0C708F4F24EB23C7003CE791 /* VectorSource.swift in Sources */, 0C946D1324DC5F8E003F114A /* Event.swift in Sources */, @@ -1907,6 +1933,7 @@ 0CD62F0724588501006421D1 /* PinchGestureHandler.swift in Sources */, 0782B17B258C236B00D5FCE5 /* MBXGeometry.swift in Sources */, B529341D2624E9D6003B181C /* DelegatingMapClient.swift in Sources */, + 0C350D85263278090090FA74 /* CameraTransition.swift in Sources */, 0C3B1E9224DDADD000CC29E8 /* MapboxMobileEvents+TelemetryProtocol.swift in Sources */, 0C9640E12531056700CABD3E /* LocationConsumer.swift in Sources */, 0C708F4D24EB23C7003CE791 /* RasterDemSource.swift in Sources */, @@ -1945,19 +1972,21 @@ 07BA35E825157A0E003E1B55 /* AnnotationSupportableMap.swift in Sources */, 0C55010B2476D83A00AE019A /* Light.swift in Sources */, B5B55D45260E4D1500EBB589 /* AnimationOwner.swift in Sources */, + 0C088ACC26388E3400107B5E /* CameraAnimationsManager.swift in Sources */, 0CD34FE8242AAE0900943687 /* MapView+Managers.swift in Sources */, - B5B55D44260E4D1500EBB589 /* CameraAnimator.swift in Sources */, CA6245D52627E72A00C79547 /* TileRegionLoadOptions+MapboxMaps.swift in Sources */, 5A9648FE246429C3001FF05D /* MapboxCompassOrnamentView.swift in Sources */, 0C8AA1F3257FE1830037FD6B /* MapEvents.swift in Sources */, B5B55D4C260E4D2900EBB589 /* MBXEdgeInsets.swift in Sources */, 0C479F90251CEC340025EC4F /* Snapshotter.swift in Sources */, 0CD62F182458865A006421D1 /* CameraView.swift in Sources */, - B5B55D47260E4D1500EBB589 /* CameraManager+CameraAnimatorDelegate.swift in Sources */, + 0C088AC526387EA400107B5E /* DateProvider.swift in Sources */, 07BA35CE251578DD003E1B55 /* AnnotationInteractionDelegate.swift in Sources */, 2B8637E62463F36400698135 /* DistanceFormatter.swift in Sources */, CA0C426926028ED30054D9D0 /* AnnotationOptions.swift in Sources */, 0C708F2F24EB1EE2003CE791 /* RasterLayer.swift in Sources */, + 0C088AC026386D2700107B5E /* WeakCameraAnimatorSet.swift in Sources */, + 0C088ACB26388E3400107B5E /* CameraAnimationsManager+CameraAnimatorDelegate.swift in Sources */, 0C35750225DD8D960085D775 /* StyleErrors.swift in Sources */, 0CE3D1B425816BD6000585A2 /* MapView+Supportable.swift in Sources */, 0C9DE369252C263600880CC8 /* GeoJSONSourceData.swift in Sources */, @@ -1974,6 +2003,7 @@ 0782B010258C051900D5FCE5 /* ScreenCoordinate.swift in Sources */, CABCDF372620E03A00D61635 /* CredentialsManager.swift in Sources */, 0782B11B258C1B9C00D5FCE5 /* Feature.swift in Sources */, + 0C350D84263278090090FA74 /* BasicCameraAnimator.swift in Sources */, C64B6FEA25E0479000C8E07E /* Bundle+MapboxMaps.swift in Sources */, 0782AFE6258BFD2300D5FCE5 /* CoreLocation.swift in Sources */, 2B8637D52461601100698135 /* MapboxScaleBarOrnamentView.swift in Sources */, @@ -1983,6 +2013,7 @@ 0706C49225B1128A008733C0 /* Terrain.swift in Sources */, CA03F1132626948300673961 /* StylePackLoadOptions+MapboxMaps.swift in Sources */, CABCDF4B2620E0D800D61635 /* Bool.swift in Sources */, + 0C350D83263278090090FA74 /* FlyToCameraAnimator.swift in Sources */, 0C88F22624F04D1600FC35F3 /* ExpressionBuilder.swift in Sources */, 1FECC9E02474519D00B63910 /* Expression.swift in Sources */, B52050A8260538240003E5BB /* ModelLayer.swift in Sources */, @@ -2030,7 +2061,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C350D8F263278420090FA74 /* CameraViewTests.swift in Sources */, CA03F08F2624FDCB00673961 /* MapInitOptionsIntegrationTests.swift in Sources */, + 0C350D91263278420090FA74 /* CameraViewMock.swift in Sources */, 0C5CFCDC25BB951B0001E753 /* ModelLayerTests.swift in Sources */, B5B55D5F260E4D7B00EBB589 /* CameraAnimatorDelegateMock.swift in Sources */, CA548FD3251C404B00F829A3 /* PolygonTests.swift in Sources */, @@ -2108,6 +2141,7 @@ 0CC6EF2625C3263400BFB153 /* HillshadeLayerIntegrationTests.swift in Sources */, CA548FEB251C404B00F829A3 /* MapboxMapsFoundationTests.swift in Sources */, CA548FEC251C404B00F829A3 /* MultiPolygonTests.swift in Sources */, + 0C350D92263278420090FA74 /* UIViewPropertyAnimatorMock.swift in Sources */, B5A6921C262754DF00A03412 /* MockDelegatingMapClientDelegate.swift in Sources */, CA548FED251C404B00F829A3 /* Geometry+MBXGeometryTests.swift in Sources */, 0C5CFDD525BE29BC0001E753 /* SouceProperties+Fixtures.swift in Sources */, @@ -2150,11 +2184,13 @@ CA548FFB251C404B00F829A3 /* StyleURITests.swift in Sources */, CA2E4A1B2538D3530096DEDE /* MapViewIntegrationTestCase.swift in Sources */, 0C32CA2A25F982300057ED31 /* ImageSourceTests.swift in Sources */, + 0C350D90263278420090FA74 /* FlyToAnimatorTests.swift in Sources */, B521FC9D262F665C00B9A446 /* SizeTests.swift in Sources */, 0C5CFCD625BB951B0001E753 /* FillExtrusionLayerTests.swift in Sources */, B54B7F2125DB1ABA003FD6CA /* Stub.swift in Sources */, 0C01C0B925486E7200E4AA46 /* ExpressionTests.swift in Sources */, CA548FFC251C404B00F829A3 /* PanGestureHandlerTests.swift in Sources */, + 0C350D8E263278420090FA74 /* CameraTransitionTests.swift in Sources */, CA548FFD251C404B00F829A3 /* QuickZoomGestureHandlerTests.swift in Sources */, 0C9DE3B3252C2A1F00880CC8 /* GeoJSONSourceDataTests.swift in Sources */, ); diff --git a/Sources/MapboxMaps/Foundation/BaseMapView.swift b/Sources/MapboxMaps/Foundation/BaseMapView.swift index 9d72a9abd33..44ea640a694 100644 --- a/Sources/MapboxMaps/Foundation/BaseMapView.swift +++ b/Sources/MapboxMaps/Foundation/BaseMapView.swift @@ -4,11 +4,9 @@ import UIKit import Turf // swiftlint:disable file_length - internal typealias PendingAnimationCompletion = (completion: AnimationCompletion, animatingPosition: UIViewAnimatingPosition) -// swiftlint:disable:next type_body_length -open class BaseMapView: UIView, CameraViewDelegate { +open class BaseMapView: UIView { // mapbox map depends on MapInitOptions, which is not available until // awakeFromNib() when instantiating BaseMapView from a xib or storyboard. @@ -32,6 +30,14 @@ open class BaseMapView: UIView, CameraViewDelegate { /// List of completion blocks that need to be completed by the displayLink internal var pendingAnimatorCompletionBlocks: [PendingAnimationCompletion] = [] + /// Pointer HashTable for holding camera animators + private var cameraAnimatorsSet = WeakCameraAnimatorSet() + + /// List of animators currently alive + internal var cameraAnimators: [CameraAnimator] { + return cameraAnimatorsSet.allObjects + } + /// Map of event types to subscribed event handlers private var eventHandlers: [String: [(MapboxCoreMaps.Event) -> Void]] = [:] @@ -52,87 +58,53 @@ open class BaseMapView: UIView, CameraViewDelegate { } } - /// Returns the camera view managed by this object. - internal private(set) var cameraView: CameraView! - /// The map's current camera public var cameraOptions: CameraOptions { - get { - return mapboxMap.cameraOptions - } set { - cameraView.camera = newValue - } + return mapboxMap.cameraOptions } /// The map's current center coordinate. public var centerCoordinate: CLLocationCoordinate2D { - get { - guard let center = cameraOptions.center else { - fatalError("Center is nil in camera options") - } - return center - } set { - cameraView.centerCoordinate = newValue + guard let center = cameraOptions.center else { + fatalError("Center is nil in camera options") } + return center } - /// The map's zoom level. + /// The map's current zoom level. public var zoom: CGFloat { - get { - guard let zoom = cameraOptions.zoom else { - fatalError("Zoom is nil in camera options") - } - return CGFloat(zoom) - } set { - cameraView.zoom = newValue + guard let zoom = cameraOptions.zoom else { + fatalError("Zoom is nil in camera options") } + return CGFloat(zoom) } - /// The map's bearing, measured clockwise from 0° north. + /// The map's current bearing, measured clockwise from 0° north. public var bearing: CLLocationDirection { - get { - guard let bearing = cameraOptions.bearing else { - fatalError("Bearing is nil in camera options") - } - return CLLocationDirection(bearing) - } set { - cameraView.bearing = CGFloat(newValue) + guard let bearing = cameraOptions.bearing else { + fatalError("Bearing is nil in camera options") } + return CLLocationDirection(bearing) } - /// The map's pitch, falling within a range of 0 to 60. + /// The map's current pitch, falling within a range of 0 to 60. public var pitch: CGFloat { - get { - guard let pitch = cameraOptions.pitch else { - fatalError("Pitch is nil in camera options") - } - - return pitch - } set { - cameraView.pitch = newValue - } - } - - /// The map's camera padding - public var padding: UIEdgeInsets { - get { - return cameraOptions.padding ?? .zero - } set { - cameraView.padding = newValue + guard let pitch = cameraOptions.pitch else { + fatalError("Pitch is nil in camera options") } + return pitch } + /// The map's current anchor, calculated after applying padding (if it exists) public var anchor: CGPoint { - get { - // TODO: Evaluate whether we should get the anchor from CameraView or not - return cameraView.anchor - } set { - cameraView.anchor = newValue - } + let xAfterPadding = center.x + padding.left - padding.right + let yAfterPadding = center.y + padding.top - padding.bottom + return CGPoint(x: xAfterPadding, y: yAfterPadding) } - func jumpTo(camera: CameraOptions) { - mapboxMap.updateCamera(with: camera) + /// The map's camera padding + public var padding: UIEdgeInsets { + return cameraOptions.padding ?? .zero } // MARK: Init @@ -173,9 +145,6 @@ open class BaseMapView: UIView, CameraViewDelegate { let events = MapEvents.EventKind.allCases.map({ $0.rawValue }) mapboxMap.__map.subscribe(for: observer, events: events) - self.cameraView = CameraView(delegate: self) - self.addSubview(cameraView) - NotificationCenter.default.addObserver(self, selector: #selector(willTerminate), name: UIApplication.willTerminateNotification, @@ -256,7 +225,12 @@ open class BaseMapView: UIView, CameraViewDelegate { if needsDisplayRefresh { needsDisplayRefresh = false - self.cameraView.update() + + for animator in cameraAnimatorsSet.allObjects { + if let cameraOptions = animator.currentCameraOptions { + mapboxMap.updateCamera(with: cameraOptions) + } + } /// This executes the series of scheduled animation completion blocks and also removes them from the list while !pendingAnimatorCompletionBlocks.isEmpty { @@ -270,6 +244,11 @@ open class BaseMapView: UIView, CameraViewDelegate { } } + // Add an animator to the `cameraAnimatorsSet` + internal func addCameraAnimator(_ cameraAnimator: CameraAnimatorInterface) { + cameraAnimatorsSet.add(cameraAnimator) + } + func updateDisplayLinkPreferredFramesPerSecond() { if let displayLink = displayLink { diff --git a/Sources/MapboxMaps/Foundation/Camera/AnimationOwner.swift b/Sources/MapboxMaps/Foundation/Camera/AnimationOwner.swift index d5496d9dff0..e16377a9df9 100644 --- a/Sources/MapboxMaps/Foundation/Camera/AnimationOwner.swift +++ b/Sources/MapboxMaps/Foundation/Camera/AnimationOwner.swift @@ -1,5 +1,5 @@ // MARK: AnimationOwner Enum -public enum AnimationOwner { +public enum AnimationOwner: Equatable { case gestures case unspecified case custom(id: String) diff --git a/Sources/MapboxMaps/Foundation/Camera/BasicCameraAnimator.swift b/Sources/MapboxMaps/Foundation/Camera/BasicCameraAnimator.swift new file mode 100644 index 00000000000..a404cdceebf --- /dev/null +++ b/Sources/MapboxMaps/Foundation/Camera/BasicCameraAnimator.swift @@ -0,0 +1,182 @@ +import UIKit +import CoreLocation + +// MARK: CameraAnimator Class +public class BasicCameraAnimator: NSObject, CameraAnimator, CameraAnimatorInterface { + + /// Instance of the property animator that will run animations. + private let propertyAnimator: UIViewPropertyAnimator + + /// Delegate that conforms to `CameraAnimatorDelegate`. + internal private(set) weak var delegate: CameraAnimatorDelegate? + + /// The ID of the owner of this `CameraAnimator`. + private let owner: AnimationOwner + + /// The `CameraView` owned by this animator + private let cameraView: CameraView + + /// Represents the animation that this animator is attempting to execute + private var animation: ((inout CameraTransition) -> Void)? + + /// Defines the transition that will occur to the `CameraOptions` of the renderer due to this animator + public private(set) var transition: CameraTransition? + + /// A timer used to delay the start of an animation + private var delayedAnimationTimer: Timer? + + /// The state from of the animator. + public var state: UIViewAnimatingState { propertyAnimator.state } + + /// Boolean that represents if the animation is running or not. + public var isRunning: Bool { propertyAnimator.isRunning } + + /// Boolean that represents if the animation is running normally or in reverse. + public var isReversed: Bool { propertyAnimator.isReversed } + + /// A Boolean value that indicates whether a completed animation remains in the active state. + public var pausesOnCompletion: Bool { + get { propertyAnimator.pausesOnCompletion} + set { propertyAnimator.pausesOnCompletion = newValue } + } + + /// Value that represents what percentage of the animation has been completed. + public var fractionComplete: Double { + get { Double(propertyAnimator.fractionComplete) } + set { propertyAnimator.fractionComplete = CGFloat(newValue) } + } + + // MARK: Initializer + internal init(delegate: CameraAnimatorDelegate, + propertyAnimator: UIViewPropertyAnimator, + owner: AnimationOwner, + cameraView: CameraView = CameraView()) { + self.delegate = delegate + self.propertyAnimator = propertyAnimator + self.owner = owner + self.cameraView = cameraView + } + + deinit { + propertyAnimator.stopAnimation(false) + propertyAnimator.finishAnimation(at: .current) + cameraView.removeFromSuperview() + delayedAnimationTimer?.invalidate() + } + + /// Starts the animation if this animator is in `inactive` state. Also used to resume a "paused" animation. + public func startAnimation() { + + if self.state != .active { + + guard let delegate = delegate else { + fatalError("CameraAnimator delegate cannot be nil when starting an animation") + } + + guard let animation = animation else { + fatalError("Animation cannot be nil when starting an animation") + } + + // Set up the short lived camera view + delegate.addViewToViewHeirarchy(cameraView) + + var cameraTransition = CameraTransition(cameraOptions: delegate.camera, initialAnchor: delegate.anchorAfterPadding()) + animation(&cameraTransition) + + propertyAnimator.addAnimations { [weak cameraView] in + guard let cameraView = cameraView else { return } + cameraView.syncLayer(to: cameraTransition.toCameraOptions) // Set up the "to" values for the interpolation + } + + cameraView.syncLayer(to: cameraTransition.fromCameraOptions) // Set up the "from" values for the interpoloation + transition = cameraTransition // Store the mutated camera transition + } + + propertyAnimator.startAnimation() + } + + /// Starts the animation after a delay + /// - Parameter delay: Delay (in seconds) after which the animation should start + public func startAnimation(afterDelay delay: TimeInterval) { + delayedAnimationTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { [weak self] (_) in + self?.startAnimation() + }) + } + + /// Pauses the animation. + public func pauseAnimation() { + propertyAnimator.pauseAnimation() + } + + /// Stops the animation. + public func stopAnimation() { + propertyAnimator.stopAnimation(false) + propertyAnimator.finishAnimation(at: .current) + } + + /// Add animations block to the animator. + internal func addAnimations(_ animations: @escaping (inout CameraTransition) -> Void) { + animation = animations + } + + /// Add a completion block to the animator. + public func addCompletion(_ completion: @escaping AnimationCompletion) { + let wrappedCompletion = wrapCompletion(completion) + propertyAnimator.addCompletion(wrappedCompletion) + } + + internal func wrapCompletion(_ completion: @escaping AnimationCompletion) -> (UIViewAnimatingPosition) -> Void { + return { [weak self] animationPosition in + guard let self = self else { return } + self.transition = nil // Clear out the transition being animated by this animator since the animation is complete if we are here. + self.delegate?.schedulePendingCompletion(forAnimator: self, completion: completion, animatingPosition: animationPosition) + + // Invalidate the delayed animation timer if it exists + self.delayedAnimationTimer?.invalidate() + self.delayedAnimationTimer = nil + } + } + + /// Continue the animation with a timing parameter (`UITimingCurveProvider`) and duration factor (`CGFloat`). + public func continueAnimation(withTimingParameters parameters: UITimingCurveProvider?, durationFactor: Double) { + propertyAnimator.continueAnimation(withTimingParameters: parameters, durationFactor: CGFloat(durationFactor)) + } + + internal var currentCameraOptions: CameraOptions? { + + // Only call jumpTo if this animator is currently "active" and there are known changes to animate. + guard propertyAnimator.state == .active, + let transition = transition else { + return nil + } + + var cameraOptions = CameraOptions() + let interpolatedCamera = cameraView.localCamera + + if transition.center.toValue != nil { + cameraOptions.center = interpolatedCamera.center?.wrap() // Wraps to [-180, +180] + } + + if transition.bearing.toValue != nil { + cameraOptions.bearing = interpolatedCamera.bearing + } + + if transition.anchor.toValue != nil { + cameraOptions.anchor = interpolatedCamera.anchor + } + + if transition.padding.toValue != nil { + cameraOptions.padding = interpolatedCamera.padding + } + + if transition.zoom.toValue != nil { + cameraOptions.zoom = interpolatedCamera.zoom + } + + if transition.pitch.toValue != nil { + cameraOptions.pitch = interpolatedCamera.pitch + } + + return cameraOptions + } +} diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraAnimationDelegate.swift b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationDelegate.swift index 9993040d28e..e3a4cf57398 100644 --- a/Sources/MapboxMaps/Foundation/Camera/CameraAnimationDelegate.swift +++ b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationDelegate.swift @@ -6,13 +6,20 @@ public typealias AnimationCompletion = (UIViewAnimatingPosition) -> Void // MARK: CameraAnimatorDelegate Protocol internal protocol CameraAnimatorDelegate: class { - /** - This delegate function notifies that the completion block needs to be scheduled + /// The current camera of the map + var camera: CameraOptions { get } - - Parameter animator: The current animator that this delegate function is being called from - - Parameter completion: The completion block that needs to be scheduled - - Parameter animatingPosition The position of the animation needed for the closure - */ + /// Adds the view to the MapView's subviews + func addViewToViewHeirarchy(_ view: CameraView) + + /// Calculates the anchor after taking padding into consideration + func anchorAfterPadding() -> CGPoint + + /// This delegate function notifies that the completion block needs to be scheduled on the next tick of the displaylink + /// - Parameters: + /// - animator: The current animator that this delegate function is being called from + /// - completion: The completion block that needs to be scheduled + /// - animatingPosition: The position of the animation needed for the closure func schedulePendingCompletion(forAnimator animator: CameraAnimator, completion: @escaping AnimationCompletion, animatingPosition: UIViewAnimatingPosition) diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraManager+CameraAnimatorDelegate.swift b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager+CameraAnimatorDelegate.swift similarity index 64% rename from Sources/MapboxMaps/Foundation/Camera/CameraManager+CameraAnimatorDelegate.swift rename to Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager+CameraAnimatorDelegate.swift index d155e5be882..10a0cca61cf 100644 --- a/Sources/MapboxMaps/Foundation/Camera/CameraManager+CameraAnimatorDelegate.swift +++ b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager+CameraAnimatorDelegate.swift @@ -1,7 +1,7 @@ import UIKit // MARK: Camera Animation -extension CameraManager: CameraAnimatorDelegate { +extension CameraAnimationsManager: CameraAnimatorDelegate { // MARK: Animator Functions @@ -15,12 +15,14 @@ extension CameraManager: CameraAnimatorDelegate { /// - timingParameters: The object providing the timing information. This object must adopt the `UITimingCurveProvider` protocol. /// - animationOwner: Property that conforms to `AnimationOwnerProtocol` to represent who owns that animation. /// - Returns: A class that represents an animator with the provided configuration. - public func makeCameraAnimator(duration: TimeInterval, - timingParameters parameters: UITimingCurveProvider, - animationOwner: AnimationOwner = .unspecified) -> CameraAnimator { + public func makeAnimator(duration: TimeInterval, + timingParameters parameters: UITimingCurveProvider, + animationOwner: AnimationOwner = .unspecified, + animations: @escaping (inout CameraTransition) -> Void) -> BasicCameraAnimator { let propertyAnimator = UIViewPropertyAnimator(duration: duration, timingParameters: parameters) - let cameraAnimator = CameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) - cameraAnimators.add(cameraAnimator) + let cameraAnimator = BasicCameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) + cameraAnimator.addAnimations(animations) + mapView?.addCameraAnimator(cameraAnimator) return cameraAnimator } @@ -37,13 +39,14 @@ extension CameraManager: CameraAnimatorDelegate { /// Use this block to modify any animatable view properties. When you start the animations, /// those properties are animated from their current values to the new values using the specified animation parameters. /// - Returns: A class that represents an animator with the provided configuration. - public func makeCameraAnimator(duration: TimeInterval, - curve: UIView.AnimationCurve, - animationOwner: AnimationOwner = .unspecified, - animations: (() -> Void)? = nil) -> CameraAnimator { - let propertyAnimator = UIViewPropertyAnimator(duration: duration, curve: curve, animations: animations) - let cameraAnimator = CameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) - cameraAnimators.add(cameraAnimator) + public func makeAnimator(duration: TimeInterval, + curve: UIView.AnimationCurve, + animationOwner: AnimationOwner = .unspecified, + animations: @escaping (inout CameraTransition) -> Void) -> BasicCameraAnimator { + let propertyAnimator = UIViewPropertyAnimator(duration: duration, curve: curve) + let cameraAnimator = BasicCameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) + cameraAnimator.addAnimations(animations) + mapView?.addCameraAnimator(cameraAnimator) return cameraAnimator } @@ -61,14 +64,15 @@ extension CameraManager: CameraAnimatorDelegate { /// Use this block to modify any animatable view properties. When you start the animations, /// those properties are animated from their current values to the new values using the specified animation parameters. /// - Returns: A class that represents an animator with the provided configuration. - public func makeCameraAnimator(duration: TimeInterval, - controlPoint1 point1: CGPoint, - controlPoint2 point2: CGPoint, - animationOwner: AnimationOwner = .unspecified, - animations: (() -> Void)? = nil) -> CameraAnimator { - let propertyAnimator = UIViewPropertyAnimator(duration: duration, controlPoint1: point1, controlPoint2: point2, animations: animations) - let cameraAnimator = CameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) - cameraAnimators.add(cameraAnimator) + public func makeAnimator(duration: TimeInterval, + controlPoint1 point1: CGPoint, + controlPoint2 point2: CGPoint, + animationOwner: AnimationOwner = .unspecified, + animations: @escaping (inout CameraTransition) -> Void) -> BasicCameraAnimator { + let propertyAnimator = UIViewPropertyAnimator(duration: duration, controlPoint1: point1, controlPoint2: point2) + let cameraAnimator = BasicCameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) + cameraAnimator.addAnimations(animations) + mapView?.addCameraAnimator(cameraAnimator) return cameraAnimator } @@ -86,13 +90,14 @@ extension CameraManager: CameraAnimatorDelegate { /// Use this block to modify any animatable view properties. When you start the animations, /// those properties are animated from their current values to the new values using the specified animation parameters. /// - Returns: A class that represents an animator with the provided configuration. - public func makeCameraAnimator(duration: TimeInterval, - dampingRatio ratio: CGFloat, - animationOwner: AnimationOwner = .unspecified, - animations: (() -> Void)? = nil) -> CameraAnimator { - let propertyAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: ratio, animations: animations) - let cameraAnimator = CameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) - cameraAnimators.add(cameraAnimator) + public func makeAnimator(duration: TimeInterval, + dampingRatio ratio: CGFloat, + animationOwner: AnimationOwner = .unspecified, + animations: @escaping (inout CameraTransition) -> Void) -> BasicCameraAnimator { + let propertyAnimator = UIViewPropertyAnimator(duration: duration, dampingRatio: ratio) + let cameraAnimator = BasicCameraAnimator(delegate: self, propertyAnimator: propertyAnimator, owner: animationOwner) + cameraAnimator.addAnimations(animations) + mapView?.addCameraAnimator(cameraAnimator) return cameraAnimator } @@ -101,4 +106,31 @@ extension CameraManager: CameraAnimatorDelegate { guard let mapView = mapView else { return } mapView.pendingAnimatorCompletionBlocks.append((completion, animatingPosition)) } + + var camera: CameraOptions { + guard let validMapView = mapView else { + fatalError("MapView cannot be nil.") + } + + return validMapView.cameraOptions + } + + func addViewToViewHeirarchy(_ view: CameraView) { + + guard let validMapView = mapView else { + fatalError("MapView cannot be nil.") + } + + validMapView.addSubview(view) + + } + + func anchorAfterPadding() -> CGPoint { + + guard let validMapView = mapView else { + fatalError("MapView cannot be nil.") + } + + return validMapView.anchor + } } diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraManager.swift b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager.swift similarity index 61% rename from Sources/MapboxMaps/Foundation/Camera/CameraManager.swift rename to Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager.swift index 7f157ea1714..a81fe84d032 100644 --- a/Sources/MapboxMaps/Foundation/Camera/CameraManager.swift +++ b/Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager.swift @@ -1,12 +1,35 @@ import UIKit import Turf +public protocol CameraAnimator { + + /// Stops the animation in its tracks and calls any provided completion + func stopAnimation() + + /// The current state of the animation + var state: UIViewAnimatingState { get } +} + +/// Internal-facing protocol to represent camera animators +internal protocol CameraAnimatorInterface: CameraAnimator { + var currentCameraOptions: CameraOptions? { get } +} + /// An object that manages a camera's view lifecycle. -public class CameraManager { +public class CameraAnimationsManager { /// Used to set up camera specific configuration public internal(set) var mapCameraOptions: MapCameraOptions + /// List of animators currently alive + public var cameraAnimators: [CameraAnimator] { + guard let mapView = mapView else { + return [] + } + + return mapView.cameraAnimators + } + /// Used to update the map's camera options and pass them to the core Map. internal func updateMapCameraOptions(newOptions: MapCameraOptions) { let boundOptions = BoundOptions(__bounds: newOptions.restrictedCoordinateBounds ?? nil, @@ -18,16 +41,8 @@ public class CameraManager { mapCameraOptions = newOptions } - /// Pointer HashTable for holding camera animators - internal var cameraAnimators = NSHashTable.weakObjects() - - /// List of animators currently alive - public var cameraAnimatorsList: [CameraAnimator] { - return cameraAnimators.allObjects - } - /// Internal camera animator used for animated transition - internal var internalCameraAnimator: CameraAnimator? + internal var internalAnimator: CameraAnimator? /// May want to convert to an enum. fileprivate let northBearing: CGFloat = 0 @@ -55,16 +70,16 @@ public class CameraManager { let coordinateLocations = coordinates.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } // Construct new camera options with current values - let cameraOptions = MapboxCoreMaps.CameraOptions(mapView.cameraView.camera) + let cameraOptions = MapboxCoreMaps.CameraOptions(mapView.cameraOptions) let defaultEdgeInsets = EdgeInsets(top: 0, left: 0, bottom: 0, right: 0) // Create a new camera options with adjusted values return CameraOptions(mapView.mapboxMap.__map.cameraForCoordinates( - forCoordinates: coordinateLocations, - padding: cameraOptions.__padding ?? defaultEdgeInsets, - bearing: cameraOptions.__bearing, - pitch: cameraOptions.__pitch)) + forCoordinates: coordinateLocations, + padding: cameraOptions.__padding ?? defaultEdgeInsets, + bearing: cameraOptions.__bearing, + pitch: cameraOptions.__pitch)) } /// Returns the camera that best fits the given coordinate bounds, with optional edge padding, bearing, and pitch values. @@ -83,10 +98,10 @@ public class CameraManager { } return CameraOptions(mapView.mapboxMap.__map.cameraForCoordinateBounds( - for: coordinateBounds, - padding: edgePadding.toMBXEdgeInsetsValue(), - bearing: NSNumber(value: Float(bearing)), - pitch: NSNumber(value: Float(pitch)))) + for: coordinateBounds, + padding: edgePadding.toMBXEdgeInsetsValue(), + bearing: NSNumber(value: Float(bearing)), + pitch: NSNumber(value: Float(pitch)))) } /// Returns the camera that best fits the given geometry, with optional edge padding, bearing, and pitch values. @@ -106,10 +121,10 @@ public class CameraManager { } return CameraOptions(mapView.mapboxMap.__map.cameraForGeometry( - for: MBXGeometry(geometry: geometry), - padding: edgePadding.toMBXEdgeInsetsValue(), - bearing: NSNumber(value: Float(bearing)), - pitch: NSNumber(value: Float(pitch)))) + for: MBXGeometry(geometry: geometry), + padding: edgePadding.toMBXEdgeInsetsValue(), + bearing: NSNumber(value: Float(bearing)), + pitch: NSNumber(value: Float(pitch)))) } /// Returns the coordinate bounds for a given `Camera` object's viewport. @@ -140,11 +155,13 @@ public class CameraManager { return } + internalAnimator?.stopAnimation() + let clampedCamera = CameraOptions(center: targetCamera.center, padding: targetCamera.padding, anchor: targetCamera.anchor, zoom: targetCamera.zoom?.clamped(to: mapCameraOptions.minimumZoomLevel...mapCameraOptions.maximumZoomLevel), - bearing: optimizeBearing(startBearing: mapView.cameraView.localBearing, endBearing: targetCamera.bearing), + bearing: targetCamera.bearing, pitch: targetCamera.pitch?.clamped(to: mapCameraOptions.minimumPitch...mapCameraOptions.maximumPitch)) // Return early if the cameraView's camera is already at `clampedCamera` @@ -152,19 +169,28 @@ public class CameraManager { return } - let transitionBlock = { - mapView.cameraOptions = clampedCamera - } - if animated && duration > 0 { - performCameraAnimation(duration: duration, animation: transitionBlock, completion: completion) + let animation = { (transition: inout CameraTransition) in + transition.center.toValue = clampedCamera.center + transition.padding.toValue = clampedCamera.padding + transition.anchor.toValue = clampedCamera.anchor + transition.zoom.toValue = clampedCamera.zoom + transition.bearing.toValue = clampedCamera.bearing + transition.pitch.toValue = clampedCamera.pitch + } + performCameraAnimation(duration: duration, animation: animation, completion: completion) } else { - transitionBlock() + mapView.mapboxMap.updateCamera(with: clampedCamera) } } + /// Interrupts all `active` animation. + /// The camera remains at the last point before the cancel request was invoked, i.e., + /// the camera is not reset or fast-forwarded to the end of the transition. + /// Canceled animations cannot be restarted / resumed. The animator must be recreated. public func cancelAnimations() { - for animator in cameraAnimators.allObjects where animator.state == .active { + guard let validMapView = mapView else { return } + for animator in validMapView.cameraAnimators { animator.stopAnimation() } } @@ -174,25 +200,30 @@ public class CameraManager { /// - duration: If animated, how long the animation takes /// - animation: closure to perform /// - completion: animation block called on completion - fileprivate func performCameraAnimation(duration: TimeInterval, animation: @escaping () -> Void, completion: ((UIViewAnimatingPosition) -> Void)? = nil) { + fileprivate func performCameraAnimation(duration: TimeInterval, + animation: @escaping (inout CameraTransition) -> Void, + completion: ((UIViewAnimatingPosition) -> Void)? = nil) { // Stop previously running animations - internalCameraAnimator?.stopAnimation() + internalAnimator?.stopAnimation() // Make a new camera animator for the new properties - internalCameraAnimator = makeCameraAnimator(duration: duration, - curve: .easeOut, - animationOwner: .custom(id: "com.mapbox.maps.cameraManager"), - animations: animation) + + let cameraAnimator = makeAnimator(duration: duration, + curve: .easeOut, + animationOwner: .custom(id: "com.mapbox.maps.cameraManager"), + animations: animation) // Add completion - internalCameraAnimator?.addCompletion({ [weak self] (position) in + cameraAnimator.addCompletion({ (position) in completion?(position) - self?.internalCameraAnimator = nil }) // Start animation - internalCameraAnimator?.startAnimation() + cameraAnimator.startAnimation() + + // Store the animator in order to keep it alive + internalAnimator = cameraAnimator } /// Moves the viewpoint to a different location using a transition animation that @@ -200,96 +231,77 @@ public class CameraManager { /// It seamlessly incorporates zooming and panning to help /// the user find his or her bearings even after traversing a great distance. /// - /// NOTE: Keep in mind the lifecycle of the `CameraAnimator` returned by this method. - /// If a `CameraAnimator` is destroyed, before the animation is finished, - /// the animation will be interrupted and completion handlers will be called. - /// /// - Parameters: /// - camera: The camera options at the end of the animation. Any camera parameters that are nil will not be animated. /// - duration: Duration of the animation, measured in seconds. If nil, a suitable calculated duration is used. /// - completion: Completion handler called when the animation stops - /// - Returns: The optional `CameraAnimator` that will execute the FlyTo animation + /// - Returns: An instance of `CameraAnimatorProtocol` which can be interrupted if necessary + @discardableResult public func fly(to camera: CameraOptions, duration: TimeInterval? = nil, completion: AnimationCompletion? = nil) -> CameraAnimator? { - guard let mapView = mapView else { + guard let mapView = mapView, + let flyToAnimator = FlyToCameraAnimator( + inital: mapView.cameraOptions, + final: camera, + owner: .custom(id: "fly-to"), + duration: duration, + mapSize: mapView.mapboxMap.size, + delegate: self) else { + Log.warning(forMessage: "Unable to start fly-to animation", category: "CameraManager") return nil } - // Stop the `internalCameraAnimator` before beginning a `flyTo` - internalCameraAnimator?.stopAnimation() - - guard let interpolator = FlyToInterpolator(from: mapView.cameraOptions, - to: camera, - size: mapView.bounds.size) else { - return nil - } + // Stop the `internalAnimator` before beginning a `flyTo` + internalAnimator?.stopAnimation() - // If there was no duration specified, use a default - let time: TimeInterval = duration ?? interpolator.duration() + mapView.addCameraAnimator(flyToAnimator) - // TODO: Consider timesteps based on the flyTo curve, for example, it would be beneficial to have a higher - // density of time steps at towards the start and end of the animation to avoid jiggling. - let timeSteps = stride(from: 0.0, through: 1.0, by: 0.025) - let keyTimes: [Double] = Array(timeSteps) - - let animator = makeCameraAnimator(duration: time, curve: .linear) { - - UIView.animateKeyframes(withDuration: 0, delay: 0, options: []) { - - for keyTime in keyTimes { - let interpolatedCoordinate = interpolator.coordinate(at: keyTime) - let interpolatedZoom = interpolator.zoom(at: keyTime) - let interpolatedBearing = interpolator.bearing(at: keyTime) - let interpolatedPitch = interpolator.pitch(at: keyTime) - - UIView.addKeyframe(withRelativeStartTime: keyTime, relativeDuration: 0.025) { - self.mapView?.cameraView.centerCoordinate = interpolatedCoordinate - self.mapView?.cameraView.zoom = CGFloat(interpolatedZoom) - self.mapView?.cameraView.bearing = CGFloat(interpolatedBearing) - self.mapView?.cameraView.pitch = CGFloat(interpolatedPitch) - } - } - } - } - - if let completion = completion { - animator.addCompletion(completion) + // Nil out the internalAnimator after `flyTo` finishes + flyToAnimator.addCompletion { (position) in + // Call the developer-provided completion (if present) + completion?(position) } - animator.startAnimation() - - return animator + flyToAnimator.startAnimation() + internalAnimator = flyToAnimator + return internalAnimator } - /// This function optimizes the bearing for set camera so that it is taking the shortest path. + /// Ease the camera to a destination /// - Parameters: - /// - startBearing: The current or start bearing of the map viewport. - /// - endBearing: The bearing of where the map viewport should end at. - /// - Returns: A `CLLocationDirection` that represents the correct final bearing accounting for positive and negatives. - internal func optimizeBearing(startBearing: CLLocationDirection?, endBearing: CLLocationDirection?) -> CLLocationDirection? { - // This modulus is required to account for larger values - guard - let startBearing = startBearing?.truncatingRemainder(dividingBy: 360.0), - let endBearing = endBearing?.truncatingRemainder(dividingBy: 360.0) - else { - return nil + /// - camera: the target camera after animation + /// - duration: duration of the animation + /// - completion: completion to be called after animation + /// - Returns: An instance of `CameraAnimatorProtocol` which can be interrupted if necessary + @discardableResult + public func ease(to camera: CameraOptions, + duration: TimeInterval, + completion: AnimationCompletion? = nil) -> CameraAnimator? { + + internalAnimator?.stopAnimation() + + let animator = makeAnimator(duration: duration, curve: .easeInOut) { (transition) in + transition.center.toValue = camera.center + transition.padding.toValue = camera.padding + transition.anchor.toValue = camera.anchor + transition.zoom.toValue = camera.zoom + transition.bearing.toValue = camera.bearing + transition.pitch.toValue = camera.pitch } - // 180 degrees is the max the map should rotate, therefore if the difference between the end and start point is - // more than 180 we need to go the opposite direction - if endBearing - startBearing >= 180 { - return endBearing - 360 + // Nil out the `internalAnimator` once the "ease to" finishes + animator.addCompletion { (position) in + completion?(position) } - // This is the inverse of the above, accounting for negative bearings - if endBearing - startBearing <= -180 { - return endBearing + 360 - } + animator.startAnimation() + internalAnimator = animator - return endBearing + return internalAnimator } + } fileprivate extension CoordinateBounds { diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraAnimator.swift b/Sources/MapboxMaps/Foundation/Camera/CameraAnimator.swift deleted file mode 100644 index 1de6d45d5c6..00000000000 --- a/Sources/MapboxMaps/Foundation/Camera/CameraAnimator.swift +++ /dev/null @@ -1,100 +0,0 @@ -import UIKit - -// MARK: CameraAnimator Class -public class CameraAnimator: NSObject { - - // MARK: Stored Properties - - /// Instance of the property animator that will run animations. - private var propertyAnimator: UIViewPropertyAnimator - - /// Delegate that conforms to `CameraAnimatorDelegate`. - private weak var delegate: CameraAnimatorDelegate? - - /// The ID of the owner of this `CameraAnimator`. - internal var owner: AnimationOwner - - // MARK: Computed Properties - - /// The state from of the animator. - public var state: UIViewAnimatingState { return propertyAnimator.state } - - /// Boolean that represents if the animation is running or not. - public var isRunning: Bool { return propertyAnimator.isRunning } - - /// Boolean that represents if the animation is running normally or in reverse. - public var isReversed: Bool { return propertyAnimator.isReversed } - - /// A Boolean value that indicates whether a completed animation remains in the active state. - public var pausesOnCompletion: Bool { - get { return propertyAnimator.pausesOnCompletion} - set { propertyAnimator.pausesOnCompletion = newValue } - } - - /// Value that represents what percentage of the animation has been completed. - public var fractionComplete: Double { - get { return Double(propertyAnimator.fractionComplete) } - set { propertyAnimator.fractionComplete = CGFloat(newValue) } - } - - // MARK: Initializer - internal init(delegate: CameraAnimatorDelegate, - propertyAnimator: UIViewPropertyAnimator, - owner: AnimationOwner) { - self.delegate = delegate - self.propertyAnimator = propertyAnimator - self.owner = owner - } - - deinit { - propertyAnimator.stopAnimation(false) - propertyAnimator.finishAnimation(at: .current) - } - - // MARK: Functions - - /// Starts the animation. - public func startAnimation() { - propertyAnimator.startAnimation() - } - - /// Starts the animation after a `delay` which is of type `TimeInterval`. - public func startAnimation(afterDelay delay: TimeInterval) { - propertyAnimator.startAnimation(afterDelay: delay) - } - - /// Pauses the animation. - public func pauseAnimation() { - propertyAnimator.pauseAnimation() - } - - /// Stops the animation. - public func stopAnimation() { - propertyAnimator.stopAnimation(false) - propertyAnimator.finishAnimation(at: .current) - } - - /// Add animations block to the animator with a `delayFactor`. - public func addAnimations(_ animations: @escaping () -> Void, delayFactor: Double) { - // if this cameraAnimator is not in the list of CameraAnimators held by the `CameraManager` then add it to that list - propertyAnimator.addAnimations(animations, delayFactor: CGFloat(delayFactor)) - } - - /// Add animations block to the animator. - public func addAnimations(_ animations: @escaping () -> Void) { - propertyAnimator.addAnimations(animations) - } - - /// Add a completion block to the animator. - public func addCompletion(_ completion: @escaping AnimationCompletion) { - propertyAnimator.addCompletion({ [weak self] animatingPosition in - guard let self = self else { return } - self.delegate?.schedulePendingCompletion(forAnimator: self, completion: completion, animatingPosition: animatingPosition) - }) - } - - /// Continue the animation with a timing parameter (`UITimingCurveProvider`) and duration factor (`CGFloat`). - public func continueAnimation(withTimingParameters parameters: UITimingCurveProvider?, durationFactor: Double) { - propertyAnimator.continueAnimation(withTimingParameters: parameters, durationFactor: CGFloat(durationFactor)) - } -} diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraTransition.swift b/Sources/MapboxMaps/Foundation/Camera/CameraTransition.swift new file mode 100644 index 00000000000..bbf8cb52553 --- /dev/null +++ b/Sources/MapboxMaps/Foundation/Camera/CameraTransition.swift @@ -0,0 +1,102 @@ +import UIKit +import CoreLocation + +/// Structure used to represent a desired change to the map's camera +public struct CameraTransition { + + /// Represents a change to the center coordinate of the map. + /// NOTE: Setting the `toValue` of `center` overrides any `anchor` animations + public var center: Change + + /// Represents a change to the zoom of the map. + public var zoom: Change + + /// Represetns a change to the padding of the map. + public var padding: Change + + /// Represents a change to the anchor of the map + /// NOTE: Incompatible with concurrent center animations + public var anchor: Change + + /// Represents a change to the bearing of the map. + public var bearing: Change + + /// Ensures that bearing transitions are optimized to take the shortest path. + public var shouldOptimizeBearingPath: Bool = true + + /// Represents a change to the pitch of the map. + public var pitch: Change + + /// Generic struct used to represent a change in a value from a starting point (i.e. `fromValue`) to an end point (i.e. `toValue`). + public struct Change { + public var fromValue: T + public var toValue: T? + + init(fromValue: T, toValue: T? = nil) { + self.fromValue = fromValue + self.toValue = toValue + } + } + + internal init(cameraOptions: CameraOptions, initialAnchor: CGPoint) { + + guard let renderedCenter = cameraOptions.center, + let renderedZoom = cameraOptions.zoom, + let renderedPadding = cameraOptions.padding, + let renderedPitch = cameraOptions.pitch, + let renderedBearing = cameraOptions.bearing else { + fatalError("Values in CameraOptions cannot be nil") + } + + center = Change(fromValue: renderedCenter) + zoom = Change(fromValue: renderedZoom) + padding = Change(fromValue: renderedPadding) + pitch = Change(fromValue: renderedPitch) + bearing = Change(fromValue: renderedBearing) + anchor = Change(fromValue: initialAnchor) + } + + internal var toCameraOptions: CameraOptions { + return CameraOptions(center: center.toValue, + padding: padding.toValue, + anchor: anchor.toValue, + zoom: zoom.toValue, + bearing: shouldOptimizeBearingPath ? optimizedBearingToValue : bearing.toValue, + pitch: pitch.toValue) + } + + internal var fromCameraOptions: CameraOptions { + return CameraOptions(center: center.fromValue, + padding: padding.fromValue, + anchor: anchor.fromValue, + zoom: zoom.fromValue, + bearing: bearing.fromValue, + pitch: pitch.fromValue) + + } + + internal var optimizedBearingToValue: CLLocationDirection? { + + // If `bearing.toValue` is nil, then return nil. + guard let toBearing = bearing.toValue?.truncatingRemainder(dividingBy: 360.0) else { + return nil + } + + let fromBearing = bearing.fromValue + + // 180 degrees is the max the map should rotate, therefore if the difference between the end and start point is + // more than 180 we need to go the opposite direction + if toBearing - fromBearing >= 180 { + return toBearing - 360 + } + + // This is the inverse of the above, accounting for negative bearings + if toBearing - fromBearing <= -180 { + return toBearing + 360 + } + + return toBearing + + } + +} diff --git a/Sources/MapboxMaps/Foundation/Camera/CameraView.swift b/Sources/MapboxMaps/Foundation/Camera/CameraView.swift index b685bc5119b..82b3d7e9094 100644 --- a/Sources/MapboxMaps/Foundation/Camera/CameraView.swift +++ b/Sources/MapboxMaps/Foundation/Camera/CameraView.swift @@ -1,132 +1,8 @@ import UIKit -/// Internal protocol that provides needed information / methods for the `CameraView` -internal protocol CameraViewDelegate: class { - /// The map's current camera - var cameraOptions: CameraOptions { get } - - /// The map's current center coordinate. - var centerCoordinate: CLLocationCoordinate2D { get } - - /// The map's zoom level. - var zoom: CGFloat { get } - - /// The map's bearing, measured clockwise from 0° north. - var bearing: CLLocationDirection { get } - - /// The map's pitch, falling within a range of 0 to 60. - var pitch: CGFloat { get } - - /// The map's camera padding - var padding: UIEdgeInsets { get } - - /// The map's camera anchor - var anchor: CGPoint { get } - - /// The map should jumpt to some camera - func jumpTo(camera: CameraOptions) -} - /// A view that represents a camera view port. internal class CameraView: UIView { - internal var camera: CameraOptions { - get { - return delegate.cameraOptions - } - set { - if let newZoom = newValue.zoom { - zoom = newZoom - } - - if let newBearing = newValue.bearing { - bearing = CGFloat(newBearing) - } - - if let newPitch = newValue.pitch { - pitch = newPitch - } - - if let newPadding = newValue.padding { - padding = newPadding - } - - if let newAnchor = newValue.anchor { - anchor = newAnchor - } - - if let newCenterCoordinate = newValue.center { - centerCoordinate = newCenterCoordinate - } - } - } - - /// The camera's zoom. Animatable. - @objc dynamic internal var zoom: CGFloat { - get { - return delegate.zoom - } - set { - layer.opacity = Float(newValue) - } - } - - /// The camera's bearing. Animatable. - @objc dynamic internal var bearing: CGFloat { - get { - return CGFloat(delegate.bearing) - } - - set { - layer.cornerRadius = newValue - } - } - - /// Coordinate at the center of the camera. Animatable. - @objc dynamic internal var centerCoordinate: CLLocationCoordinate2D { - get { - return delegate.centerCoordinate - } - - set { - layer.position = CGPoint(x: newValue.longitude, y: newValue.latitude) - } - } - - /// The camera's padding. Animatable. - @objc dynamic internal var padding: UIEdgeInsets { - get { - return delegate.padding - } - set { - layer.bounds = CGRect(x: newValue.left, - y: newValue.right, - width: newValue.bottom, - height: newValue.top) - } - } - - /// The camera's pitch. Animatable. - @objc dynamic internal var pitch: CGFloat { - get { - return delegate.pitch - } - set { - layer.transform.m11 = newValue - } - } - - /// The screen coordinate that the map rotates, pitches and zooms around. Setting this also affects the horizontal vanishing point when pitched. Animatable. - @objc dynamic internal var anchor: CGPoint { - get { - return layer.presentation()?.anchorPoint ?? layer.anchorPoint - } - - set { - layer.anchorPoint = newValue - } - } - internal var localCenterCoordinate: CLLocationCoordinate2D { let proxyCoord = layer.presentation()?.position ?? layer.position return CLLocationCoordinate2D(latitude: CLLocationDegrees(proxyCoord.y), @@ -166,83 +42,42 @@ internal class CameraView: UIView { pitch: localPitch) } - private unowned var delegate: CameraViewDelegate! - - init(delegate: CameraViewDelegate, edgeInsets: UIEdgeInsets = .zero) { - self.delegate = delegate + init() { super.init(frame: .zero) - self.isHidden = true self.isUserInteractionEnabled = false - - // Sync default values from MBXMap - setFromValuesWithMapView() } internal required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func setFromValuesWithMapView() { - zoom = delegate.zoom - bearing = CGFloat(delegate.bearing) - pitch = delegate.pitch - padding = delegate.padding - centerCoordinate = delegate.centerCoordinate - } - - internal func update() { - - // Retrieve currently rendered camera - let currentCamera = delegate.cameraOptions - - // Get the latest interpolated values of the camera properties (if they exist) - let targetCamera = localCamera.wrap() - - // Apply targetCamera options only if they are different from currentCamera options - if currentCamera != targetCamera { - - // Diff the targetCamera with the currentCamera and apply diffed camera properties to map - var diffedCamera = CameraOptions() - - if targetCamera.zoom != currentCamera.zoom, let targetZoom = targetCamera.zoom, !targetZoom.isNaN { - diffedCamera.zoom = targetCamera.zoom - } - - if targetCamera.bearing != currentCamera.bearing, let targetBearing = targetCamera.bearing, !targetBearing.isNaN { - diffedCamera.bearing = targetCamera.bearing - } - - if targetCamera.pitch != currentCamera.pitch, let targetPitch = targetCamera.pitch, !targetPitch.isNaN { - diffedCamera.pitch = targetCamera.pitch - } - - if targetCamera.center != currentCamera.center, let targetCenter = targetCamera.center, !targetCenter.latitude.isNaN, !targetCenter.longitude.isNaN { - diffedCamera.center = targetCamera.center - } - - if targetCamera.anchor != currentCamera.anchor { - diffedCamera.anchor = targetCamera.anchor - } + internal func syncLayer(to cameraOptions: CameraOptions) { + if let zoom = cameraOptions.zoom { + layer.opacity = Float(zoom) + } - if targetCamera.padding != currentCamera.padding { - diffedCamera.padding = targetCamera.padding - } + if let bearing = cameraOptions.bearing { + layer.cornerRadius = CGFloat(bearing) + } - delegate.jumpTo(camera: diffedCamera) + if let centerCoordinate = cameraOptions.center { + layer.position = CGPoint(x: centerCoordinate.longitude, y: centerCoordinate.latitude) } - } -} -fileprivate extension CameraOptions { + if let padding = cameraOptions.padding { + layer.bounds = CGRect(x: padding.left, + y: padding.right, + width: padding.bottom, + height: padding.top) + } - func wrap() -> CameraOptions { - return CameraOptions(center: self.center?.wrap(), - padding: self.padding, - anchor: self.anchor, - zoom: self.zoom, - bearing: self.bearing, - pitch: self.pitch) + if let pitch = cameraOptions.pitch { + layer.transform.m11 = pitch + } + if let anchor = cameraOptions.anchor { + layer.anchorPoint = anchor + } } } diff --git a/Sources/MapboxMaps/Foundation/Camera/FlyToCameraAnimator.swift b/Sources/MapboxMaps/Foundation/Camera/FlyToCameraAnimator.swift new file mode 100644 index 00000000000..3f2f5d0d683 --- /dev/null +++ b/Sources/MapboxMaps/Foundation/Camera/FlyToCameraAnimator.swift @@ -0,0 +1,86 @@ +import UIKit + +public class FlyToCameraAnimator: NSObject, CameraAnimator, CameraAnimatorInterface { + + internal private(set) weak var delegate: CameraAnimatorDelegate? + + public private(set) var owner: AnimationOwner + + private let interpolator: FlyToInterpolator + + public let duration: TimeInterval + + public private(set) var state: UIViewAnimatingState = .inactive + + private var start: Date? + + private let finalCameraOptions: CameraOptions + + private var completionBlocks = [AnimationCompletion]() + + private let dateProvider: DateProvider + + internal init?(inital: CameraOptions, + final: CameraOptions, + owner: AnimationOwner, + duration: TimeInterval? = nil, + mapSize: CGSize, + delegate: CameraAnimatorDelegate, + dateProvider: DateProvider = DefaultDateProvider()) { + guard let flyToInterpolator = FlyToInterpolator(from: inital, to: final, size: mapSize) else { + return nil + } + if let duration = duration { + guard duration >= 0 else { + return nil + } + } + self.interpolator = flyToInterpolator + self.delegate = delegate + self.owner = owner + self.finalCameraOptions = final + self.duration = duration ?? flyToInterpolator.duration() + self.dateProvider = dateProvider + } + + public func stopAnimation() { + state = .stopped + scheduleCompletionIfNecessary(position: .current) // `current` represents an interrupted animation. + } + + internal func startAnimation() { + state = .active + start = dateProvider.now + } + + internal func addCompletion(_ completion: @escaping AnimationCompletion) { + completionBlocks.append(completion) + } + + private func scheduleCompletionIfNecessary(position: UIViewAnimatingPosition) { + for completion in completionBlocks { + delegate?.schedulePendingCompletion( + forAnimator: self, + completion: completion, + animatingPosition: position) + } + completionBlocks.removeAll() + } + + internal var currentCameraOptions: CameraOptions? { + guard state == .active, let start = start else { + return nil + } + let fractionComplete = min(dateProvider.now.timeIntervalSince(start) / duration, 1) + guard fractionComplete < 1 else { + state = .stopped + scheduleCompletionIfNecessary(position: .end) + return finalCameraOptions + } + return CameraOptions( + center: interpolator.coordinate(at: fractionComplete), + zoom: CGFloat(interpolator.zoom(at: fractionComplete)), + bearing: interpolator.bearing(at: fractionComplete), + pitch: CGFloat(interpolator.pitch(at: fractionComplete))) + } +} diff --git a/Sources/MapboxMaps/Foundation/Camera/FlyToInterpolator.swift b/Sources/MapboxMaps/Foundation/Camera/FlyToInterpolator.swift index fb475ae9889..045e3dbbf9d 100644 --- a/Sources/MapboxMaps/Foundation/Camera/FlyToInterpolator.swift +++ b/Sources/MapboxMaps/Foundation/Camera/FlyToInterpolator.swift @@ -58,7 +58,6 @@ internal struct FlyToInterpolator { let sourceZoomParam = source.zoom, let sourcePitchParam = source.pitch, let sourceBearingParam = source.bearing else { - preconditionFailure("Source camera should have valid, non optional, parameters") return nil } diff --git a/Sources/MapboxMaps/Foundation/Camera/WeakCameraAnimatorSet.swift b/Sources/MapboxMaps/Foundation/Camera/WeakCameraAnimatorSet.swift new file mode 100644 index 00000000000..2f12152608b --- /dev/null +++ b/Sources/MapboxMaps/Foundation/Camera/WeakCameraAnimatorSet.swift @@ -0,0 +1,22 @@ +import Foundation + +// swiftlint:disable force_cast +internal class WeakCameraAnimatorSet { + private let hashTable = NSHashTable.weakObjects() + + internal func add(_ object: CameraAnimatorInterface) { + hashTable.add((object as! NSObject)) + } + + internal func remove(_ object: CameraAnimatorInterface) { + hashTable.remove((object as! NSObject)) + } + + internal func removeAll() { + hashTable.removeAllObjects() + } + + internal var allObjects: [CameraAnimatorInterface] { + hashTable.allObjects.map { $0 as! CameraAnimatorInterface } + } +} diff --git a/Sources/MapboxMaps/Foundation/DateProvider.swift b/Sources/MapboxMaps/Foundation/DateProvider.swift new file mode 100644 index 00000000000..0c37a821e21 --- /dev/null +++ b/Sources/MapboxMaps/Foundation/DateProvider.swift @@ -0,0 +1,13 @@ +import Foundation + +internal protocol DateProvider { + + // Provides the current date + var now: Date { get } +} + +internal struct DefaultDateProvider: DateProvider { + var now: Date { + return Date() + } +} diff --git a/Sources/MapboxMaps/Foundation/Extensions/Core/MBXEdgeInsets.swift b/Sources/MapboxMaps/Foundation/Extensions/Core/MBXEdgeInsets.swift index b0208864f6d..9947d750808 100644 --- a/Sources/MapboxMaps/Foundation/Extensions/Core/MBXEdgeInsets.swift +++ b/Sources/MapboxMaps/Foundation/Extensions/Core/MBXEdgeInsets.swift @@ -9,7 +9,7 @@ internal extension EdgeInsets { } } -internal extension UIEdgeInsets { +extension UIEdgeInsets { func toMBXEdgeInsetsValue() -> EdgeInsets { return EdgeInsets(top: Double(self.top), left: Double(self.left), diff --git a/Sources/MapboxMaps/Gestures/GestureManager+GestureHandlerDelegate.swift b/Sources/MapboxMaps/Gestures/GestureManager+GestureHandlerDelegate.swift index 9dcad056940..a4ade3938a7 100644 --- a/Sources/MapboxMaps/Gestures/GestureManager+GestureHandlerDelegate.swift +++ b/Sources/MapboxMaps/Gestures/GestureManager+GestureHandlerDelegate.swift @@ -102,14 +102,14 @@ extension GestureManager: GestureHandlerDelegate { return false } - return mapView.cameraView.zoom >= cameraManager.mapCameraOptions.minimumZoomLevel + return mapView.zoom >= cameraManager.mapCameraOptions.minimumZoomLevel } internal func rotationStartAngle() -> CGFloat { guard let mapView = cameraManager.mapView else { return 0 } - return (mapView.cameraView.bearing * .pi) / 180.0 * -1 + return CGFloat((mapView.bearing * .pi) / 180.0 * -1) } internal func rotationChanged(with changedAngle: CGFloat, and anchor: CGPoint, and pinchScale: CGFloat) { @@ -145,10 +145,10 @@ extension GestureManager: GestureHandlerDelegate { // Avoid contention with in-progress gestures // let toleranceForSnappingToNorth: CGFloat = 7.0 - if mapView.cameraView.bearing != 0.0 + if mapView.bearing != 0.0 && pinchState != .began && pinchState != .changed { - if mapView.cameraView.bearing != 0.0 && isRotationAllowed() == false { + if mapView.bearing != 0.0 && isRotationAllowed() == false { cameraManager.setCamera(to: CameraOptions(bearing: 0), animated: false, duration: 0, @@ -167,7 +167,7 @@ extension GestureManager: GestureHandlerDelegate { guard let mapView = cameraManager.mapView else { return 0 } - return mapView.cameraView.pitch + return mapView.pitch } internal func horizontalPitchTiltTolerance() -> Double { diff --git a/Sources/MapboxMaps/Gestures/GestureManager.swift b/Sources/MapboxMaps/Gestures/GestureManager.swift index 71359e73c79..86678c9766b 100644 --- a/Sources/MapboxMaps/Gestures/GestureManager.swift +++ b/Sources/MapboxMaps/Gestures/GestureManager.swift @@ -120,7 +120,7 @@ internal protocol CameraManagerProtocol: AnyObject { func cancelAnimations() } -extension CameraManager: CameraManagerProtocol { } +extension CameraAnimationsManager: CameraManagerProtocol { } public final class GestureManager: NSObject { diff --git a/Sources/MapboxMaps/MapView/MapView+Managers.swift b/Sources/MapboxMaps/MapView/MapView+Managers.swift index ad3f084ba15..0cee16b9ce2 100644 --- a/Sources/MapboxMaps/MapView/MapView+Managers.swift +++ b/Sources/MapboxMaps/MapView/MapView+Managers.swift @@ -60,7 +60,7 @@ extension MapView { metalView?.presentsWithTransaction = newOptions.presentsWithTransaction } - internal func setupGestures(with view: UIView, options: GestureOptions, cameraManager: CameraManager) { + internal func setupGestures(with view: UIView, options: GestureOptions, cameraManager: CameraAnimationsManager) { gestures = GestureManager(for: view, options: options, cameraManager: cameraManager) } @@ -69,7 +69,7 @@ extension MapView { } internal func setupCamera(for view: MapView, options: MapCameraOptions) { - camera = CameraManager(for: view, with: mapConfig.camera) + camera = CameraAnimationsManager(for: view, with: mapConfig.camera) } internal func updateCamera(with newOptions: MapCameraOptions) { diff --git a/Sources/MapboxMaps/MapView/MapView+Supportable.swift b/Sources/MapboxMaps/MapView/MapView+Supportable.swift index bf9ed1e3a02..bcc9b08a3a3 100644 --- a/Sources/MapboxMaps/MapView/MapView+Supportable.swift +++ b/Sources/MapboxMaps/MapView/MapView+Supportable.swift @@ -8,13 +8,16 @@ extension MapView: OrnamentSupportableView { } internal func compassTapped() { - // Don't have access to CameraManager, so calling UIView.animate directly. - UIView.animate(withDuration: 0.3, - delay: 0.0, - options: [.curveEaseOut, .allowUserInteraction], - animations: { [weak self] in - self?.cameraView.bearing = 0.0 - }, completion: nil) + var animator: BasicCameraAnimator? + animator = camera.makeAnimator(duration: 0.3, curve: .easeOut, animations: { (transition) in + transition.bearing.toValue = 0 + }) + + animator?.addCompletion({ (_) in + animator = nil + }) + + animator?.startAnimation() } internal func subscribeCameraChangeHandler(_ handler: @escaping (CameraOptions) -> Void) { @@ -32,7 +35,7 @@ extension MapView: LocationSupportableMapView { } public func metersPerPointAtLatitude(latitude: CLLocationDegrees) -> CLLocationDistance { - return Projection.getMetersPerPixelAtLatitude(forLatitude: latitude, zoom: Double(cameraView.zoom)) + return Projection.getMetersPerPixelAtLatitude(forLatitude: latitude, zoom: Double(zoom)) } public func subscribeRenderFrameHandler(_ handler: @escaping (MapboxCoreMaps.Event) -> Void) { diff --git a/Sources/MapboxMaps/MapView/MapView.swift b/Sources/MapboxMaps/MapView/MapView.swift index 7fc78124945..774311379d3 100644 --- a/Sources/MapboxMaps/MapView/MapView.swift +++ b/Sources/MapboxMaps/MapView/MapView.swift @@ -15,7 +15,7 @@ open class MapView: BaseMapView { internal var ornaments: OrnamentsManager! /// The `camera` object manages a camera's view lifecycle.. - public internal(set) var camera: CameraManager! + public internal(set) var camera: CameraAnimationsManager! /// The `location`object handles location events of the map. public internal(set) var location: LocationManager! diff --git a/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorDelegateMock.swift b/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorDelegateMock.swift index 7a92f45bdb9..5756effdf98 100644 --- a/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorDelegateMock.swift +++ b/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorDelegateMock.swift @@ -1,24 +1,49 @@ import XCTest - -#if canImport(MapboxMaps) @testable import MapboxMaps -#else -@testable import MapboxMapsFoundation -#endif final class CameraAnimatorDelegateMock: CameraAnimatorDelegate { - struct CameraAnimatorDelegateParameters {} + struct SchedulePendingCompletionParameters { + var animator: CameraAnimator + var completion: AnimationCompletion + var animatingPosition: UIViewAnimatingPosition + } - let cameraAnimatorStub = Stub() + let schedulePendingCompletionStub = Stub() public func schedulePendingCompletion(forAnimator animator: CameraAnimator, completion: @escaping AnimationCompletion, animatingPosition: UIViewAnimatingPosition) { - cameraAnimatorStub.call(with: CameraAnimatorDelegateParameters()) + schedulePendingCompletionStub.call(with: SchedulePendingCompletionParameters(animator: animator, + completion: completion, + animatingPosition: animatingPosition)) + } + + let animatorFinishedStub = Stub() + public func animatorIsFinished(forAnimator animator: BasicCameraAnimator) { + animatorFinishedStub.call(with: animator) + } + + var camera: CameraOptions { + return CameraOptions(center: .init(latitude: 10, longitude: 10), + padding: .init(top: 10, left: 10, bottom: 10, right: 10), + zoom: 10, + bearing: 10, + pitch: 20) + } + + let jumpToStub = Stub() + func jumpTo(camera: CameraOptions) { + jumpToStub.call(with: camera) + } + + let addViewToViewHeirarchyStub = Stub() + func addViewToViewHeirarchy(_ view: CameraView) { + addViewToViewHeirarchyStub.call(with: view) } - public func animatorIsFinished(forAnimator animator: CameraAnimator) { - cameraAnimatorStub.call(with: CameraAnimatorDelegateParameters()) + let anchorAfterPaddingStub = Stub(defaultReturnValue: .zero) + func anchorAfterPadding() -> CGPoint { + return anchorAfterPaddingStub.call() } } diff --git a/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorTests.swift b/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorTests.swift index cec47f5a81b..3fea49e6100 100644 --- a/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorTests.swift +++ b/Tests/MapboxMapsTests/Foundation/Camera/CameraAnimatorTests.swift @@ -1,28 +1,74 @@ import XCTest - -#if canImport(MapboxMaps) @testable import MapboxMaps -#else -@testable import MapboxMapsFoundation -#endif -internal class CameraAnimatorTests: XCTestCase { +internal let cameraOptionsTestValue = CameraOptions( + center: CLLocationCoordinate2D(latitude: 10, longitude: 10), + padding: .init(top: 10, left: 10, bottom: 10, right: 10), + anchor: .init(x: 10.0, y: 10.0), + zoom: 10, + bearing: 10, + pitch: 10) + +internal class BasicCameraAnimatorTests: XCTestCase { // swiftlint:disable weak_delegate var delegate: CameraAnimatorDelegateMock! - var cameraAnimator: CameraAnimator! + var propertyAnimator: UIViewPropertyAnimatorMock! + var cameraView: CameraViewMock! + var animator: BasicCameraAnimator! override func setUp() { delegate = CameraAnimatorDelegateMock() - cameraAnimator = CameraAnimator(delegate: delegate, - propertyAnimator: UIViewPropertyAnimator(), - owner: .unspecified) + propertyAnimator = UIViewPropertyAnimatorMock() + cameraView = CameraViewMock() + animator = BasicCameraAnimator( + delegate: delegate, + propertyAnimator: propertyAnimator , + owner: .unspecified, + cameraView: cameraView) } - func testAddCompletionSchedulesACompletion() { - cameraAnimator.addCompletion({ _ in - XCTAssertEqual(self.delegate.cameraAnimatorStub.invocations.count, 1) - }) + func testDeinit() { + animator = nil + XCTAssertEqual(propertyAnimator.stopAnimationStub.invocations.count, 1) + XCTAssertEqual(propertyAnimator.finishAnimationStub.invocations.count, 1) + XCTAssertEqual(cameraView.removeFromSuperviewStub.invocations.count, 1) } + func testStartAndStopAnimation() { + animator?.addAnimations { (transition) in + transition.zoom.toValue = cameraOptionsTestValue.zoom! + } + + animator?.startAnimation() + + XCTAssertEqual(delegate.addViewToViewHeirarchyStub.invocations.count, 1) + XCTAssertEqual(propertyAnimator.startAnimationStub.invocations.count, 1) + XCTAssertEqual(propertyAnimator.addAnimationsStub.invocations.count, 1) + XCTAssertNotNil(animator?.transition) + XCTAssertEqual(animator?.transition?.toCameraOptions.zoom, 10) + + animator?.stopAnimation() + XCTAssertEqual(propertyAnimator.stopAnimationStub.invocations.count, 1) + XCTAssertEqual(propertyAnimator.finishAnimationStub.invocations.count, 1) + XCTAssertEqual(propertyAnimator.finishAnimationStub.invocations.first?.parameters.finalPosition, .current) + + } + + func testCurrentCameraOptions() { + animator?.addAnimations { (transition) in + transition.zoom.toValue = cameraOptionsTestValue.zoom! + transition.center.toValue = cameraOptionsTestValue.center! + transition.bearing.toValue = cameraOptionsTestValue.bearing! + transition.anchor.toValue = cameraOptionsTestValue.anchor! + transition.pitch.toValue = cameraOptionsTestValue.pitch! + transition.padding.toValue = cameraOptionsTestValue.padding! + } + animator?.startAnimation() + propertyAnimator.shouldReturnState = .active + + let cameraOptions = animator.currentCameraOptions + + XCTAssertEqual(cameraOptions, cameraView.localCamera) + } } diff --git a/Tests/MapboxMapsTests/Foundation/Camera/CameraTransitionTests.swift b/Tests/MapboxMapsTests/Foundation/Camera/CameraTransitionTests.swift new file mode 100644 index 00000000000..2a86a87364d --- /dev/null +++ b/Tests/MapboxMapsTests/Foundation/Camera/CameraTransitionTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import MapboxMaps + +class CameraTransitionTests: XCTestCase { + + var cameraTransition = CameraTransition( + cameraOptions: cameraOptionsTestValue, + initialAnchor: .zero) + + func testOptimizeBearingClockwise() { + let startBearing = 0.0 + let endBearing = 90.0 + cameraTransition.bearing.fromValue = startBearing + cameraTransition.bearing.toValue = endBearing + let optimizedBearing = cameraTransition.optimizedBearingToValue + + XCTAssertEqual(optimizedBearing, 90.0) + } + + func testOptimizeBearingCounterClockwise() { + let startBearing = 0.0 + let endBearing = 270.0 + cameraTransition.bearing.fromValue = startBearing + cameraTransition.bearing.toValue = endBearing + let optimizedBearing = cameraTransition.optimizedBearingToValue + + // We should rotate counter clockwise which is shown by a negative angle + XCTAssertEqual(optimizedBearing, -90.0) + } + + func testOptimizeBearingWhenBearingsAreTheSame() { + let startBearing = -90.0 + let endBearing = 270.0 + cameraTransition.bearing.fromValue = startBearing + cameraTransition.bearing.toValue = endBearing + let optimizedBearing = cameraTransition.optimizedBearingToValue + + // -90 and 270 degrees is the same bearing so should just return original + XCTAssertEqual(optimizedBearing, -90) + } + + func testOptimizeBearingWhenStartBearingIsNegative() { + var optimizedBearing: CLLocationDirection? + + // Starting at -90 aka 270 should rotate clockwise to 20 + cameraTransition.bearing.fromValue = -90 + cameraTransition.bearing.toValue = 20 + + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, 20) + + // Starting at -90 aka 270 should rotate clockwise to -270 aka 90 + cameraTransition.bearing.fromValue = -90 + cameraTransition.bearing.toValue = -270 + + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, 90) + } + + func testOptimizeBearingWhenStartBearingIsNegativeAndIsLesserThanMinus360() { + var optimizedBearing: CLLocationDirection? + + cameraTransition.bearing.fromValue = -560 + cameraTransition.bearing.toValue = 0 + + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, -360) + } + + func testOptimizeBearingHandlesNil() { + var optimizedBearing: CLLocationDirection? + + // Test when no end bearing is provided + cameraTransition.bearing.fromValue = 0.0 + cameraTransition.bearing.toValue = nil + + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertNil(optimizedBearing) + } + + func testOptimizeBearingLargerThan360() { + var optimizedBearing: CLLocationDirection? + + // 719 degrees is the same as 359 degrees. -1 should be returned because it is the shortest path from starting at 90 + cameraTransition.bearing.fromValue = 90 + cameraTransition.bearing.toValue = 719 + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, -1.0) + + // -195 should be returned because it is the shortest path from starting at 180 + cameraTransition.bearing.fromValue = 180 + cameraTransition.bearing.toValue = -555 + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, 165) + + // -160 should be returned because it is the shortest path from starting at 180 + cameraTransition.bearing.fromValue = 180 + cameraTransition.bearing.toValue = -520 + optimizedBearing = cameraTransition.optimizedBearingToValue + XCTAssertEqual(optimizedBearing, 200) + } +} diff --git a/Tests/MapboxMapsTests/Foundation/Camera/CameraViewMock.swift b/Tests/MapboxMapsTests/Foundation/Camera/CameraViewMock.swift new file mode 100644 index 00000000000..516c5f04c47 --- /dev/null +++ b/Tests/MapboxMapsTests/Foundation/Camera/CameraViewMock.swift @@ -0,0 +1,24 @@ +import UIKit +@testable import MapboxMaps + +class CameraViewMock: CameraView { + + let localCameraStub = Stub(defaultReturnValue: cameraOptionsTestValue) + override var localCamera: CameraOptions { + return localCameraStub.call() + } + + struct SyncLayerParameters { + var cameraOptions: CameraOptions + } + let syncLayerStub = Stub() + override func syncLayer(to cameraOptions: CameraOptions) { + syncLayerStub.call(with: .init(cameraOptions: cameraOptions)) + } + + let removeFromSuperviewStub = Stub() + override func removeFromSuperview() { + removeFromSuperviewStub.call() + } + +} diff --git a/Tests/MapboxMapsTests/Foundation/Camera/CameraViewTests.swift b/Tests/MapboxMapsTests/Foundation/Camera/CameraViewTests.swift new file mode 100644 index 00000000000..ab0690ff441 --- /dev/null +++ b/Tests/MapboxMapsTests/Foundation/Camera/CameraViewTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import MapboxMaps + +final class CameraViewTests: XCTestCase { + + let cameraOptions = CameraOptions(center: .init(latitude: 10, longitude: 10), + padding: .init(top: 10, left: 10, bottom: 10, right: 10), + anchor: .init(x: 10, y: 10), + zoom: 10, + bearing: 10, + pitch: 10) + + var cameraView: CameraView! + + override func setUp() { + cameraView = CameraView() + cameraView.syncLayer(to: cameraOptions) + } + + func testSyncLayer() { + XCTAssertEqual(cameraView.layer.opacity, Float(cameraOptions.zoom!)) + XCTAssertEqual(cameraView.layer.cornerRadius, CGFloat(cameraOptions.bearing!)) + let padding = cameraOptions.padding! + XCTAssertEqual(cameraView.layer.bounds, CGRect(x: padding.left, + y: padding.right, + width: padding.bottom, + height: padding.top)) + let center = cameraOptions.center! + XCTAssertEqual(cameraView.layer.position, CGPoint(x: center.longitude, + y: center.latitude)) + XCTAssertEqual(cameraView.layer.transform.m11, cameraOptions.pitch!) + XCTAssertEqual(cameraView.layer.anchorPoint, cameraOptions.anchor!) + } + + func testLocalCamera() { + XCTAssertEqual(cameraView.localCamera, cameraOptions) + } + +} diff --git a/Tests/MapboxMapsTests/Foundation/Camera/FlyToAnimatorTests.swift b/Tests/MapboxMapsTests/Foundation/Camera/FlyToAnimatorTests.swift new file mode 100644 index 00000000000..83335c5e22b --- /dev/null +++ b/Tests/MapboxMapsTests/Foundation/Camera/FlyToAnimatorTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import MapboxMaps + +final class FlyToAnimatorTests: XCTestCase { + + let initalCameraOptions = CameraOptions( + center: CLLocationCoordinate2D( + latitude: 42.3601, + longitude: -71.0589), + padding: .zero, + zoom: 10, + bearing: 10, + pitch: 10) + + let finalCameraOptions = CameraOptions( + center: CLLocationCoordinate2D( + latitude: 37.7749, + longitude: -122.4194), + padding: .zero, + zoom: 10, + bearing: 10, + pitch: 10) + + let animationOwner = AnimationOwner.custom(id: "fly-to") + let duration: TimeInterval = 10 + + var flyToAnimator: FlyToCameraAnimator! + + // swiftlint:disable weak_delegate + var cameraAnimatorDelegate: CameraAnimatorDelegateMock! + + fileprivate var dateProvider: MockDateProvider! + + override func setUp() { + super.setUp() + cameraAnimatorDelegate = CameraAnimatorDelegateMock() + dateProvider = MockDateProvider() + flyToAnimator = FlyToCameraAnimator( + inital: initalCameraOptions, + final: finalCameraOptions, + owner: .custom(id: "fly-to"), + duration: duration, + mapSize: CGSize(width: 500, height: 500), + delegate: cameraAnimatorDelegate, + dateProvider: dateProvider) + } + + override func tearDown() { + cameraAnimatorDelegate = nil + flyToAnimator = nil + super.tearDown() + } + + func testInitializationWithValidOptions() { + XCTAssertTrue(flyToAnimator.delegate === cameraAnimatorDelegate) + XCTAssertEqual(flyToAnimator.owner, animationOwner) + XCTAssertEqual(flyToAnimator.duration, duration) + XCTAssertEqual(flyToAnimator.state, .inactive) + } + + func testInitializationWithANegativeDurationReturnsNil() { + XCTAssertNil( + FlyToCameraAnimator( + inital: initalCameraOptions, + final: finalCameraOptions, + owner: .custom(id: "fly-to"), + duration: -1, + mapSize: CGSize(width: 500, height: 500), + delegate: cameraAnimatorDelegate) + ) + } + + func testInitializationWithANilDurationSetsDurationToCalculatedValue() { + let animator = FlyToCameraAnimator( + inital: initalCameraOptions, + final: finalCameraOptions, + owner: .custom(id: "fly-to"), + duration: nil, + mapSize: CGSize(width: 500, height: 500), + delegate: cameraAnimatorDelegate) + XCTAssertNotNil(animator?.duration) + } + + func testInitializationWithInvalidCameraOptionsReturnsNil() { + XCTAssertNil( + FlyToCameraAnimator( + inital: CameraOptions(), + final: finalCameraOptions, + owner: .custom(id: "fly-to"), + duration: -1, + mapSize: CGSize(width: 500, height: 500), + delegate: cameraAnimatorDelegate) + ) + } + + func testStartAnimationChangesStateToActive() { + flyToAnimator.startAnimation() + XCTAssertEqual(flyToAnimator.state, .active) + } + + func testAnimationBlocksAreScheduledWhenAnimationIsComplete() { + flyToAnimator.addCompletion({ (_) in + () // no-op + }) + + flyToAnimator.startAnimation() + dateProvider.mockValue = Date(timeIntervalSinceReferenceDate: 20) + + let currentCameraOptions = flyToAnimator.currentCameraOptions + XCTAssertEqual(currentCameraOptions, finalCameraOptions) + XCTAssertEqual(flyToAnimator.state, .stopped) + XCTAssertEqual(cameraAnimatorDelegate.schedulePendingCompletionStub.invocations.count, 1) + XCTAssertEqual(cameraAnimatorDelegate.schedulePendingCompletionStub.invocations.first?.parameters.animatingPosition, .end) + + } + + func testAnimationBlocksAreScheduledWhenStopAnimationIsInvoked() { + + flyToAnimator.addCompletion({ (_) in + () // no-op + }) + + flyToAnimator.startAnimation() + flyToAnimator.stopAnimation() + + XCTAssertEqual(cameraAnimatorDelegate.schedulePendingCompletionStub.invocations.count, 1) + XCTAssertEqual(cameraAnimatorDelegate.schedulePendingCompletionStub.invocations.first?.parameters.animatingPosition, .current) + + } + + func testStopAnimationChangesStateToStopped() { + flyToAnimator.startAnimation() + flyToAnimator.stopAnimation() + + XCTAssertEqual(flyToAnimator.state, .stopped) + } + + func testCurrentCameraOptionsReturnsNilIfAnimationIsNotRunning() { + XCTAssertEqual(flyToAnimator.state, .inactive) + XCTAssertNil(flyToAnimator.currentCameraOptions) + } + + func testCurrentCameraOptionsReturnsInterpolatedValueIfAnimationIsRunning() { + + flyToAnimator.startAnimation() + dateProvider.mockValue = Date(timeIntervalSinceReferenceDate: 5) + + let interpolatedCamera = flyToAnimator.currentCameraOptions + XCTAssertNotNil(interpolatedCamera) + } +} + +private class MockDateProvider: DateProvider { + + var mockValue: Date + + var now: Date { + return mockValue + } + + init(mockValue: Date = Date(timeIntervalSinceReferenceDate: 0)) { + self.mockValue = mockValue + } +} diff --git a/Tests/MapboxMapsTests/Foundation/Camera/MapboxMapsCameraTests.swift b/Tests/MapboxMapsTests/Foundation/Camera/MapboxMapsCameraTests.swift index e28c9f82d40..6214c89f215 100644 --- a/Tests/MapboxMapsTests/Foundation/Camera/MapboxMapsCameraTests.swift +++ b/Tests/MapboxMapsTests/Foundation/Camera/MapboxMapsCameraTests.swift @@ -1,36 +1,79 @@ import XCTest -import MetalKit - -#if canImport(MapboxMaps) @testable import MapboxMaps -#else -@testable import MapboxMapsFoundation -#endif -class CameraManagerTests: XCTestCase { +internal class CameraManagerIntegrationTests: MapViewIntegrationTestCase { + + var cameraManager: CameraAnimationsManager { + guard let mapView = mapView else { + fatalError("MapView must not be nil") + } + return mapView.camera + } - var mapView: BaseMapView! - var cameraManager: CameraManager! - var mapInitOptions: MapInitOptions! + func testSetCameraEnforcesMinZoom() { - override func setUp() { - mapInitOptions = MapInitOptions(resourceOptions: ResourceOptions(accessToken: "pk.feedcafedeadbeefbadebede")) + guard let mapView = mapView else { + XCTFail("MapView must not be nil") + return + } - mapView = BaseMapView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), - mapInitOptions: mapInitOptions, - styleURI: nil) - cameraManager = CameraManager(for: mapView, with: MapCameraOptions()) + mapView.update(with: { (config) in + config.camera.minimumZoomLevel = CGFloat.random(in: 0..() + override func startAnimation() { + startAnimationStub.call() + } + + struct StopAnimationParameters { + var withoutFinishing: Bool + } + let stopAnimationStub = Stub() + override func stopAnimation(_ withoutFinishing: Bool) { + stopAnimationStub.call(with: StopAnimationParameters(withoutFinishing: withoutFinishing)) + } + + let pauseAnimationsStub = Stub() + override func pauseAnimation() { + pauseAnimationsStub.call() + } + + struct AddAnimationParameters { + var animation: () -> Void + } + let addAnimationsStub = Stub() + override func addAnimations(_ animation: @escaping () -> Void) { + addAnimationsStub.call(with: .init(animation: animation)) + } + + let addCompletionStub = Stub() + override func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void) { + addCompletionStub.call() + } + + struct ContinueAnimationParameters { + var parameters: UITimingCurveProvider? + var durationFactor: CGFloat + } + let continueAnimationStub = Stub() + override func continueAnimation(withTimingParameters parameters: UITimingCurveProvider?, durationFactor: CGFloat) { + continueAnimationStub.call(with: .init(parameters: parameters, durationFactor: durationFactor)) + } + + struct FinishAnimationParameters { + var finalPosition: UIViewAnimatingPosition + } + + let finishAnimationStub = Stub() + override func finishAnimation(at finalPosition: UIViewAnimatingPosition) { + finishAnimationStub.call(with: .init(finalPosition: finalPosition)) + } +} diff --git a/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift b/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift index 29679768291..942dc4835f7 100644 --- a/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift +++ b/Tests/MapboxMapsTests/Gestures/GestureManagerTests.swift @@ -90,14 +90,6 @@ final class GestureManagerTests: XCTestCase { shouldRecognizeSimultaneouslyWith: tapGestureRecognizer)) } - func testScaleForZoom() { - mapView.cameraView.zoom = CGFloat.random(in: 0...22) - - let scale = gestureManager.scaleForZoom() - - XCTAssertEqual(scale, mapView.cameraView.zoom) - } - func testPinchScaleChanged_SetsCamera() { let zoom = CGFloat.random(in: 0...22) diff --git a/Tests/MapboxMapsTests/MapView/Integration Tests/ExampleIntegrationTest.swift b/Tests/MapboxMapsTests/MapView/Integration Tests/ExampleIntegrationTest.swift index 718c5717051..90c07d172a5 100644 --- a/Tests/MapboxMapsTests/MapView/Integration Tests/ExampleIntegrationTest.swift +++ b/Tests/MapboxMapsTests/MapView/Integration Tests/ExampleIntegrationTest.swift @@ -9,9 +9,7 @@ internal class ExampleIntegrationTest: MapViewIntegrationTestCase { } internal func testWaitForIdle() throws { - guard - let mapView = mapView, - let style = style else { + guard let style = style else { XCTFail("There should be valid MapView and Style objects created by setUp.") return } @@ -21,9 +19,6 @@ internal class ExampleIntegrationTest: MapViewIntegrationTestCase { style.uri = .streets - mapView.centerCoordinate = CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0) - mapView.zoom = 8.0 - didFinishLoadingStyle = { _ in expectation.fulfill() } diff --git a/Tests/MapboxMapsTests/MapView/Integration Tests/FeatureQueryingTest.swift b/Tests/MapboxMapsTests/MapView/Integration Tests/FeatureQueryingTest.swift index b0dee8cfd89..cb6b8dc7d05 100644 --- a/Tests/MapboxMapsTests/MapView/Integration Tests/FeatureQueryingTest.swift +++ b/Tests/MapboxMapsTests/MapView/Integration Tests/FeatureQueryingTest.swift @@ -22,7 +22,7 @@ internal class FeatureQueryingTest: MapViewIntegrationTestCase { let featureQueryExpectation = XCTestExpectation(description: "Wait for features to be queried.") didFinishLoadingStyle = { mapView in - let cameraManager = CameraManager(for: mapView, with: MapCameraOptions()) + let cameraManager = CameraAnimationsManager(for: mapView, with: MapCameraOptions()) cameraManager.setCamera(to: CameraOptions(center: self.centerCoordinate, zoom: 15.0)) } @@ -56,7 +56,7 @@ internal class FeatureQueryingTest: MapViewIntegrationTestCase { let featureQueryExpectation = XCTestExpectation(description: "Wait for features to be queried.") didFinishLoadingStyle = { mapView in - let cameraManager = CameraManager(for: mapView, with: MapCameraOptions()) + let cameraManager = CameraAnimationsManager(for: mapView, with: MapCameraOptions()) cameraManager.setCamera(to: CameraOptions(center: self.centerCoordinate, zoom: 15.0)) } diff --git a/Tests/MapboxMapsTests/MapView/Integration Tests/Map/DidIdleFailureIntegrationTest.swift b/Tests/MapboxMapsTests/MapView/Integration Tests/Map/DidIdleFailureIntegrationTest.swift index ff58f427baf..9b0c219dfed 100644 --- a/Tests/MapboxMapsTests/MapView/Integration Tests/Map/DidIdleFailureIntegrationTest.swift +++ b/Tests/MapboxMapsTests/MapView/Integration Tests/Map/DidIdleFailureIntegrationTest.swift @@ -176,9 +176,6 @@ internal class DidIdleFailureIntegrationTest: IntegrationTestCase { style.uri = .streets - mapView.centerCoordinate = CLLocationCoordinate2D(latitude: 42.0, longitude: -71.0) - mapView.zoom = 8.0 - mapView.on(.mapLoadingError) { event in let userInfo: [String: Any] = (event.data as? [String: Any]) ?? [:] Log.error(forMessage: "Map failed to load with error: \(userInfo)", category: "Map")