Skip to content

Commit

Permalink
Pan Deceleration (#692)
Browse files Browse the repository at this point in the history
  • Loading branch information
macdrevx authored and nishant-karajgikar committed Sep 22, 2021
1 parent 3561c90 commit 650b46a
Show file tree
Hide file tree
Showing 8 changed files with 411 additions and 35 deletions.
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,
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) {
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

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

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
})
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,26 @@ final class MockCameraAnimationsManager: CameraAnimationsManagerProtocol {
func cancelAnimations() {
cancelAnimationsStub.call()
}

struct DecelerateParameters {
var location: CGPoint
var velocity: CGPoint
var decelerationRate: CGFloat
var locationChangeHandler: (CGPoint) -> Void
var completion: () -> Void
}
let decelerateStub = Stub<DecelerateParameters, Void>()
func decelerate(location: CGPoint,
velocity: CGPoint,
decelerationRate: CGFloat,
locationChangeHandler: @escaping (CGPoint) -> Void,
completion: @escaping () -> Void) {
return decelerateStub.call(
with: DecelerateParameters(
location: location,
velocity: velocity,
decelerationRate: decelerationRate,
locationChangeHandler: locationChangeHandler,
completion: completion))
}
}
Loading

0 comments on commit 650b46a

Please sign in to comment.