maplibre / maplibre-native

MapLibre Native - Interactive vector tile maps for iOS, Android and other platforms.
https://maplibre.org
BSD 2-Clause "Simplified" License
1.07k stars 317 forks source link

Improve fling animation #927

Closed Helium314 closed 1 year ago

Helium314 commented 1 year ago

The current fling animation feels very awkward, and is different to basically all/most other map engines and online maps. It appears more like an easeCamera animation than a slowly decelerating continuation of the previous movement. After some testing I think the issue mainly comes from

I tried using a custom move gesture detector (see below) to implement a slightly modified fling gesture. This feels much better than current behavior, but a) still a little awkward (due to the sudden stop) and b) requires re-implementing move and pinch-zoom gestures. A first and simple step could be allowing to choose the fling velocity threshold and animation base time, but ideally the camera velocity behavior during the movement would be adjusted.

            override fun onMoveEnd(p0: MoveGestureDetector, velocityX: Float, velocityY: Float) {
                // maybe fling camera using somewhat different settings
                val minVelocity = 200 // default is 1000
                val animationVelocityFactor = 1.0 / 7 // default is 1 / 7
                val animationDurationOffset = 500 // default is 150
                // without moveFactor (or setting to 1)
                //  the move feels too far
                //  the move starts too fast (animation should not start faster than the move before lifting finger)
                val moveFactor = 0.5

                // reproduce roughly what maplibre does
                val screenDensity = mapView.pixelRatio
                val velocityXY = hypot(velocityX / screenDensity, velocityY / screenDensity)
                if (velocityXY < minVelocity) return
                val tiltFactor = 1.5 // assume tilt = 0 for simplicity
                // default way of determining animation time
                val animationTime = (velocityXY * animationVelocityFactor / tiltFactor + animationDurationOffset).toInt()
                // determine target position
                val offsetX = velocityX / tiltFactor / screenDensity
                val offsetY = velocityY / tiltFactor / screenDensity
                // below is mostly guess, but seems to fit at least roughly
                val c = mapboxMap.cameraPosition
                val p = c.target ?: return
                val metersPerPixel = mapboxMap.projection.getMetersPerPixelAtLatitude(p.latitude)
                val degreesPerMeterLat = 1 / 111225.0 // only approximate, but good enough
                val degreesPerMeterLon = degreesPerMeterLat / (cos(p.latitude * Math.PI / 180.0).coerceAtLeast(0.001))
                val moveLng = offsetX * degreesPerMeterLon * metersPerPixel * moveFactor
                val moveLat = offsetY * degreesPerMeterLat * metersPerPixel * moveFactor
                val target = LatLng(p.latitude + moveLat, p.longitude - moveLng)

                // move camera
                val update = CameraUpdateFactory.CameraPositionUpdate(c.bearing, target, c.tilt, c.zoom, c.padding)
                mapboxMap.easeCamera(update, animationTime)
            }
ovivoda commented 1 year ago

@Helium314 can you share a video of this?

Helium314 commented 1 year ago

Two videos using a testing version of StreetComplete for migration from Tangram to MapLibre:

In the first video you first see current behavior with Tangram (on the left), which I would like to have in MapLibre. Every move, unless very slow, leads to the map gliding a little bit. On the right you see MapLibre with the move gesture detector above (there is some onMove code which I didn't include, otherwise scrolling wouldn't work). The deceleration is clearly less "smooth" than Tangram, but using the map still feels acceptable compared to Tangram.

https://user-images.githubusercontent.com/43007630/227012663-7d528c62-a74c-4b58-a75c-965fb6e6ea5a.mp4

In the second video is MapLibre's default behavior. You can't see the taps, but basically either the move stops immediately when I take my finger off the screen, or there is this rather big "jump" with sudden acceleration and stop. This may be related to https://github.com/maplibre/maplibre-gl-js/issues/2238, which looks similar but with shorter animation.

https://user-images.githubusercontent.com/43007630/227012670-b4b8f775-2446-44a8-8eec-82e3906b238d.mp4

As far as I understand, the fling ends up calling moveBy, which sets some animation parameters that (I assume) are used for interpolation. Then it might be enough to tune the parameters, though I'm not sure whether the starting velocity is taken into account here.

Helium314 commented 1 year ago

Assuming the UnitBezier in animationOptions.easing.emplace(mbgl::util::UnitBezier {0.25, 0.46, 0.45, 0.94}); is the same thing as what I can play with on https://cubic-bezier.com/#.25,.46,.45,.94:

wipfli commented 1 year ago

I think it would look best if the camera velocity increased linearly from zero to a maximum value and then decreased linearly to zero again. People sometimes use a quaternion slerp to interpolate the angles.

Helium314 commented 1 year ago

I think it would look best if the camera velocity increased linearly from zero to a maximum value and then decreased linearly to zero again.

Wouldn't this make the fling animation even more awkward? I can't imagine how a fling would look better if the camera started at velocity 0.

It's JS and not native, but also in ML JS the fling it not nice (imo actually worse than in native): Compare the fling behavior of https://demotiles.maplibre.org with other maps like https://www.openstreetmap.org, https://streetcomplete.github.io/streetcomplete-mapstyle/?provider=jawg, or https://www.google.com/maps Doing it different is not wrong by itself, but in my opinion the others do it in a better way. And in a way that is very similar, which is convenient for users who use different map sites/apps.

ovivoda commented 1 year ago

Thanks @Helium314 ! Let me ask around a bit and gather community feedback based on your proposal.

One thing that we can do is to keep old behaviour but also allow the behaviour you proposed.

Are you willing to contribute with a PR that will enable this ˆˆˆ?

1ec5 commented 1 year ago

It's JS and not native, but also in ML JS the fling it not nice (imo actually worse than in native)

GL JS gestures have been primarily optimized for non-touch input. I ran into something similar way back in mapbox/mapbox-gl-native#1266. It felt like the map had a lot of “friction” because you had to fling the map very hard to get it to move much. It used mbgl’s default animation curve but with a duration that was determined by an unnecessarily complex formula. The solution on iOS was to replace it with a much more straightforward, 1-second-long animation, matching the behavior of not only MapKit but also every standard UIKit control on the system (scroll views, menus, etc.). Applying this same duration to other gestures for zooming and rotation made the map feel a lot more fluid.

The Android map SDK subsequently updated its gesture handling in mapbox/mapbox-gl-native#553, but I don’t know if it followed the iOS implementation precisely. In any case, platform parity is not as important as matching the user’s expectations based on common controls.

Helium314 commented 1 year ago

I played quite a bit with different fling parameters. Here is what I found best:

Are you willing to contribute with a PR that will enable this ˆˆˆ?

I can try... What did you have in mind? Simply switching between the 2 animation types? Or more flexibility, like configurable fling threshold and (base) animation time? And what about the UnitBezier parameters? In my opinion they are more suitable and could be used for both old and new animations.

ovivoda commented 1 year ago

@Helium314 I think the more flexible solution the better. But we can do this step by step maybe?

louwers commented 1 year ago

@Helium314 Can this be closed now?

Helium314 commented 1 year ago

Yes, done now. Thanks!

louwers commented 1 year ago

@Helium314 Thank YOU! 🙂