Add custom item offset scrolling#615
Conversation
Expose a Listable-owned item scrolling API that lets callers compute a vertical content offset adjustment without accessing the scroll view.
|
🤖 @codex review |
| list.animation = .fast | ||
| list.scrollIndicatorInsets.bottom = 112.0 | ||
|
|
||
| list.autoScrollAction = .pin( |
There was a problem hiding this comment.
The new API in action
| @discardableResult | ||
| public func scrollTo( | ||
| item : AnyIdentifier, | ||
| contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, |
There was a problem hiding this comment.
I think it's okay that this is marked as escaping but making a note for myself
There was a problem hiding this comment.
This is okay to me. Since this closure is stored, it would be good to add a small doc around this new closure to note that clients should only use weak self.
There was a problem hiding this comment.
🤖 [Codex] Addressed in 2a62c74. I added docs to the adjustment type/factory noting that declarative scrolling APIs may store the closure, and that clients should capture list owners/configurers weakly to avoid retain cycles.
johnnewman-square
left a comment
There was a problem hiding this comment.
This is a neat enhancement. I left just a few notes that I think we could potentially explore.
There was a problem hiding this comment.
I received this feedback from Codex. I think we could potentially make a few adjustments here and add a unit test to capture this specific case:
In ListView.Delegate.swift, onDidEndScrollingAnimation now gets actions, so a client can start another scroll from inside that callback. But self.view.didEndScrolling() is called after the observer callback. If the callback starts an animated scroll with a completion, ListView.swift appends that new completion, and then the current “old scroll ended” event drains it immediately. So the completion can fire with stale position info before the newly requested scroll finishes.
The likely fix is to snapshot/drain existing scroll completions before invoking onDidEndScrollingAnimation, or otherwise make didEndScrolling() only process handlers that existed before this delegate callback began.
There was a problem hiding this comment.
Good catch, will adjust.
There was a problem hiding this comment.
🤖 [Codex] Addressed in bd63c5a. The delegate now drains the completion handlers that existed before onDidEndScrollingAnimation, runs the observer callback with actions, and then executes only that drained batch. I also added a regression test covering a new animated scroll started from the callback so its completion waits for the next scroll end.
There was a problem hiding this comment.
These updates look good 👍
| list.appearance = .demoAppearance | ||
| list.layout = .demoLayout | ||
| list.animation = .fast | ||
| list.scrollIndicatorInsets.bottom = 112.0 |
There was a problem hiding this comment.
We could replace the hardcoded inset with an example of a calculated inset that gets the scroll indicator closer to the top edge of the footer. Though, we'd need to call updateList in viewSafeAreaInsetsDidChange() for good measure. Up to you!
| list.scrollIndicatorInsets.bottom = 112.0 | |
| list.scrollIndicatorInsets.bottom = footer.bounds.height - view.safeAreaInsets.bottom |
There was a problem hiding this comment.
Good suggestion. This is more aligned with the target use case too 👍🏼
There was a problem hiding this comment.
🤖 [Codex] Addressed in c27fe33. The demo now calculates the bottom scroll indicator inset from the footer height minus the safe-area bottom inset, clamps it to zero, and re-runs updateList() from viewSafeAreaInsetsDidChange().
There was a problem hiding this comment.
This update is looking good!
| list.autoScrollAction = .pin( | ||
| .item(targetIdentifier), | ||
| itemPosition: .verticalContentOffsetAdjustment { [weak self] info in | ||
| self?.footerAwareScrollDelta(for: info) ?? 0.0 | ||
| }, | ||
| animated: false, | ||
| scrollInterruptionPolicy: .deferDuringUserScrolling, | ||
| shouldPerform: { _ in true } | ||
| ) |
There was a problem hiding this comment.
I think we should explore the initial animations when the view is presented. When opening the demo, the list animates to the selected row when viewDidLayoutSubviews() calls updateList(). It would be a great enhancement if we could find a way around this issue in the demo example. Also curious if this is an issue in the production use case.
Here's a recording with slow animations, taken from an iOS 17.5 simulator.
Simulator.Screen.Recording.-.UI.Test.-.2026-05-27.at.09.53.29.mov
While investigating, I flipped animations to enabled by default in AutoScrollingViewController3 but opening that demo's controller didn't perform the same initial scroll there. I don't think this is an issue in the other demos that showcase pin and scrollTo because they don't have the viewDidLayoutSubviews pass.
There was a problem hiding this comment.
I will investigate 👍🏼
There was a problem hiding this comment.
🤖 [Codex] Addressed in 67ade56. The demo now suppresses animation only for the first layout-driven updateList() pass when the screen is presented. Later row changes still honor the animation toggle.
There was a problem hiding this comment.
Sorry, AI missed here; let me explore addressing this workaround within the library itself.
There was a problem hiding this comment.
Actually, on second though, I do think this maneuver should be done within the client app.
There was a problem hiding this comment.
The new behavior looks much smoother in the demo. And having this be a client app configuration tweak sounds good.
| itemPosition: .verticalContentOffsetAdjustment { [weak self] info in | ||
| self?.footerAwareScrollDelta(for: info) ?? 0.0 | ||
| }, | ||
| animated: false, |
There was a problem hiding this comment.
A button in the navigation bar to toggle the animated flag would be a neat enhancement here, similar to some of the other autoscrolling demos.
There was a problem hiding this comment.
Makes sense, I'll add.
There was a problem hiding this comment.
Thank you for adding that flag -- it's nice to see both behaviors in the demo.
| private func shouldSkipAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { | ||
| guard info.scrollInterruptionPolicy == .skipDuringUserScrolling else { | ||
| return false | ||
| } | ||
|
|
||
| return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress | ||
| } | ||
|
|
||
| private func shouldDeferAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { | ||
| guard info.scrollInterruptionPolicy == .deferDuringUserScrolling else { | ||
| return false | ||
| } | ||
|
|
||
| return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress | ||
| } |
There was a problem hiding this comment.
My agent had this final feedback:
I think using Listable’s own isUserScrollInProgress is the cleaner fix for the deferral/skip logic. Right now the PR does this:
return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgressBut
scrollPositionInfo.isScrollInProgressincludesscrollView.isTracking, so it can be true for a touch-down that never becomes a drag. That creates a pending auto-scroll even though Listable never entered its own “user scroll” lifecycle, so there may be no later delegate callback to flush it.
isUserScrollInProgresshas the better lifecycle pairing:
- Set true in scrollViewWillBeginDragging.
- Set false and flush in scrollViewDidEndDragging when there is no deceleration.
- Set false and flush in scrollViewDidEndDecelerating when there is deceleration.
That means every deferred action caused by isUserScrollInProgress == true has a corresponding end callback that should flush it. Nice and tidy.
I have verified locally that touching down on the scroll view without dragging will enter the isTracking state, but will not call those delegates when the touch down gesture ends. I think this does create a small opportunity to enter a pending autoscroll. We could remove || self.scrollPositionInfo.isScrollInProgress from both of these functions to omit the tracking case.
There was a problem hiding this comment.
My agent had the same finding.
- Resolve before merging
There was a problem hiding this comment.
🤖 [Codex] Addressed in 370abbc. The skip/defer policies now rely only on Listable’s own isUserScrollInProgress lifecycle flag, so a touch-down tracking state that never becomes a drag will not create a pending auto-scroll with no matching flush callback. I also added focused coverage for the non-dragging path.
| list.appearance = .demoAppearance | ||
| list.layout = .demoLayout | ||
| list.animation = .fast | ||
| list.scrollIndicatorInsets.bottom = 112.0 |
There was a problem hiding this comment.
This update is looking good!
| @discardableResult | ||
| public func scrollTo( | ||
| item : AnyIdentifier, | ||
| contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment, |
There was a problem hiding this comment.
This is okay to me. Since this closure is stored, it would be good to add a small doc around this new closure to note that clients should only use weak self.
| list.autoScrollAction = .pin( | ||
| .item(targetIdentifier), | ||
| itemPosition: .verticalContentOffsetAdjustment { [weak self] info in | ||
| self?.footerAwareScrollDelta(for: info) ?? 0.0 | ||
| }, | ||
| animated: false, | ||
| scrollInterruptionPolicy: .deferDuringUserScrolling, | ||
| shouldPerform: { _ in true } | ||
| ) |
There was a problem hiding this comment.
The new behavior looks much smoother in the demo. And having this be a client app configuration tweak sounds good.
| itemPosition: .verticalContentOffsetAdjustment { [weak self] info in | ||
| self?.footerAwareScrollDelta(for: info) ?? 0.0 | ||
| }, | ||
| animated: false, |
There was a problem hiding this comment.
Thank you for adding that flag -- it's nice to see both behaviors in the demo.
There was a problem hiding this comment.
These updates look good 👍
| private func shouldSkipAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { | ||
| guard info.scrollInterruptionPolicy == .skipDuringUserScrolling else { | ||
| return false | ||
| } | ||
|
|
||
| return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress | ||
| } | ||
|
|
||
| private func shouldDeferAutoScroll(_ info: _AutoScrollActionConfiguration) -> Bool { | ||
| guard info.scrollInterruptionPolicy == .deferDuringUserScrolling else { | ||
| return false | ||
| } | ||
|
|
||
| return self.isUserScrollInProgress || self.scrollPositionInfo.isScrollInProgress | ||
| } |
There was a problem hiding this comment.
My agent had the same finding.
- Resolve before merging
Adds a Listable-owned API for custom vertical item positioning from both imperative
ListActionsand declarativeAutoScrollAction.What's changed
ListItemScrollPosition.standard(_:)and.verticalContentOffsetAdjustment(_:).AutoScrollAction.scrollTo(... itemPosition:)andpin(... itemPosition:), with existingposition:overloads preserved.AutoScrollAction.ScrollInterruptionPolicy:.performImmediately.deferDuringUserScrolling.skipDuringUserScrollingCustom Auto Scrolling (Footer-Aware Pin).Example
Use
.skipDuringUserScrollingwhen an auto-scroll request should be dropped instead of retried after user scrolling ends.Visuals
auto-scroll-vertical-offset-rotated.mov
Test Plan
mise exec -- xcodebuild test -workspace Development/ListableDevelopment.xcworkspace -scheme ListableDevelopment-Workspace -destination 'id=52DC49DC-0376-4B43-99BE-BC7A016A51B3' -derivedDataPath /tmp/listable-dd-development-tests -only-testing:ListableTestsgit diff --checkCustom Auto Scrolling (Footer-Aware Pin)demo on iOS SimulatorChecklist
Please do the following before merging:
Mainsection.