Skip to content

Add ScrollAnimation for duration-based scrolls - #628

Open
maxmeyers wants to merge 2 commits into
square:mainfrom
maxmeyers:mmeyers/scroll-animation-duration
Open

Add ScrollAnimation for duration-based scrolls#628
maxmeyers wants to merge 2 commits into
square:mainfrom
maxmeyers:mmeyers/scroll-animation-duration

Conversation

@maxmeyers

@maxmeyers maxmeyers commented Jul 30, 2026

Copy link
Copy Markdown

UIScrollView gives you a Bool for setContentOffset(_:animated:) and nothing else, so a programmatic Listable scroll is either instant or runs at a fixed system speed. This adds ScrollAnimation so a caller can say how long the scroll should take.

listActions.scrolling.scrollToSection(
    with: section.identifier,
    scrollPosition: .init(position: .top),
    animation: .duration(0.75)
)

The motivating case is a guided flow in Square Point of Sale: as the user completes each step, the list scrolls to the next one. At system speed the scroll is fast enough that it reads as a jump, and there's no way to slow it down.

What's here

ScrollAnimation has three states — .none, .system, and .duration(_:) — and every animated: Bool scrolling method on ListView and ListActions.Scrolling now has an animation: counterpart. animated: true and false map to .system and .none, so existing callers behave exactly as before.

Two things came along with it, because the new path needed them and the old ones didn't have them:

  • scrollToTop(...) and scrollToLastItem(...) now take a completion handler. They previously had no way to report finishing.
  • Fixed a completion handler being stranded when animated: true was requested inside a UIView.performWithoutAnimation block. The suppressed scroll produced no scrollViewDidEndScrollingAnimation(_:) callback, so the handler queued for it was never called.

Why a display link, and not UIView.animate

The usual suggestion for this problem is:

UIView.animate(withDuration: duration) {
    scrollView.setContentOffset(target, animated: false)
}

That animates, but on a UICollectionView the scroll is completely blank. The assignment lands the real contentOffset on the target immediately, so the collection view lays out at the destination and recycles every cell it was showing. Only the layer's bounds animate back — across a region that no longer has any cells in it. I had this built and passing tests before noticing it on screen; at ~100 rows and at ~800 rows of travel, content vanished for the whole animation and reappeared at the end.

Unit tests can't see it, which is worth knowing if this code is ever revisited: layer.animationKeys() contains bounds.origin and layer.presentation() interpolates correctly the entire time the screen is empty.

So ScrollAnimationDriver does what UIScrollView does for its own animation — assigns contentOffset on every frame from a CADisplayLink. Each assignment drives a layout pass, so cells dequeue as they scroll into view. ListViewTests has a regression test for this specifically (test_scroll_with_duration_animation_keeps_content_laid_out), asserting that the visible-item set mid-scroll is both non-empty and different from the set at the end — content laid out only at the destination would report the same items throughout.

The curve is a hardcoded ease-in-out, matching what UIScrollView does for its own scroll. It's a cosine rather than UIKit's bezier because a CAMediaTimingFunction only exposes its control points, not its value at a given time — solving the bezier per frame would mean carrying a Newton-iteration solver in Listable to land within 2% of where a one-line closed form already lands. There's no parameter for it: an exposed curve would have needed a bezier solver to be honest about the names, and no caller wanted one.

Two details worth a reviewer's attention

Where the animation is resolved. A scroll toward content that hasn't been laid out yet is deferred until the presentation state catches up, and that deferred work runs on a later runloop pass — outside any performWithoutAnimation block the caller made the request from, where UIView.areAnimationsEnabled reads true again. So the animation is resolved against the current context where the scroll is requested, not where the offset changes. Resolving late would silently ignore a caller's request not to animate.

Interruption. A driven scroll is stopped by the same things that stop the scroll view's own animation — the user taking hold of the list (scrollViewWillBeginDragging), or another scroll superseding it — and reports its completion handler either way. It's also cancelled when the list's identifier changes, since the offset reset there invalidates the target, and in ListView.deinit, because the main runloop retains the display link and would otherwise keep driving a torn down list.

Testing

Nine tests in ListViewTests under // MARK: Scroll animations, covering completion-exactly-once, offset parity with .system, mid-scroll sampling proving it eases rather than jumps, the blank-scroll regression above, cancellation, non-positive durations, suppressed animations, and that the driver doesn't retain itself while animating.

Worth noting for anyone extending these: a display-link animation is immune to layer.speed, so the layer.speed = 4 that show(vc:) applies to the test host doesn't affect it. Durations in these tests are real wall-clock seconds.

Also verified by hand in the demo app. ScrollToSectionCompletionHandlerViewController and its new ScrollToOffscreenSectionCompletionHandlerViewController subclass (40 sections, so the far ones are nowhere near the initial layout) both have an animation picker with None / System / 1s / 3s.

Checklist

