diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationCameraController.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationCameraController.java index c923308079cf..e3393264f7a3 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationCameraController.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationCameraController.java @@ -330,7 +330,7 @@ void setEnabled(boolean enabled) { isEnabled = enabled; } - private boolean isLocationTracking() { + boolean isLocationTracking() { return cameraMode == CameraMode.TRACKING || cameraMode == CameraMode.TRACKING_COMPASS || cameraMode == CameraMode.TRACKING_GPS diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponent.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponent.java index 5809d13f12af..a55f388c121d 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponent.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponent.java @@ -1387,6 +1387,10 @@ private void updateAnimatorListenerHolders() { locationAnimatorCoordinator.resetAllLayerAnimations(); } + public boolean isLocationTracking() { + return locationCameraController.isLocationTracking(); + } + @NonNull private OnCameraMoveListener onCameraMoveListener = new OnCameraMoveListener() { @Override @@ -1546,7 +1550,7 @@ public void onRenderModeChanged(int currentMode) { new MapLibreMap.OnDeveloperAnimationListener() { @Override public void onDeveloperAnimationStarted() { - if (isComponentInitialized && isEnabled) { + if (isComponentInitialized && isEnabled && !options.concurrentCameraAnimationsEnabled()) { setCameraMode(CameraMode.NONE); } } diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java index 83b6db8a282e..b1bebcc10083 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/location/LocationComponentOptions.java @@ -144,6 +144,7 @@ public class LocationComponentOptions implements Parcelable { private float pulseAlpha; @Nullable private Interpolator pulseInterpolator; + private Boolean concurrentCameraAnimationsEnabled; public LocationComponentOptions( float accuracyAlpha, @@ -186,7 +187,8 @@ public LocationComponentOptions( float pulseSingleDuration, float pulseMaxRadius, float pulseAlpha, - @Nullable Interpolator pulseInterpolator) { + @Nullable Interpolator pulseInterpolator, + Boolean concurrentCameraAnimationsEnabled) { this.accuracyAlpha = accuracyAlpha; this.accuracyColor = accuracyColor; this.backgroundDrawableStale = backgroundDrawableStale; @@ -231,6 +233,7 @@ public LocationComponentOptions( this.pulseMaxRadius = pulseMaxRadius; this.pulseAlpha = pulseAlpha; this.pulseInterpolator = pulseInterpolator; + this.concurrentCameraAnimationsEnabled = concurrentCameraAnimationsEnabled; } /** @@ -373,6 +376,10 @@ public static LocationComponentOptions createFromAttributes(@NonNull Context con builder.pulseAlpha = typedArray.getFloat( R.styleable.maplibre_LocationComponent_maplibre_pulsingLocationCircleAlpha, CIRCLE_PULSING_ALPHA_DEFAULT); + builder.concurrentCameraAnimationsEnabled = typedArray.getBoolean( + R.styleable.maplibre_LocationComponent_maplibre_concurrentCameraAnimationsEnabled, false + ); + typedArray.recycle(); return builder.build(); @@ -892,6 +899,15 @@ public Interpolator pulseInterpolator() { return pulseInterpolator; } + /** + * Enable or disable concurrent camera animations during navigation + * + * @return whether concurrent camera animations are enabled or disabled during navigation + */ + public Boolean concurrentCameraAnimationsEnabled() { + return concurrentCameraAnimationsEnabled; + } + @NonNull @Override public String toString() { @@ -934,6 +950,7 @@ public String toString() { + "pulseSingleDuration=" + pulseSingleDuration + "pulseMaxRadius=" + pulseMaxRadius + "pulseAlpha=" + pulseAlpha + + "concurrentCameraAnimationsEnabled=" + concurrentCameraAnimationsEnabled + "}"; } @@ -1081,6 +1098,10 @@ public boolean equals(Object o) { return false; } + if (concurrentCameraAnimationsEnabled != options.concurrentCameraAnimationsEnabled) { + return false; + } + return layerBelow != null ? layerBelow.equals(options.layerBelow) : options.layerBelow == null; } @@ -1130,6 +1151,7 @@ public int hashCode() { result = 31 * result + (pulseSingleDuration != +0.0f ? Float.floatToIntBits(pulseSingleDuration) : 0); result = 31 * result + (pulseMaxRadius != +0.0f ? Float.floatToIntBits(pulseMaxRadius) : 0); result = 31 * result + (pulseAlpha != +0.0f ? Float.floatToIntBits(pulseAlpha) : 0); + result = 31 * result + (concurrentCameraAnimationsEnabled ? 1 : 0); return result; } @@ -1180,6 +1202,7 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeFloat(this.pulseSingleDuration); dest.writeFloat(this.pulseMaxRadius); dest.writeFloat(this.pulseAlpha); + dest.writeByte(this.concurrentCameraAnimationsEnabled ? (byte) 1 : (byte) 0); } protected LocationComponentOptions(Parcel in) { @@ -1223,6 +1246,7 @@ protected LocationComponentOptions(Parcel in) { this.pulseSingleDuration = in.readFloat(); this.pulseMaxRadius = in.readFloat(); this.pulseAlpha = in.readFloat(); + this.concurrentCameraAnimationsEnabled = in.readByte() != 0; } public static final Parcelable.Creator CREATOR = @@ -1349,6 +1373,7 @@ public LocationComponentOptions build() { private float pulseAlpha; @Nullable private Interpolator pulseInterpolator; + private Boolean concurrentCameraAnimationsEnabled; Builder() { } @@ -1395,6 +1420,7 @@ private Builder(LocationComponentOptions source) { this.pulseMaxRadius = source.pulseMaxRadius; this.pulseAlpha = source.pulseAlpha; this.pulseInterpolator = source.pulseInterpolator; + this.concurrentCameraAnimationsEnabled = source.concurrentCameraAnimationsEnabled; } /** @@ -1973,6 +1999,17 @@ public LocationComponentOptions.Builder pulseInterpolator(Interpolator pulseInte return this; } + /** + * Enable or disable the concurrent camera animations during navigation feature + * + * @return whether concurrent camera animations are enabled or disabled + */ + public LocationComponentOptions.Builder concurrentCameraAnimationsEnabled( + Boolean concurrentCameraAnimationsEnabled) { + this.concurrentCameraAnimationsEnabled = concurrentCameraAnimationsEnabled; + return this; + } + @Nullable LocationComponentOptions autoBuild() { String missing = ""; @@ -2074,7 +2111,8 @@ LocationComponentOptions autoBuild() { this.pulseSingleDuration, this.pulseMaxRadius, this.pulseAlpha, - this.pulseInterpolator); + this.pulseInterpolator, + this.concurrentCameraAnimationsEnabled); } } } diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapKeyListener.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapKeyListener.java index 238cce138548..175b0fbe9df4 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapKeyListener.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapKeyListener.java @@ -167,6 +167,14 @@ boolean onKeyUp(int keyCode, KeyEvent event) { PointF focalPoint = new PointF(uiSettings.getWidth() / 2, uiSettings.getHeight() / 2); mapGestureDetector.zoomInAnimated(focalPoint, true); return true; + case KeyEvent.KEYCODE_DEL: + if (!uiSettings.isZoomGesturesEnabled()) { + return false; + } + + // Zoom out + focalPoint = new PointF(uiSettings.getWidth() / 2, uiSettings.getHeight() / 2); + mapGestureDetector.zoomOutAnimated(focalPoint, true); } // We are not interested in this key diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/Transform.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/Transform.java index 3efc3c254df7..a26fb0c942d3 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/Transform.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/Transform.java @@ -190,6 +190,12 @@ CameraPosition invalidateCameraPosition() { } void cancelTransitions() { + MapLibreMap map = mapView.getMapLibreMap(); + if (map != null && map.getLocationComponent().isLocationComponentActivated() + && map.getLocationComponent().getLocationComponentOptions().concurrentCameraAnimationsEnabled()) { + return; + } + // notify user about cancel cameraChangeDispatcher.onCameraMoveCanceled(); diff --git a/platform/android/MapLibreAndroid/src/main/res-public/values/public.xml b/platform/android/MapLibreAndroid/src/main/res-public/values/public.xml index bd4ea5ee290a..9fd55c41c8b1 100644 --- a/platform/android/MapLibreAndroid/src/main/res-public/values/public.xml +++ b/platform/android/MapLibreAndroid/src/main/res-public/values/public.xml @@ -173,4 +173,7 @@ + + + diff --git a/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml b/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml index eb367bd917f8..ac61972fe04c 100644 --- a/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml +++ b/platform/android/MapLibreAndroid/src/main/res/values/attrs.xml @@ -203,5 +203,8 @@ + + + diff --git a/platform/android/MapLibreAndroid/src/main/res/values/styles.xml b/platform/android/MapLibreAndroid/src/main/res/values/styles.xml index 38459eb3a70c..18ad932628f0 100644 --- a/platform/android/MapLibreAndroid/src/main/res/values/styles.xml +++ b/platform/android/MapLibreAndroid/src/main/res/values/styles.xml @@ -42,5 +42,7 @@ 0.4 decelerate + + false diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/TransformTest.kt b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/TransformTest.kt index 75acdd8c60a0..e64a95e7a7e5 100644 --- a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/TransformTest.kt +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/TransformTest.kt @@ -29,6 +29,7 @@ class TransformTest : BaseTest() { nativeMapView = mockk() mainLooper = shadowOf(getMainLooper()) transform = Transform(mapView, nativeMapView, cameraChangeDispatcher) + every { mapView.getMapLibreMap() } returns null every { nativeMapView.isDestroyed } returns false every { nativeMapView.cameraPosition } returns CameraPosition.DEFAULT every { nativeMapView.cancelTransitions() } answers {} diff --git a/platform/ios/src/MLNMapView.h b/platform/ios/src/MLNMapView.h index d4e7fc3289db..eb4642688c47 100644 --- a/platform/ios/src/MLNMapView.h +++ b/platform/ios/src/MLNMapView.h @@ -612,6 +612,12 @@ MLN_EXPORT */ @property (nonatomic, readonly, nullable) MLNUserLocation *userLocation; +/** + * A Boolean value indicating whether independent camera animations + * can be applied at the same time or not. + */ +@property (nonatomic, assign) BOOL enableConcurrentCameraAnimation; + /** The mode used to track the user location. The default value is ``MLNUserTrackingMode/MLNUserTrackingModeNone``. diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index 56f5a385c140..da45100d4e78 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -4003,6 +4003,7 @@ - (void)setZoomLevel:(double)zoomLevel animated:(BOOL)animated { MLNLogDebug(@"Setting zoomLevel: %f animated: %@", zoomLevel, MLNStringFromBOOL(animated)); if (zoomLevel == self.zoomLevel) return; + [self cancelTransitions]; self.cameraChangeReasonBitmask |= MLNCameraChangeReasonProgrammatic; @@ -4483,7 +4484,7 @@ - (void)_flyToCamera:(MLNMapCamera *)camera edgePadding:(UIEdgeInsets)insets wit } - (void)cancelTransitions { - if (!_mbglMap) + if (!_mbglMap || self.enableConcurrentCameraAnimation) { return; } diff --git a/render-test/parser.cpp b/render-test/parser.cpp index 1bd2ad13886f..9f0d10ad66c4 100644 --- a/render-test/parser.cpp +++ b/render-test/parser.cpp @@ -90,6 +90,11 @@ document.getElementById('toggle-ignored').addEventListener('click', function (e) row.classList.toggle('hide'); } }); +document.getElementById('toggle-ignored-failed').addEventListener('click', function (e) { + for (const row of document.querySelectorAll('.test.ignored, .test.failed')) { + row.classList.toggle('hide'); + } +}); document.getElementById('toggle-sequence').addEventListener('click', function (e) { document.getElementById('test-sequence').classList.toggle('hide'); }); @@ -100,6 +105,7 @@ const char* resultsHeaderButtons = R"HTML( + )HTML"; diff --git a/src/mbgl/map/transform.cpp b/src/mbgl/map/transform.cpp index a16870706c33..517e954ec357 100644 --- a/src/mbgl/map/transform.cpp +++ b/src/mbgl/map/transform.cpp @@ -105,13 +105,13 @@ void Transform::jumpTo(const CameraOptions& camera) { * smooth animation between old and new values. The map will retain the current * values for any options not included in `options`. */ -void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& animation) { +void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& animationOptions) { CameraOptions camera = inputCamera; - Duration duration = animation.duration.value_or(Duration::zero()); + Duration duration = animationOptions.duration.value_or(Duration::zero()); if (state.getLatLngBounds() == LatLngBounds() && !isGestureInProgress() && duration != Duration::zero()) { // reuse flyTo, without exaggerated animation, to achieve constant ground speed. - flyTo(camera, animation, true); + flyTo(camera, animationOptions, true); return; } @@ -127,8 +127,8 @@ void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); if (std::isnan(zoom) || std::isnan(bearing) || std::isnan(pitch)) { - if (animation.transitionFinishFn) { - animation.transitionFinishFn(); + if (animationOptions.transitionFinishFn) { + animationOptions.transitionFinishFn(); } return; } @@ -161,37 +161,88 @@ void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& const double startZoom = state.getZoom(); const double startBearing = state.getBearing(); const double startPitch = state.getPitch(); - state.setProperties(TransformStateProperties() - .withPanningInProgress(unwrappedLatLng != startLatLng) - .withScalingInProgress(zoom != startZoom) - .withRotatingInProgress(bearing != startBearing)); const EdgeInsets startEdgeInsets = state.getEdgeInsets(); - startTransition( - camera, - animation, - [=, this](double t) { - Point framePoint = util::interpolate(startPoint, endPoint, t); - LatLng frameLatLng = Projection::unproject(framePoint, state.zoomScale(startZoom)); - double frameZoom = util::interpolate(startZoom, zoom, t); - state.setLatLngZoom(frameLatLng, frameZoom); - if (bearing != startBearing) { - state.setBearing(util::wrap(util::interpolate(startBearing, bearing, t), -pi, pi)); - } - if (padding != startEdgeInsets) { - // Interpolate edge insets - EdgeInsets edgeInsets; - state.setEdgeInsets({util::interpolate(startEdgeInsets.top(), padding.top(), t), - util::interpolate(startEdgeInsets.left(), padding.left(), t), - util::interpolate(startEdgeInsets.bottom(), padding.bottom(), t), - util::interpolate(startEdgeInsets.right(), padding.right(), t)}); - } - double maxPitch = getMaxPitchForEdgeInsets(state.getEdgeInsets()); - if (pitch != startPitch || maxPitch < startPitch) { - state.setPitch(std::min(maxPitch, util::interpolate(startPitch, pitch, t))); - } - }, - duration); + auto animation = std::make_shared(Clock::now(), + duration, + animationOptions, + unwrappedLatLng != startLatLng, + zoom != startZoom, + bearing != startBearing); + + // NOTE: For tests only + transitionStart = animation->start; + transitionDuration = animation->duration; + + if (!properties.zoom.set || startZoom != zoom) { + if (properties.zoom.set && properties.zoom.current != properties.zoom.target && properties.zoom.animation) { + animationFinishFrame(*properties.zoom.animation); + } + properties.zoom = { + .animation = animation, + .current = startZoom, + .target = zoom, + .set = true, + .frameZoomFunc = + [startZoom, zoom, animation](TimePoint now) { + return util::interpolate(startZoom, zoom, animation->interpolant(now)); + }, + }; + } + if (!properties.latlng.set || startPoint != endPoint) { + if (properties.latlng.set && properties.latlng.current != properties.latlng.target && + properties.latlng.animation) { + animationFinishFrame(*properties.latlng.animation); + } + properties.latlng = { + .animation = animation, + .current = startPoint, + .target = endPoint, + .set = true, + .frameLatLngFunc = + [startPoint, endPoint, startZoom, animation, this](TimePoint now) { + Point framePoint = util::interpolate(startPoint, endPoint, animation->interpolant(now)); + return Projection::unproject(framePoint, state.zoomScale(startZoom)); + }, + }; + } + if (!properties.bearing.set || bearing != startBearing) { + if (properties.bearing.set && properties.bearing.current != properties.bearing.target && + properties.bearing.animation) { + animationFinishFrame(*properties.bearing.animation); + } + properties.bearing = { + .animation = animation, + .current = startBearing, + .target = bearing, + .set = true, + }; + } + if (!properties.padding.set || padding != startEdgeInsets) { + if (properties.padding.set && properties.padding.current != properties.padding.target && + properties.padding.animation) { + animationFinishFrame(*properties.padding.animation); + } + properties.padding = { + .animation = animation, + .current = startEdgeInsets, + .target = padding, + .set = true, + }; + } + if (!properties.pitch.set || pitch != startPitch) { + if (properties.pitch.set && properties.pitch.current != properties.pitch.target && properties.pitch.animation) { + animationFinishFrame(*properties.pitch.animation); + } + properties.pitch = { + .animation = animation, + .current = startPitch, + .target = pitch, + .set = true, + }; + } + + startTransition(camera, duration, *animation); } /** This method implements an “optimal path” animation, as detailed in: @@ -203,7 +254,7 @@ void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& Where applicable, local variable documentation begins with the associated variable or function in van Wijk (2003). */ void Transform::flyTo(const CameraOptions& inputCamera, - const AnimationOptions& animation, + const AnimationOptions& animationOptions, bool linearZoomInterpolation) { CameraOptions camera = inputCamera; @@ -217,8 +268,8 @@ void Transform::flyTo(const CameraOptions& inputCamera, double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); if (std::isnan(zoom) || std::isnan(bearing) || std::isnan(pitch) || state.getSize().isEmpty()) { - if (animation.transitionFinishFn) { - animation.transitionFinishFn(); + if (animationOptions.transitionFinishFn) { + animationOptions.transitionFinishFn(); } return; } @@ -262,8 +313,8 @@ void Transform::flyTo(const CameraOptions& inputCamera, root mean squared average velocity, VRMS. A value of 1 produces a circular motion. */ double rho = 1.42; - if (animation.minZoom || linearZoomInterpolation) { - double minZoom = util::min(animation.minZoom.value_or(startZoom), startZoom, zoom); + if (animationOptions.minZoom || linearZoomInterpolation) { + double minZoom = util::min(animationOptions.minZoom.value_or(startZoom), startZoom, zoom); minZoom = util::clamp(minZoom, state.getMinZoom(), state.getMaxZoom()); /// wm: Maximum visible span, measured in pixels with respect /// to the initial scale. @@ -306,71 +357,133 @@ void Transform::flyTo(const CameraOptions& inputCamera, double S = (isClose ? (std::abs(std::log(w1 / w0)) / rho) : ((r1 - r0) / rho)); Duration duration; - if (animation.duration) { - duration = *animation.duration; + if (animationOptions.duration) { + duration = *animationOptions.duration; } else { /// V: Average velocity, measured in ρ-screenfuls per second. double velocity = 1.2; - if (animation.velocity) { - velocity = *animation.velocity / rho; + if (animationOptions.velocity) { + velocity = *animationOptions.velocity / rho; } duration = std::chrono::duration_cast(std::chrono::duration(S / velocity)); } if (duration == Duration::zero()) { // Perform an instantaneous transition. jumpTo(camera); - if (animation.transitionFinishFn) { - animation.transitionFinishFn(); + if (animationOptions.transitionFinishFn) { + animationOptions.transitionFinishFn(); } return; } const double startScale = state.getScale(); - state.setProperties( - TransformStateProperties().withPanningInProgress(true).withScalingInProgress(true).withRotatingInProgress( - bearing != startBearing)); const EdgeInsets startEdgeInsets = state.getEdgeInsets(); - startTransition( - camera, - animation, - [=, this](double k) { - /// s: The distance traveled along the flight path, measured in - /// ρ-screenfuls. - double s = k * S; - double us = k == 1.0 ? 1.0 : u(s); - - // Calculate the current point and zoom level along the flight path. - Point framePoint = util::interpolate(startPoint, endPoint, us); - double frameZoom = linearZoomInterpolation ? util::interpolate(startZoom, zoom, k) - : startZoom + state.scaleZoom(1 / w(s)); - - // Zoom can be NaN if size is empty. - if (std::isnan(frameZoom)) { - frameZoom = zoom; - } + auto animation = std::make_shared( + Clock::now(), duration, animationOptions, true, true, bearing != startBearing); - // Convert to geographic coordinates and set the new viewpoint. - LatLng frameLatLng = Projection::unproject(framePoint, startScale); - state.setLatLngZoom(frameLatLng, frameZoom); - if (bearing != startBearing) { - state.setBearing(util::wrap(util::interpolate(startBearing, bearing, k), -pi, pi)); - } + // NOTE: For tests only + transitionStart = animation->start; + transitionDuration = animation->duration; - if (padding != startEdgeInsets) { - // Interpolate edge insets - state.setEdgeInsets({util::interpolate(startEdgeInsets.top(), padding.top(), k), - util::interpolate(startEdgeInsets.left(), padding.left(), k), - util::interpolate(startEdgeInsets.bottom(), padding.bottom(), k), - util::interpolate(startEdgeInsets.right(), padding.right(), k)}); - } - double maxPitch = getMaxPitchForEdgeInsets(state.getEdgeInsets()); + if (!properties.zoom.set || startZoom != zoom) { + if (properties.zoom.set && properties.zoom.current != properties.zoom.target && properties.zoom.animation) { + animationFinishFrame(*properties.zoom.animation); + } + properties.zoom = { + .animation = animation, + .current = startZoom, + .target = zoom, + .set = true, + .frameZoomFunc = + [linearZoomInterpolation, startZoom, zoom, S, w, this](TimePoint now) { + double t = properties.zoom.animation->interpolant(now); + double s = t * S; + double frameZoom = linearZoomInterpolation ? util::interpolate(startZoom, zoom, t) + : startZoom + state.scaleZoom(1 / w(s)); + + if (std::isnan(frameZoom)) { + frameZoom = zoom; + } + + return frameZoom; + }, + }; + } + if (!properties.latlng.set || startPoint != endPoint) { + if (properties.latlng.set && properties.latlng.current != properties.latlng.target && + properties.latlng.animation) { + animationFinishFrame(*properties.latlng.animation); + } + properties.latlng = { + .animation = animation, + .current = startPoint, + .target = endPoint, + .set = true, + .frameLatLngFunc = + [startScale, S, u, this](TimePoint now) { + double t = properties.latlng.animation->interpolant(now); + double s = t * S; + double us = t == 1.0 ? 1.0 : u(s); + + Point framePoint = util::interpolate( + properties.latlng.current, properties.latlng.target, us); + return Projection::unproject(framePoint, startScale); + }, + }; + } + if (!properties.bearing.set || bearing != startBearing) { + if (properties.bearing.set && properties.bearing.current != properties.bearing.target && + properties.bearing.animation) { + animationFinishFrame(*properties.bearing.animation); + } + properties.bearing = { + .animation = animation, + .current = startBearing, + .target = bearing, + .set = true, + }; + } + if (!properties.padding.set || padding != startEdgeInsets) { + if (properties.padding.set && properties.padding.current != properties.padding.target && + properties.padding.animation) { + animationFinishFrame(*properties.padding.animation); + } + properties.padding = {.animation = animation, .current = startEdgeInsets, .target = padding, .set = true}; + } + if (!properties.pitch.set || pitch != startPitch) { + if (properties.pitch.set && properties.pitch.current != properties.pitch.target && properties.pitch.animation) { + animationFinishFrame(*properties.pitch.animation); + } + properties.pitch = { + .animation = animation, + .current = startPitch, + .target = pitch, + .set = true, + }; + } - if (pitch != startPitch || maxPitch < startPitch) { - state.setPitch(std::min(maxPitch, util::interpolate(startPitch, pitch, k))); - } - }, - duration); + startTransition(camera, duration, *animation); +} + +bool Transform::animationTransitionFrame(Animation& animation, double t) { + if (animation.ran) { + return animation.done; + } + + animation.ran = true; + if (t < 1.0) { + if (animation.options.transitionFrameFn) { + animation.options.transitionFrameFn(t); + } + + observer.onCameraIsChanging(); + animation.done = false; + } else { + animation.done = true; + } + + return animation.done; } // MARK: - Position @@ -522,130 +635,150 @@ ProjectionMode Transform::getProjectionMode() const { // MARK: - Transition -void Transform::startTransition(const CameraOptions& camera, - const AnimationOptions& animation, - const std::function& frame, - const Duration& duration) { - if (transitionFinishFn) { - transitionFinishFn(); +void Transform::animationFinishFrame(Animation& animation) { + if (animation.finished) { + return; + } + + if (animation.options.transitionFinishFn) { + animation.options.transitionFinishFn(); } - bool isAnimated = duration != Duration::zero(); + animation.finished = true; + + if (animation.anchor) animation.anchor = std::nullopt; + + observer.onCameraDidChange(animation.isAnimated() ? MapObserver::CameraChangeMode::Animated + : MapObserver::CameraChangeMode::Immediate); +} + +void Transform::startTransition(const CameraOptions& camera, const Duration& duration, Animation& animation) { + const bool isAnimated = duration != Duration::zero(); observer.onCameraWillChange(isAnimated ? MapObserver::CameraChangeMode::Animated : MapObserver::CameraChangeMode::Immediate); // Associate the anchor, if given, with a coordinate. // Anchor and center points are mutually exclusive, with preference for the // center point when both are set. - std::optional anchor = camera.center ? std::nullopt : camera.anchor; - LatLng anchorLatLng; - if (anchor) { - anchor->y = state.getSize().height - anchor->y; - anchorLatLng = state.screenCoordinateToLatLng(*anchor); + if (!camera.center && camera.anchor) { + animation.anchor = camera.anchor; + animation.anchor->y = state.getSize().height - animation.anchor->y; + animation.anchorLatLng = state.screenCoordinateToLatLng(*animation.anchor); } - transitionStart = Clock::now(); - transitionDuration = duration; + if (!isAnimated) { + activeAnimation = false; + updateTransitions(Clock::now()); + } +} - transitionFrameFn = [isAnimated, animation, frame, anchor, anchorLatLng, this](const TimePoint now) { - float t = isAnimated ? (std::chrono::duration(now - transitionStart) / transitionDuration) : 1.0f; - if (t >= 1.0) { - frame(1.0); - } else { - util::UnitBezier ease = animation.easing ? *animation.easing : util::DEFAULT_TRANSITION_EASE; - frame(ease.solve(t, 0.001)); - } +bool Transform::inTransition() const { + return properties.latlng.set || properties.zoom.set || properties.bearing.set || properties.padding.set || + properties.pitch.set; +} - if (anchor) state.moveLatLng(anchorLatLng, *anchor); +void Transform::updateTransitions(const TimePoint& now) { + if (!activeAnimation) { + activeAnimation = true; + + bool panning = false; + bool scaling = false; + bool rotating = false; + visitProperties([&](Animation& animation) { + if (!animation.done) { + panning |= animation.panning; + scaling |= animation.scaling; + rotating |= animation.rotating; + } + }); + + state.setProperties(TransformStateProperties() + .withPanningInProgress(panning) + .withScalingInProgress(scaling) + .withRotatingInProgress(rotating)); + + const bool zoomSet = properties.zoom.set && properties.zoom.animation; + const bool latlngSet = properties.latlng.set && properties.latlng.animation; + if (latlngSet || zoomSet) { + state.setLatLngZoom( + properties.latlng.frameLatLngFunc ? properties.latlng.frameLatLngFunc(now) : state.getLatLng(), + properties.zoom.frameZoomFunc ? properties.zoom.frameZoomFunc(now) : state.getZoom()); + if (properties.latlng.animation && + animationTransitionFrame(*properties.latlng.animation, properties.latlng.animation->interpolant(now))) { + properties.latlng.set = false; + } + if (properties.zoom.animation && + animationTransitionFrame(*properties.zoom.animation, properties.zoom.animation->interpolant(now))) { + properties.zoom.set = false; + } - // At t = 1.0, a DidChangeAnimated notification should be sent from finish(). - if (t < 1.0) { - if (animation.transitionFrameFn) { - animation.transitionFrameFn(t); + // Prioritize zoom anchor over latlng anchor if two concurrent animations are active, + // if it's the same animation then we apply the first one only. + if (zoomSet && properties.zoom.animation->anchor) { + state.moveLatLng(properties.zoom.animation->anchorLatLng, *properties.zoom.animation->anchor); + } else if (latlngSet && properties.latlng.animation->anchor) { + state.moveLatLng(properties.latlng.animation->anchorLatLng, *properties.latlng.animation->anchor); } - observer.onCameraIsChanging(); - return false; - } else { - // Indicate that we need to terminate this transition - return true; } - }; - - transitionFinishFn = [isAnimated, animation, this] { - state.setProperties( - TransformStateProperties().withPanningInProgress(false).withScalingInProgress(false).withRotatingInProgress( - false)); - if (animation.transitionFinishFn) { - animation.transitionFinishFn(); + if (properties.bearing.set && properties.bearing.animation) { + const double bearing_t = properties.bearing.animation->interpolant(now); + state.setBearing(util::wrap( + util::interpolate(properties.bearing.current, properties.bearing.target, bearing_t), -pi, pi)); + if (animationTransitionFrame(*properties.bearing.animation, bearing_t)) { + properties.bearing.set = false; + } } - observer.onCameraDidChange(isAnimated ? MapObserver::CameraChangeMode::Animated - : MapObserver::CameraChangeMode::Immediate); - }; - if (!isAnimated) { - auto update = std::move(transitionFrameFn); - auto finish = std::move(transitionFinishFn); + if (properties.padding.set && properties.padding.animation) { + const double padding_t = properties.padding.animation->interpolant(now); + state.setEdgeInsets( + {util::interpolate(properties.padding.current.top(), properties.padding.target.top(), padding_t), + util::interpolate(properties.padding.current.left(), properties.padding.target.left(), padding_t), + util::interpolate(properties.padding.current.bottom(), properties.padding.target.bottom(), padding_t), + util::interpolate(properties.padding.current.right(), properties.padding.target.right(), padding_t)}); + if (animationTransitionFrame(*properties.padding.animation, padding_t)) { + properties.padding.set = false; + } + } - transitionFrameFn = nullptr; - transitionFinishFn = nullptr; + const double maxPitch = getMaxPitchForEdgeInsets(state.getEdgeInsets()); + if ((properties.pitch.set || maxPitch < properties.pitch.current) && properties.pitch.animation) { + double pitch_t = properties.pitch.animation->interpolant(now); + state.setPitch( + std::min(maxPitch, util::interpolate(properties.pitch.current, properties.pitch.target, pitch_t))); + if (animationTransitionFrame(*properties.pitch.animation, pitch_t)) { + properties.pitch.set = false; + } + } - update(Clock::now()); - finish(); - } -} + panning = false; + scaling = false; + rotating = false; + visitProperties([&](Animation& animation) { + if (animation.done) { + animationFinishFrame(animation); + } else { + panning |= animation.panning; + scaling |= animation.scaling; + rotating |= animation.rotating; + } + animation.ran = false; + }); -bool Transform::inTransition() const { - return transitionFrameFn != nullptr; -} + state.setProperties(TransformStateProperties() + .withPanningInProgress(panning) + .withScalingInProgress(scaling) + .withRotatingInProgress(rotating)); -void Transform::updateTransitions(const TimePoint& now) { - // Use a temporary function to ensure that the transitionFrameFn lambda is - // called only once per update. - - // This addresses the symptoms of - // https://github.com/mapbox/mapbox-gl-native/issues/11180 where setting a - // shape source to nil (or similar) in the `onCameraIsChanging` observer - // function causes `Map::Impl::onUpdate()` to be called which in turn calls - // this function (before the current iteration has completed), leading to an - // infinite loop. See https://github.com/mapbox/mapbox-gl-native/issues/5833 - // for a similar, related, issue. - // - // By temporarily nulling the `transitionFrameFn` (and then restoring it - // after the temporary has been called) we stop this recursion. - // - // It's important to note that the scope of this change is stop the above - // crashes. It doesn't address any potential deeper issue (for example - // user error, how often and when transition callbacks are called). - - auto transition = std::move(transitionFrameFn); - transitionFrameFn = nullptr; - - if (transition && transition(now)) { - // If the transition indicates that it is complete, then we should call - // the finish lambda (going via a temporary as above) - auto finish = std::move(transitionFinishFn); - - transitionFinishFn = nullptr; - transitionFrameFn = nullptr; - - if (finish) { - finish(); - } - } else if (!transitionFrameFn) { - // We have to check `transitionFrameFn` is nil here, since a new - // transition may have been triggered in a user callback (from the - // transition call above) - transitionFrameFn = std::move(transition); + activeAnimation = false; } } void Transform::cancelTransitions() { - if (transitionFinishFn) { - transitionFinishFn(); - } + visitProperties([this](Animation& animation) { animationFinishFrame(animation); }); - transitionFrameFn = nullptr; - transitionFinishFn = nullptr; + properties = {}; + activeAnimation = false; } void Transform::setGestureInProgress(bool inProgress) { @@ -695,4 +828,14 @@ void Transform::setFreeCameraOptions(const FreeCameraOptions& options) { state.setFreeCameraOptions(options); } +double Transform::Animation::interpolant(const TimePoint& now) const { + double t = isAnimated() ? (std::chrono::duration(now - start) / duration) : 1.0f; + if (t >= 1.0) { + return 1.0; + } + + util::UnitBezier ease = options.easing ? *options.easing : util::DEFAULT_TRANSITION_EASE; + return ease.solve(t, 0.001); +} + } // namespace mbgl diff --git a/src/mbgl/map/transform.hpp b/src/mbgl/map/transform.hpp index 4828f372f686..b01ccaf429c1 100644 --- a/src/mbgl/map/transform.hpp +++ b/src/mbgl/map/transform.hpp @@ -133,21 +133,92 @@ class Transform : private util::noncopyable { void setFreeCameraOptions(const FreeCameraOptions& options); private: + struct Animation { + const TimePoint start; + const Duration duration; + const AnimationOptions options; + bool ran = false; // Did this property animation run this frame + bool finished = false; // Did we execute the finish frame for this property animation this frame + bool done = false; // Did this property animation reach the end of the frame + // The below variables keep track of the panning, scaling, and rotating transform state + // so we can correctly set it at the end of the `updateTransitions` if more + // than one `Animation` is running at the same time. + const bool panning; + const bool scaling; + const bool rotating; + + // Anchor + std::optional anchor; + LatLng anchorLatLng; + + Animation(TimePoint start_, + Duration duration_, + AnimationOptions options_, + bool panning_, + bool scaling_, + bool rotating_) + : start(start_), + duration(duration_), + options(options_), + panning(panning_), + scaling(scaling_), + rotating(rotating_) {} + + double interpolant(const TimePoint&) const; + + bool isAnimated() const { return duration != Duration::zero(); } + }; + + template + struct Property { + std::shared_ptr animation; + T current; + T target; + bool set = false; + + std::function frameLatLngFunc = nullptr; + std::function frameZoomFunc = nullptr; + }; + + struct Properties { + Property> latlng; + Property zoom, bearing, pitch; + Property padding; + }; + TransformObserver& observer; TransformState state; - void startTransition(const CameraOptions&, - const AnimationOptions&, - const std::function&, - const Duration&); + void startTransition(const CameraOptions&, const Duration&, Animation&); + bool animationTransitionFrame(Animation&, const double); + void animationFinishFrame(Animation&); + + void visitProperties(const std::function& f) { + if (properties.zoom.animation) { + f(*properties.zoom.animation); + } + if (properties.latlng.animation) { + f(*properties.latlng.animation); + } + if (properties.bearing.animation) { + f(*properties.bearing.animation); + } + if (properties.padding.animation) { + f(*properties.padding.animation); + } + if (properties.pitch.animation) { + f(*properties.pitch.animation); + } + } // We don't want to show horizon: limit max pitch based on edge insets. double getMaxPitchForEdgeInsets(const EdgeInsets& insets) const; + Properties properties; + bool activeAnimation = false; + TimePoint transitionStart; Duration transitionDuration; - std::function transitionFrameFn; - std::function transitionFinishFn; }; } // namespace mbgl diff --git a/test/map/transform.test.cpp b/test/map/transform.test.cpp index e98b82c7d159..00101c7bb9f3 100644 --- a/test/map/transform.test.cpp +++ b/test/map/transform.test.cpp @@ -1189,3 +1189,119 @@ TEST(Transform, FreeCameraOptionsStateSynchronization) { EXPECT_THAT(up, Vec3NearEquals1E5(vec3{{0, -0.5, 0.866025}})); EXPECT_THAT(forward, Vec3NearEquals1E5(vec3{{0, -0.866025, -0.5}})); } + +TEST(Transform, ConcurrentAnimation) { + Transform transform; + transform.resize({1, 1}); + + const LatLng defaultLatLng{0, 0}; + CameraOptions defaultCameraOptions = + CameraOptions().withCenter(defaultLatLng).withZoom(0).withPitch(0).withBearing(0); + transform.jumpTo(defaultCameraOptions); + ASSERT_DOUBLE_EQ(transform.getLatLng().latitude(), 0); + ASSERT_DOUBLE_EQ(transform.getLatLng().longitude(), 0); + ASSERT_DOUBLE_EQ(0, transform.getZoom()); + ASSERT_DOUBLE_EQ(0, transform.getPitch()); + ASSERT_DOUBLE_EQ(0, transform.getBearing()); + + const LatLng latLng{45, 135}; + const double zoom = 10; + CameraOptions zoomLatLngCameraOptions = CameraOptions().withCenter(latLng).withZoom(zoom); + AnimationOptions zoomLatLngOptions(Seconds(1)); + int zoomLatLngFrameCallbackCount = 0; + zoomLatLngOptions.transitionFrameFn = [&](double t) { + zoomLatLngFrameCallbackCount++; + + ASSERT_TRUE(t >= 0 && t <= 1); + ASSERT_GE(latLng.latitude(), transform.getLatLng().latitude()); + ASSERT_GE(latLng.longitude(), transform.getLatLng().longitude()); + ASSERT_GE(zoom, transform.getZoom()); + }; + int zoomLatLngFinishCallbackCount = 0; + zoomLatLngOptions.transitionFinishFn = [&]() { + zoomLatLngFinishCallbackCount++; + + ASSERT_DOUBLE_EQ(latLng.latitude(), transform.getLatLng().latitude()); + ASSERT_DOUBLE_EQ(latLng.longitude(), transform.getLatLng().longitude()); + ASSERT_DOUBLE_EQ(zoom, transform.getZoom()); + }; + transform.easeTo(zoomLatLngCameraOptions, zoomLatLngOptions); + + ASSERT_TRUE(transform.inTransition()); + + TimePoint transitionStart = transform.getTransitionStart(); + + const double pitch = 60; + const double bearing = 45; + CameraOptions pitchBearingCameraOptions = CameraOptions().withPitch(pitch).withBearing(bearing); + AnimationOptions pitchBearingOptions(Seconds(2)); + int pitchBearingFrameCallbackCount = 0; + pitchBearingOptions.transitionFrameFn = [&](double t) { + pitchBearingFrameCallbackCount++; + + ASSERT_TRUE(t >= 0 && t <= 1); + ASSERT_GE(util::deg2rad(pitch), transform.getPitch()); + ASSERT_LE(-util::deg2rad(bearing), transform.getBearing()); + }; + int pitchBearingFinishCallbackCount = 0; + pitchBearingOptions.transitionFinishFn = [&]() { + pitchBearingFinishCallbackCount++; + + ASSERT_DOUBLE_EQ(util::deg2rad(pitch), transform.getPitch()); + ASSERT_DOUBLE_EQ(-util::deg2rad(bearing), transform.getBearing()); + }; + transform.easeTo(pitchBearingCameraOptions, pitchBearingOptions); + + ASSERT_TRUE(transform.inTransition()); + transform.updateTransitions(transitionStart + Milliseconds(500)); + transform.updateTransitions(transitionStart + Milliseconds(900)); + ASSERT_TRUE(transform.inTransition()); // Second Transition is still running + transform.updateTransitions(transform.getTransitionStart() + Seconds(2)); + ASSERT_FALSE(transform.inTransition()); + + ASSERT_EQ(zoomLatLngFrameCallbackCount, 2); + ASSERT_EQ(zoomLatLngFinishCallbackCount, 1); + ASSERT_EQ(pitchBearingFrameCallbackCount, 2); + ASSERT_EQ(pitchBearingFinishCallbackCount, 1); + + // Test cancelTransitions with concurrent animations + const LatLng latLng2{0, 0}; + const double zoom2 = 0; + CameraOptions zoomLatLngCameraOptions2 = CameraOptions().withCenter(latLng2).withZoom(zoom2); + AnimationOptions zoomLatLngOptions2(Seconds(1)); + transform.easeTo(zoomLatLngCameraOptions2, zoomLatLngOptions2); + + const double pitch2 = 0; + const double bearing2 = 0; + CameraOptions pitchBearingCameraOptions2 = CameraOptions().withPitch(pitch2).withBearing(bearing2); + AnimationOptions pitchBearingOptions2(Seconds(2)); + transform.easeTo(pitchBearingCameraOptions2, pitchBearingOptions2); + + ASSERT_TRUE(transform.inTransition()); + transform.cancelTransitions(); + ASSERT_FALSE(transform.inTransition()); + + // Reset State + transform.jumpTo(defaultCameraOptions); + + zoomLatLngFrameCallbackCount = 0; + zoomLatLngFinishCallbackCount = 0; + transform.easeTo(zoomLatLngCameraOptions, zoomLatLngOptions); + + transitionStart = transform.getTransitionStart(); + + pitchBearingFrameCallbackCount = 0; + pitchBearingFinishCallbackCount = 0; + pitchBearingOptions.duration = Seconds(0); + transform.easeTo(pitchBearingCameraOptions, pitchBearingOptions); + + ASSERT_TRUE(transform.inTransition()); + transform.updateTransitions(transitionStart + Milliseconds(500)); + transform.updateTransitions(transitionStart + Seconds(1)); + ASSERT_FALSE(transform.inTransition()); + + ASSERT_EQ(zoomLatLngFrameCallbackCount, 2); + ASSERT_EQ(zoomLatLngFinishCallbackCount, 1); + ASSERT_EQ(pitchBearingFrameCallbackCount, 0); + ASSERT_EQ(pitchBearingFinishCallbackCount, 1); +}