← Back
[W] Writing

The maths of iOS scrolling

Flick a list on an iPhone and it glides to a stop. Pull past the top and it stretches, then snaps back. Throw a sheet and it lands where you meant, even when you let go short of it.

Each of those is one small formula. Try the sheet first. Drag it and let go with a flick.

The dashed line is where your flick would coast to. Try a short, quick flick from the bottom with each setting. Snapping from where you let go ignores the throw. Snapping from the projection doesn't.

With release position, a quick flick that only moves the sheet a little falls back to where it started. Your hand clearly said "up", but the code only looked at where your finger stopped.

Momentum is a number to a power

When you let go of a scroll view, it keeps the velocity your finger had. Then, every millisecond, it multiplies that velocity by a number just under 1. UIKit calls that number the deceleration rate. The two built-in values are .normal, which is 0.998, and .fast, which is 0.99.

That's exponential decay. After t milliseconds the velocity is v₀ × 0.998ᵗ. It loses a fixed share every millisecond, never a fixed amount, so it slows quickly at first and then fades out gently. That long, soft tail is a large part of why iOS scrolling feels physical.

v₀ 0 346 ms 1.5 s → .normal, 0.998: coasts 0.499 s × v₀ .fast, 0.99: coasts 0.099 s × v₀
Velocity after you let go. The normal rate halves the speed every 346ms. The fast rate halves it every 69ms. The shaded area under the normal curve is the distance it travels.

The difference between 0.998 and 0.99 looks tiny. It isn't. Look at how much is lost per second: 0.998¹⁰⁰⁰ leaves 13.5% of the speed, while 0.99¹⁰⁰⁰ leaves almost nothing. .fast is what you'd use for a picker or a paged view, where content should stop near where your finger left it.

Where will it land?

Because the rule is so simple, you can work out where a throw ends before it has even started. Add up how far it moves in each millisecond:

distance = (v₀ / 1000) × (d + d² + d³ + …)

v₀ / 1000 turns points per second into points per millisecond. The bracket is a geometric series, and it sums to d / (1 − d). Apple showed the result as a small function in the WWDC 2018 talk Designing Fluid Interfaces. It's reproduced in Nathan Gitter's recreation of the talk's demos:

const project = (velocity, rate = 0.998) =>
  ((velocity / 1000) * rate) / (1 - rate);

With the normal rate, 0.998 / 0.002 is 499. So a scroll view coasts about half a second's worth of its release speed. Let go at 2,000 points per second and it travels roughly 1,000 points.

Snapping is projection plus nearest

The talk's example is FaceTime's picture-in-picture window. You throw it, and it goes to a corner. The naive version picks the corner closest to where you let go. That breaks for exactly the flick you tried above, a short, fast throw that ends near where it started.

Apple's version projects first. It adds the projected distance to the release position, finds the corner nearest that imaginary point, then animates there. The talk calls the imaginary point a projection, and it never appears on screen. The sheet demo does the same with three snap points:

const landing = releaseY + project(velocityY);
const snap = snaps.reduce((a, b) =>
  Math.abs(b - landing) < Math.abs(a - landing) ? b : a);

Then it springs to that snap point, starting with the release velocity, so the hand-off from your finger has no seam. The talk also notes that projection works for scale and rotation, not just position.

Velocity is the hard part

All of this depends on knowing how fast the finger was moving when it lifted, and touch hardware doesn't tell you that. You get a stream of positions with timestamps, and you have to estimate.

Using only the last two samples is too noisy. They can be a few milliseconds apart, and one jittery reading swings the answer. Using the whole gesture is too slow to react. If you drag down and then flick up, the average still says "down".

So trackers look at a short window. Android's VelocityTracker only considers the last 100ms of movement, with the comment "we want to react to quick changes in direction". It also treats a pointer as stopped if it hasn't moved for 40ms, so pausing before you lift your finger correctly gives zero velocity. The demo does both. On iOS, UIPanGestureRecognizer gives you velocity(in:), so you rarely need to write this yourself.

Rubber-banding stretches less and less

Pull a scroll view past its edge and the content follows your finger, but less and less the further you go. The formula that's widely used for this was posted by Grant Paul in 2012, who said iOS 6 had switched to it. You'll find it copied in many places, like this gist:

offset = (1 − 1 / (x × c / d + 1)) × d

x is how far your finger is past the edge, d is the size of the view, and c is a constant, 0.55 for UIScrollView. Apple has never documented it, so treat it as a close approximation rather than the source.

Pull the box down, or use the Pull slider. The dashed diagonal is 1:1 tracking. The curve starts at a slope of c and flattens towards d, the height of the box.

Two properties make it work. At the very start the slope is exactly c, so the first few points move at 55% of your finger. That makes the edge obvious at once, without feeling stuck. And as x grows, the offset approaches d but never reaches it. You could drag forever, and the content would never leave the view entirely.

The constant is a feel decision. A lower c makes the edge feel stiff and final. A higher one feels loose, closer to dragging the content itself. 0.55 is somewhere in between.

None of these formulas is clever on its own. What makes iOS feel the way it does is that they agree with each other. The release velocity feeds the projection, the projection picks the target, and the same velocity starts the spring that gets there.

Next