Please do the following before merging:

  • Ensure any public-facing changes are reflected in the changelog. Include them in the Main section.

@maxmeyers
maxmeyers force-pushed the mmeyers/scroll-animation-duration branch from f848587 to 5d0ecaa Compare July 31, 2026 00:35
UIScrollView exposes only a Bool for setContentOffset(_:animated:), so a
programmatic scroll was either instant or ran at a fixed system speed. A
caller that needs the scroll to take a specific amount of time — one
pacing a sequence of steps a user is being walked through, say — had no
way to ask for it.

Add ScrollAnimation, with .none, .system and .duration(_:), and give
every animated: Bool scrolling method on ListView and
ListActions.Scrolling an animation: counterpart. animated: true and false
remain equivalent to .system and .none, so existing callers are
unaffected.

A .duration scroll is driven by a CADisplayLink, which assigns
contentOffset on every frame. The widely cited alternative — animating
contentOffset inside a UIView animation block — does not work: the
assignment lands on the target immediately, so the list lays out its
content there and recycles every cell it was showing, and only the
layer's bounds animate back across a region that has nothing in it. The
scroll is smooth and completely blank. Assigning per frame is what
UIScrollView does for its own animation, and it drives the layout passes
that keep content on screen throughout.

The duration is honored even when the target has not been laid out yet.
Those scrolls are deferred until the presentation state catches up, so
the animation has to wrap the deferred content offset change rather than
the initial request. For the same reason the animation is resolved
against UIView.areAnimationsEnabled where the scroll is requested: the
deferred work runs on a later runloop pass, outside any
performWithoutAnimation block the caller made the request from, where
animations read as enabled again.

The curve is a hardcoded ease-in-out, matching what UIScrollView does
for its own scroll. It is a cosine rather than UIKit's bezier because a
CAMediaTimingFunction only exposes its control points, not its value at
a given time, and a Newton-iteration bezier solver is a lot of machinery
to carry for the 2% of fidelity that buys.

A driven scroll is interrupted by the same things that interrupt the
scroll view's own animation — the user taking hold of the list, or
another scroll replacing it — and reports its completion handler either
way.

Route scrollToTop and scrollToLastItem through the same path, which also
gives them the completion handlers they never had, and fix a completion
handler being stranded when animated: true was requested with animations
disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@maxmeyers
maxmeyers force-pushed the mmeyers/scroll-animation-duration branch from 5d0ecaa to dc3c97c Compare July 31, 2026 16:45
@maxmeyers
maxmeyers marked this pull request as ready for review July 31, 2026 17:29

@johnnewman-square johnnewman-square left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! This change looks good on my end and the demo works great. I left a couple comments: the one from my agent about the scroll completion handler feels like one worth investigating before we merge.

Comment on lines +1151 to +1162
let startOffset = collectionView.contentOffset

changeOffset(false)

let targetOffset = collectionView.contentOffset

// Back to where the scroll started, so the driver can animate the distance
// itself. Restoring here rather than on the driver's first frame avoids
// displaying a frame at the target.
collectionView.setContentOffset(startOffset, animated: false)

driveScroll(to: targetOffset, duration: duration, completion: completion)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker at all, but one edge case occurred to me: if the cell heights dynamically change during the driven scroll animation, we may animate to the wrong end location. It might be difficult to add a test for this case and I'm happy to treat it as an edge case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Agreed it's an edge case, and worth noting the system animation has it too: setContentOffset(_:animated:) commits to a target offset up front just as the driver does, so content that resizes mid-scroll can land off-target either way. The driven scroll is no worse than .system here rather than introducing a new failure mode, which is why I've left it. Clamping to the content size each frame would diverge from what the caller asked for, so if this ever needs solving I'd rather it be solved for both paths at once.

🤖 Addressed by Claude Code

Comment thread ListableUI/Sources/ListView/ListView.swift
setContentOffset(_:animated: true) with the offset the scroll view is
already at starts no animation, so scrollViewDidEndScrollingAnimation(_:)
never arrives and a handler queued for it waits forever.

scrollToTop and scrollToLastItem take a completion handler as of this
change, which is what exposes this: scrolling to the top while already at
the top, or to the last item while already at the bottom, moves nothing.
Both now report the way an unanimated scroll reports, on the next runloop
pass.

The two applyScroll variants need different treatment. The one given an
explicit target offset can compare it against the current offset and
answer immediately. The one given a closure cannot, because only the
collection view knows where scrollToItem(at:at:animated:) and
scrollRectToVisible(_:animated:) land, and an animated call leaves the
content offset at its start value either way. That variant performs the
change unanimated to discover the destination, then restores — the same
trick the duration path already uses. Since that costs a layout pass, it
runs only when a completion handler was supplied and so only for callers
who would otherwise be stranded.

The duration path was already correct; driveScroll has always short
circuited when there is no distance to cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants