Hi everybody. In this article, I will show you the UIScrollView
scroll deceleration mechanism, and how we can implement this mechanism by ourselves.
Understanding how scrolling works is helpful when we want to mimic the UIScrollView
animation for some other view using UIPanGestureRecognizer
.
It is necessary to find the equation of motion to understand how the coil mechanism works. And when we find out, we can calculate the components of this scroll function: roll time, velocity and final position (slide) after the roll ends.
The function for calculating the projection of scrolling was introduced in Designing Fluid Interfaces (WWDC18).
1 2 3 4 5 | // Distance travelled after decelerating to zero velocity at a constant rate. func project(initialVelocity: Float, decelerationRate: Float) -> Float { return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) } |
However, this is only a function of the scroll projection. It is not enough for timing functions or motion equations. But can be used to reference our calculations.
Velocity function
Guess how deceleration works and what can DecelerationRate
be? In the Apple documentation it says:
A floating-point value that determines the rate of deceleration after the user lifts their finger.
We can assume that this rate tells how much scroll speed will change per millisecond (all values in UIScrollView
are represented as milliseconds, unlike UIPanGestureRecognizer
).
If at the moment of swiping and releasing we have the initial velocity and we choose DecelerationRate.fast
, then:
- after 1 millisecond the speed will be 0.99 times v
- after 2 milliseconds the speed will be 0.99² times v
- after k seconds, the speed will be 0. 99¹⁰⁰⁰k times v₀
Obviously, we have the formula for velocity based on deceleration rate as follows:
Over there:
- ? – velocity,
- ?ₒ – initial velocity in pt / s (points per second),
- d – deceleration rate (0 <d <1),
- t – time.
Equation of motion
It is not possible to use only the velocity function to implement the deceleration mechanism. So we need to find the equation of motion: the dependence of the coordinate on time x (t). And the velocity formula will help us find the equation of motion, we just need to take the primitive of the velocity equation (refer to Application of Integral Primitives ) and finally:
Then replace the velocity formula for v (x) and transform, we have:
The endpoint equation
Now we can find the formula for the scroll endpoint, compare it with Apple’s formula, and test our reasoning. To do this, we need to direct time t to infinity. Since we have d less than one, and d¹⁰⁰⁰t converges to zero, we get: ENGLISH Now we can find the formula for the endpoint after scrolling, compare it with Apple’s formula and test it. To do this, we need to direct time t to infinity. Since d <1 and d¹⁰⁰⁰t converge to 0, we will have:
Now let’s try to compare the formula found with the Apple formula. Write under the same form:
And we easily notice that the formulas differ only in the right part:
However, if we look at how the natural logarithm is decomposed into a Taylor series in the neighborhood of 1, we will see that the Apple formula is actually an approximate formula for our formula:
About natural logarithms: https://en.wikipedia.org/wiki/Naturallogarithm#Series
If we graph these functions, we will see that as we approach 1, they almost match:
The default DecelerationRate
values are very close to 1, so we can see that Apple’s optimization is pretty standard. The logarithm calculation takes more performance than the usual math pretty much.
Deceleration time
Now all we have to do is find the time to slow down so we can implement the animation. To find the end, we have turned time into infinity. But to do animation, time will have to be a limited number.
If we plot the equation of motion, we can see that the function, upon reaching infinity, will come close to the end point X. However, at a certain finite point, the function approaches the end point X so close that movement is no longer visible to the naked eye.
Therefore, we can reformat our problem as follows: we find a time period T, after which the function is close enough to the end point X (by some small distance ε). In practice, ε could be equal to half a pixel, for example.
Find T at which the distance to the end is equal to ε:
Replace the formula for x and X and we’ll get the formula for deceleration time:
And now we have all the information needed to implement the deceleration mechanism manually. Now try to put a few lines of code in it!
Implement deceleration mechanism
To begin, define a struct DecelerationTimingParameters
that will contain all the information needed to animate when you remove your finger:
1 2 3 4 5 6 7 | struct DecelerationTimingParameters { var initialValue: CGPoint var initialVelocity: CGPoint var decelerationRate: CGFloat var threshold: CGFloat } |
initialValue
is the originalcontentOffset
– the point where we release our fingerinitialVelocity
is the velocity of the gesturedecelerationRate
is thedecelerationRate
ratethreshold
is the threshold for finding the deceleration time.
Using the formula, we find a scroll stop:
1 2 3 4 5 | var destination: CGPoint { let dCoeff = 1000 * log(decelerationRate) return initialValue - initialVelocity / dCoeff } |
Deceleration time:
1 2 3 4 5 6 7 | var duration: TimeInterval { guard initialVelocity.length > 0 else { return 0 } let dCoeff = 1000 * log(decelerationRate) return TimeInterval(log(-dCoeff * threshold / initialVelocity.length) / dCoeff) } |
And the equation of motion:
1 2 3 4 5 | func value(at time: TimeInterval) -> CGPoint { let dCoeff = 1000 * log(decelerationRate) return initialValue + (pow(decelerationRate, CGFloat(1000 * time)) - 1) / dCoeff * initialVelocity } |
We will use TimerAnimation, which will call the passed animation callback 60 times per second when the screen is updated (or 120 times per second on the iPad Pro), as the animation: VIETNAMESE We’ll use TimerAnimation, which will call the animation callback which we pass in 60 times per second when the screen is updated (or 120 times per second on the iPad Pro):
1 2 3 4 5 6 7 8 | class TimerAnimation { typealias Animations = (_ progress: Double, _ time: TimeInterval) -> Void typealias Completion = (_ finished: Bool) -> Void init(duration: TimeInterval, animations: @escaping Animations, completion: Completion? = nil) } |
We will calculate the contentOffset
using the current motion equation in the animation block to change accordingly. TimerAnimation can be found in this repo .
And now we will improve the gesture handler function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | @objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: state = .dragging(initialOffset: contentOffset) case .changed: let translation = sender.translation(in: self) if case .dragging(let initialOffset) = state { contentOffset = clampOffset(initialOffset - translation) } case .ended: state = .default // Other cases } } |
The deceleration will start when the finger is released. Therefore, when the .end
state arrives, we will call the startDeceleration
function, passing the gesture’s velocity to it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @objc func handlePanRecognizer(_ sender: UIPanGestureRecognizer) { switch sender.state { case .began: state = .dragging(initialOffset: contentOffset) case .changed: let translation = sender.translation(in: self) if case .dragging(let initialOffset) = state { contentOffset = clampOffset(initialOffset - translation) } case .ended: state = .default let velocity = sender.velocity(in: self) startDeceleration(withVelocity: -velocity) // Other cases } } |
The startDeceleration
function will be executed as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | var contentOffsetAnimation: TimerAnimation? func startDeceleration(withVelocity velocity: CGPoint) { let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue let threshold = 0.5 / UIScreen.main.scale let parameters = DecelerationTimingParameters(initialValue: contentOffset, initialVelocity: velocity, decelerationRate: decelerationRate, threshold: threshold) contentOffsetAnimation = TimerAnimation( duration: parameters.duration, animations: { [weak self] _, time in guard let self = self else { return } self.contentOffset = self.clampOffset(parameters.value(at: time)) }) } |
- Select
DecelerationRate.normal
and the threshold is about half a pixel. - Initialize DecelerationTimingParameters.
- Run animation, pass animation time in. We’ll then call the motion equation in the animation block to update the
contentOffset
.
Conclusion
Above is the knowledge related to the deceleration mechanism. If we grasp this mechanism, we can easily customize and create our own UI that is very smooth and compliant with UX standards. Have a nice working day.
The article is translated and referenced from How UIScrollView works