Skip to content

Add custom item offset scrolling#615

Open
aaalaniz wants to merge 8 commits into
square:mainfrom
aaalaniz:aalaniz/listable-item-offset-scrolling
Open

Add custom item offset scrolling#615
aaalaniz wants to merge 8 commits into
square:mainfrom
aaalaniz:aalaniz/listable-item-offset-scrolling

Conversation

@aaalaniz
Copy link
Copy Markdown

@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
Author

🤖 @codex review

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

list.autoScrollAction = .pin(
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.

The new API in action

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

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

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
Contributor

@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
Contributor

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

list.appearance = .demoAppearance
list.layout = .demoLayout
list.animation = .fast
list.scrollIndicatorInsets.bottom = 112.0
Copy link
Copy Markdown
Contributor

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

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
Contributor

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

itemPosition: .verticalContentOffsetAdjustment { [weak self] info in
self?.footerAwareScrollDelta(for: info) ?? 0.0
},
animated: false,
Copy link
Copy Markdown
Contributor

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

@johnnewman-square johnnewman-square requested a review from a team May 27, 2026 14:30
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