Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pan Deceleration #692

Merged
merged 4 commits into from
Sep 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ Mapbox welcomes participation and contributions from everyone.
* `GestureOptions.hapticFeedbackEnabled` has been removed. ([#663](https://github.com/mapbox/mapbox-maps-ios/pull/663))
* `GestureManager.decelarationRate` has been removed and `GestureOptions.decelerationRate` is the single source of truth. ([#662](https://github.com/mapbox/mapbox-maps-ios/pull/662))
* `GestureManager` no longer conforms to `NSObject` and is not a `UIGestureRecognizerDelegate`. ([#669](https://github.com/mapbox/mapbox-maps-ios/pull/669))
* Pan deceleration has been temporarily removed. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* `TapGestureHandler.init` was previously public by mistake and is now internal. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* The behavior of `GestureManager.options` has been updated to better reflect the `isEnabled` state of the associated gesture recognizers. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* The gesture recognizer properties of `GestureManager` are no longer `Optional`. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
Expand All @@ -22,6 +21,7 @@ Mapbox welcomes participation and contributions from everyone.
* `CameraState`'s fields are now `var`s instead of `let`s for testing purposes, and a public, memberwise initializer has been added. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* `PanScrollingMode` now conforms to `CaseIterable`. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* `GestureType` now conforms to `CaseIterable`. ([#677](https://github.com/mapbox/mapbox-maps-ios/pull/677))
* Pan deceleration has been reimplemented to produce a more natural deceleration effect. ([#692](https://github.com/mapbox/mapbox-maps-ios/pull/692))

### Bug fixes 🐞

Expand Down
49 changes: 43 additions & 6 deletions Sources/MapboxMaps/Foundation/Camera/CameraAnimationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ internal protocol CameraAnimationsManagerProtocol: AnyObject {
curve: UIView.AnimationCurve,
completion: AnimationCompletion?) -> Cancelable?

func decelerate(location: CGPoint,
velocity: CGPoint,
decelerationRate: CGFloat,
locationChangeHandler: @escaping (CGPoint) -> Void,
completion: @escaping () -> Void)

func cancelAnimations()
}

Expand Down Expand Up @@ -109,9 +115,7 @@ public class CameraAnimationsManager: CameraAnimationsManagerProtocol {
cameraAnimatorsSet.add(flyToAnimator)

flyToAnimator.addCompletion { [weak self, weak flyToAnimator] (position) in
if let internalAnimator = self?.internalAnimator,
let animator = flyToAnimator,
internalAnimator === animator {
if self?.internalAnimator === flyToAnimator {
self?.internalAnimator = nil
}
// Call the developer-provided completion (if present)
Expand Down Expand Up @@ -148,9 +152,7 @@ public class CameraAnimationsManager: CameraAnimationsManagerProtocol {

// Nil out the `internalAnimator` once the "ease to" finishes
animator.addCompletion { [weak self, weak animator] (position) in
if let internalAnimator = self?.internalAnimator,
let animator = animator,
internalAnimator === animator {
if self?.internalAnimator === animator {
self?.internalAnimator = nil
}
completion?(position)
Expand Down Expand Up @@ -281,4 +283,39 @@ public class CameraAnimationsManager: CameraAnimationsManagerProtocol {
cameraViewContainerView.addSubview(cameraView)
return cameraView
}

/// This function will handle the natural decelration of a gesture when there is a velocity provided. A use case for this is the pan gesture.
/// - Parameters:
/// - location: Current location of center coordinate
/// - velocity: The speed at which the map should move over time
/// - decelerationRate: A multiplication factor that determines the speed at which the velocity should slow down
/// - locationChangeHandler: Change handler to be passed through to the animator
/// - completion: Completion to be called after animation has finished
internal func decelerate(location: CGPoint,
velocity: CGPoint,
macdrevx marked this conversation as resolved.
Show resolved Hide resolved
decelerationRate: CGFloat,
locationChangeHandler: @escaping (CGPoint) -> Void,
completion: @escaping () -> Void) {

// Stop the `internalAnimator` before beginning a deceleration
internalAnimator?.stopAnimation()

let decelerateAnimator = GestureDecelerationCameraAnimator(
location: location,
velocity: velocity,
decelerationRate: decelerationRate,
locationChangeHandler: locationChangeHandler,
dateProvider: DefaultDateProvider())

decelerateAnimator.completion = { [weak self, weak decelerateAnimator] in
if self?.internalAnimator === decelerateAnimator {
self?.internalAnimator = nil
}
completion()
}

cameraAnimatorsSet.add(decelerateAnimator)
decelerateAnimator.startAnimation()
internalAnimator = decelerateAnimator
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import UIKit

internal final class GestureDecelerationCameraAnimator: NSObject, CameraAnimatorInterface {

private var location: CGPoint
private var velocity: CGPoint
private let decelerationRate: CGFloat
private let locationChangeHandler: (CGPoint) -> Void
private var previousDate: Date?
private let dateProvider: DateProvider
internal var completion: (() -> Void)?

internal init(location: CGPoint,
velocity: CGPoint,
decelerationRate: CGFloat,
locationChangeHandler: @escaping (CGPoint) -> Void,
dateProvider: DateProvider) {
macdrevx marked this conversation as resolved.
Show resolved Hide resolved
self.location = location
self.velocity = velocity
self.decelerationRate = decelerationRate
self.locationChangeHandler = locationChangeHandler
self.dateProvider = dateProvider
}

internal private(set) var state: UIViewAnimatingState = .inactive

internal func cancel() {
stopAnimation()
}

internal func startAnimation() {
previousDate = dateProvider.now
state = .active
}

internal func stopAnimation() {
state = .inactive
completion?()
completion = nil
}

internal func update() {
guard state == .active, let previousDate = previousDate else {
return
}

let currentDate = dateProvider.now
self.previousDate = currentDate
macdrevx marked this conversation as resolved.
Show resolved Hide resolved

let elapsedTime = CGFloat(currentDate.timeIntervalSince(previousDate))

// calculate new location showing how far we have traveled
location.x += velocity.x * elapsedTime
location.y += velocity.y * elapsedTime

locationChangeHandler(location)

// deceleration rate is a factor that should
// be applied to the velocity once per millisecond
velocity.x *= pow(decelerationRate, (elapsedTime * 1000))
velocity.y *= pow(decelerationRate, (elapsedTime * 1000))

guard abs(velocity.x) >= 1 || abs(velocity.y) >= 1 else {
stopAnimation()
return
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ internal final class MapViewDependencyProvider: MapViewDependencyProviderProtoco
return PanGestureHandler(
gestureRecognizer: gestureRecognizer,
mapboxMap: mapboxMap,
cameraAnimationsManager: cameraAnimationsManager)
cameraAnimationsManager: cameraAnimationsManager,
dateProvider: DefaultDateProvider())
}

func makePinchGestureHandler(view: UIView,
Expand Down
92 changes: 65 additions & 27 deletions Sources/MapboxMaps/Gestures/GestureHandlers/PanGestureHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@ internal final class PanGestureHandler: GestureHandler {
// The camera state when the gesture began
private var initialCameraState: CameraState?

// The date when the most recent gesture changed event was handled
private var lastChangedDate: Date?

// Provides access to the current date in a way that can be mocked
// for unit testing
private let dateProvider: DateProvider

macdrevx marked this conversation as resolved.
Show resolved Hide resolved
internal init(gestureRecognizer: UIPanGestureRecognizer,
mapboxMap: MapboxMapProtocol,
cameraAnimationsManager: CameraAnimationsManagerProtocol) {
cameraAnimationsManager: CameraAnimationsManagerProtocol,
dateProvider: DateProvider) {
gestureRecognizer.maximumNumberOfTouches = 1
self.dateProvider = dateProvider
super.init(
gestureRecognizer: gestureRecognizer,
mapboxMap: mapboxMap,
Expand All @@ -33,38 +42,67 @@ internal final class PanGestureHandler: GestureHandler {
cameraAnimationsManager.cancelAnimations()
delegate?.gestureBegan(for: .pan)
case .changed:
guard let initialTouchLocation = initialTouchLocation,
let initialCameraState = initialCameraState,
let panScrollingMode = delegate?.panScrollingMode else {
return
}
lastChangedDate = dateProvider.now
cameraAnimationsManager.cancelAnimations()

// Reset the camera to its state when the gesture began
mapboxMap.setCamera(to: CameraOptions(cameraState: initialCameraState))

let clampedTouchLocation: CGPoint
switch panScrollingMode {
case .horizontal:
clampedTouchLocation = CGPoint(x: touchLocation.x, y: initialTouchLocation.y)
case .vertical:
clampedTouchLocation = CGPoint(x: initialTouchLocation.x, y: touchLocation.y)
case .horizontalAndVertical:
clampedTouchLocation = touchLocation
handleChange(withTouchLocation: touchLocation)
case .ended:
// Only decelerate if the gesture ended quickly. Otherwise,
// you get a deceleration in situations where you drag, then
// hold the touch in place for several seconds, then release
// it without further dragging. This specific time interval
// is just the result of manual tuning.
let decelerationTimeout: TimeInterval = 1.0 / 30.0
guard let lastChangedDate = lastChangedDate,
dateProvider.now.timeIntervalSince(lastChangedDate) < decelerationTimeout,
let decelerationRate = delegate?.decelerationRate else {
return
}

// Execute the drag relative to the initial touch location
mapboxMap.dragStart(for: initialTouchLocation)
let dragCameraOptions = mapboxMap.dragCameraOptions(
from: initialTouchLocation,
to: clampedTouchLocation)
mapboxMap.setCamera(to: dragCameraOptions)
mapboxMap.dragEnd()
case .ended, .cancelled:
cameraAnimationsManager.decelerate(
location: touchLocation,
velocity: gestureRecognizer.velocity(in: view),
decelerationRate: decelerationRate,
locationChangeHandler: handleChange(withTouchLocation:),
completion: {
self.initialTouchLocation = nil
self.initialCameraState = nil
self.lastChangedDate = nil
})
macdrevx marked this conversation as resolved.
Show resolved Hide resolved
case .cancelled:
// no deceleration
initialTouchLocation = nil
initialCameraState = nil
lastChangedDate = nil
default:
break
}
}

private func handleChange(withTouchLocation touchLocation: CGPoint) {
guard let initialTouchLocation = initialTouchLocation,
let initialCameraState = initialCameraState,
let panScrollingMode = delegate?.panScrollingMode else {
return
}

// Reset the camera to its state when the gesture began
mapboxMap.setCamera(to: CameraOptions(cameraState: initialCameraState))

let clampedTouchLocation: CGPoint
switch panScrollingMode {
case .horizontal:
clampedTouchLocation = CGPoint(x: touchLocation.x, y: initialTouchLocation.y)
case .vertical:
clampedTouchLocation = CGPoint(x: initialTouchLocation.x, y: touchLocation.y)
case .horizontalAndVertical:
clampedTouchLocation = touchLocation
}

// Execute the drag relative to the initial touch location
mapboxMap.dragStart(for: initialTouchLocation)
let dragCameraOptions = mapboxMap.dragCameraOptions(
from: initialTouchLocation,
to: clampedTouchLocation)
mapboxMap.setCamera(to: dragCameraOptions)
mapboxMap.dragEnd()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import XCTest
@testable import MapboxMaps

final class GestureDecelerationCameraAnimatorTests: XCTestCase {

var location: CGPoint!
var velocity: CGPoint!
var decelerationRate: CGFloat!
var locationChangeHandler: Stub<CGPoint, Void>!
var dateProvider: MockDateProvider!
var completion: Stub<Void, Void>!
var animator: GestureDecelerationCameraAnimator!

override func setUp() {
super.setUp()
location = .zero
velocity = CGPoint(x: 1000, y: -1000)
decelerationRate = 0.7
locationChangeHandler = Stub()
dateProvider = MockDateProvider()
completion = Stub()
animator = GestureDecelerationCameraAnimator(
location: location,
velocity: velocity,
decelerationRate: decelerationRate,
locationChangeHandler: locationChangeHandler.call(with:),
dateProvider: dateProvider)
animator.completion = completion.call
}

override func tearDown() {
animator = nil
completion = nil
dateProvider = nil
locationChangeHandler = nil
decelerationRate = nil
velocity = nil
location = nil
super.tearDown()
}

func testStateIsInitiallyInactive() {
XCTAssertEqual(animator.state, .inactive)
}

func testStartAnimation() {
animator.startAnimation()

XCTAssertEqual(animator.state, .active)
}

func testStopAnimation() {
animator.startAnimation()

animator.stopAnimation()

XCTAssertEqual(animator.state, .inactive)
XCTAssertEqual(completion.invocations.count, 1)
}

func testUpdate() {
animator.startAnimation()

// Simulate advancing by 10 ms
dateProvider.nowStub.defaultReturnValue += 0.01
animator.update()

// Expected value is duration * velocity;
XCTAssertEqual(locationChangeHandler.parameters, [CGPoint(x: 10, y: -10)])
// The previous update() should also have reduced the velocity
// by multiplying it by the decelerationRate once for each elapsed
// millisecond. In this simulateion, 10 ms have elapsed.
let expectedVelocityAdjustmentFactor = pow(decelerationRate, 10)
locationChangeHandler.reset()
// Make sure the animation didn't end yet
XCTAssertEqual(animator.state, .active)
XCTAssertEqual(completion.invocations.count, 0)

// This time, advance by 20 ms to keep it distinct
// from the first update() call.
dateProvider.nowStub.defaultReturnValue += 0.02
animator.update()

// The expected value this time is the previous location + the reduced
// velocity (velocity * expectedVelocityAdjustmentFactor) times the elapsed duration
XCTAssertEqual(
locationChangeHandler.parameters, [
CGPoint(
x: 10 + (velocity.x * expectedVelocityAdjustmentFactor) * 0.02,
y: -10 + (velocity.y * expectedVelocityAdjustmentFactor) * 0.02)])
locationChangeHandler.reset()
// After the previous update() call, the velocity should have also been reduced
// to be sufficiently low (< 1 in both x and y) to end the animation.
XCTAssertEqual(animator.state, .inactive)
XCTAssertEqual(completion.invocations.count, 1)
}
}
Loading