Skip to content

Add custom item offset scrolling#615

Merged
robmaceachern merged 10 commits into
square:mainfrom
aaalaniz:aalaniz/listable-item-offset-scrolling
May 28, 2026
Merged

Add custom item offset scrolling#615
robmaceachern merged 10 commits into
square:mainfrom
aaalaniz:aalaniz/listable-item-offset-scrolling

Conversation

@aaalaniz
Copy link
Copy Markdown
Collaborator

@aaalaniz aaalaniz commented May 21, 2026

Adds a Listable-owned API for custom vertical item positioning from both imperative ListActions and declarative AutoScrollAction.

What's changed

  • Adds ListItemScrollPosition.standard(_:) and .verticalContentOffsetAdjustment(_:).
  • Adds AutoScrollAction.scrollTo(... itemPosition:) and pin(... itemPosition:), with existing position: overloads preserved.
  • Adds AutoScrollAction.ScrollInterruptionPolicy:
    • .performImmediately
    • .deferDuringUserScrolling
    • .skipDuringUserScrolling
  • Keeps custom adjustments vertical-only; no horizontal offset API is added.
  • Adds a generic demo: Custom Auto Scrolling (Footer-Aware Pin).
  • Adds tests for custom declarative auto-scroll, callback behavior, deferral, and skip policy.

Example

list.autoScrollAction = .pin(
    .item(targetIdentifier),
    itemPosition: .verticalContentOffsetAdjustment { info in
        max(0.0, info.itemFrame.maxY - info.visibleContentFrame.maxY)
    },
    scrollInterruptionPolicy: .deferDuringUserScrolling
)

Use .skipDuringUserScrolling when 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:ListableTests
  • git diff --check
  • Built and launched Custom Auto Scrolling (Footer-Aware Pin) demo on iOS Simulator

Checklist

Please do the following before merging:

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

Expose a Listable-owned item scrolling API that lets callers compute
a vertical content offset adjustment without accessing the scroll view.
@aaalaniz
Copy link
Copy Markdown
Collaborator Author

🤖 @codex review

list.animation = .fast
list.scrollIndicatorInsets.bottom = 112.0

list.autoScrollAction = .pin(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The new API in action

@discardableResult
public func scrollTo(
item : AnyIdentifier,
contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think it's okay that this is marked as escaping but making a note for myself

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [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.

Comment thread ListableUI/Tests/ListView/ListViewTests.swift
@aaalaniz aaalaniz marked this pull request as ready for review May 22, 2026 18:42
Copy link
Copy Markdown
Collaborator

@johnnewman-square johnnewman-square left a comment

Choose a reason for hiding this comment

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

This is a neat enhancement. I left just a few notes that I think we could potentially explore.

Comment on lines 103 to 111
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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, will adjust.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [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.

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.

These updates look good 👍

list.appearance = .demoAppearance
list.layout = .demoLayout
list.animation = .fast
list.scrollIndicatorInsets.bottom = 112.0
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.

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!

Suggested change
list.scrollIndicatorInsets.bottom = 112.0
list.scrollIndicatorInsets.bottom = footer.bounds.height - view.safeAreaInsets.bottom

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good suggestion. This is more aligned with the target use case too 👍🏼

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [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().

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.

This update is looking good!

Comment on lines +151 to +159
list.autoScrollAction = .pin(
.item(targetIdentifier),
itemPosition: .verticalContentOffsetAdjustment { [weak self] info in
self?.footerAwareScrollDelta(for: info) ?? 0.0
},
animated: false,
scrollInterruptionPolicy: .deferDuringUserScrolling,
shouldPerform: { _ in true }
)
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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I will investigate 👍🏼

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sorry, AI missed here; let me explore addressing this workaround within the library itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Actually, on second though, I do think this maneuver should be done within the client app.

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.

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,
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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Makes sense, I'll add.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [Codex] Addressed in 82b39ea and refined in 0eb4ab2. The demo has a navigation bar control for the auto-scroll animated flag, and the button now shows Animations: On / Animations: Off so the current state is visible while recording the demo.

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.

Thank you for adding that flag -- it's nice to see both behaviors in the demo.

@johnnewman-square johnnewman-square requested a review from a team May 27, 2026 14:30
Copy link
Copy Markdown
Collaborator

@johnnewman-square johnnewman-square left a comment

Choose a reason for hiding this comment

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

This looks good. Thanks for making those edits. There were just a couple final items of feedback that I think we could address before merging.

Comment on lines +1663 to +1677
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
}
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.

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.isScrollInProgress

But scrollPositionInfo.isScrollInProgress includes scrollView.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.

isUserScrollInProgress has 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.

Copy link
Copy Markdown
Member

@robmaceachern robmaceachern May 28, 2026

Choose a reason for hiding this comment

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

My agent had the same finding.

  • Resolve before merging

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 [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
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.

This update is looking good!

@discardableResult
public func scrollTo(
item : AnyIdentifier,
contentOffsetAdjustment : @escaping ListItemScrollPositionAdjustment,
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.

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.

Comment on lines +151 to +159
list.autoScrollAction = .pin(
.item(targetIdentifier),
itemPosition: .verticalContentOffsetAdjustment { [weak self] info in
self?.footerAwareScrollDelta(for: info) ?? 0.0
},
animated: false,
scrollInterruptionPolicy: .deferDuringUserScrolling,
shouldPerform: { _ in true }
)
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.

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,
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.

Thank you for adding that flag -- it's nice to see both behaviors in the demo.

Comment on lines 103 to 111
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.

These updates look good 👍

Copy link
Copy Markdown
Member

@robmaceachern robmaceachern left a comment

Choose a reason for hiding this comment

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

Thanks for doing this!

Comment on lines +1663 to +1677
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
}
Copy link
Copy Markdown
Member

@robmaceachern robmaceachern May 28, 2026

Choose a reason for hiding this comment

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

My agent had the same finding.

  • Resolve before merging

@robmaceachern robmaceachern enabled auto-merge (squash) May 28, 2026 20:39
@robmaceachern robmaceachern merged commit fe74f3f into square:main May 28, 2026
6 checks passed
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.

3 participants