Skip to content

Feature/scroll position with geometry - #573

Open
gurmeherchawla wants to merge 6 commits into
skiptools:mainfrom
Clockworks-Solutions:feature/scroll-position-with-geometry
Open

gurmeherchawla wants to merge 6 commits into
skiptools:mainfrom
Clockworks-Solutions:feature/scroll-position-with-geometry

Conversation

@gurmeherchawla

Copy link
Copy Markdown

Support ScrollPosition, onScrollGeometryChange, and onScrollPhaseChange

Adds Android support for programmatic scrolling with ScrollPosition, makes .scrollPosition(id:anchor:) two-way, adds
ScrollViewProxy anchors, and brings onScrollGeometryChange and onScrollPhaseChange to every scroll container. The
behavior follows iOS 26, which I checked case by case on the simulator.

Related PR: #532

What changes

ScrollPosition

  • ScrollPosition was an unavailable stub. It's now a value type with every iOS initializer, the scrollTo(id:anchor:),
    scrollTo(edge:), scrollTo(point:), and scrollTo(x:y:) variants, and edge, point, viewID, viewID(type:),
    and isPositionedByUser.
  • .scrollPosition(_:anchor:) scrolls as the binding changes, animated inside withAnimation.
  • .scrollPosition(id:anchor:) used to only report the top item. It now also scrolls when the ID changes, and supports
    anchor.

Matching iOS

  • With a nil anchor, scrolling to an ID moves the least distance that shows the whole view.
  • The user's scrolling writes ScrollPosition(id:) for the first view whose middle is past the anchor, and leaves
    isPositionedByUser false (what iOS 26 does). Views without IDs write nothing.
  • point is nil unless both x and y were set.

ScrollViewReader: scrollTo(_:anchor:) honors anchor, and works on the .id/ForEach views of a non-lazy
ScrollView, which it didn't before.

Geometry: ScrollGeometry and .onScrollGeometryChange work in ScrollView, the lazy stacks and grids, and List.

Phase: both .onScrollPhaseChange overloads report:

Action Phases Velocity
Appear idle → idle once 0
Drag idle → interacting → idle 0
Fling interacting → decelerating → idle points per millisecond at decelerating
Animated scroll idle → animating → idle 0
Unanimated scroll none —

Bug fix: the withAnimation marker read by Animation.isInWithAnimation was never cleared, because its coroutine ran
on Dispatchers.Main, which has no frame clock. Every change after the first withAnimation then animated. It's a
separate commit.

How it works

  • ScrollPosition.swift adds a ScrollDriver protocol with two implementations:
    • ScrollViewDriver: ScrollState, plus the layout coordinates that .id/ForEach views register as they're placed.
    • LazyScrollDriver: LazyListState/LazyGridState, plus LazyItemCollector.
  • ScrollDriverEffects connects a driver to the reader, position, geometry, and phase APIs. Each container replaces its
    own scroll-to-ID and position code with about three lines.
  • Bindings and scroll state are observed with snapshotFlow in effects, never read during composition, so updates don't
    recompose the container. On a device, a LazyVStack with .scrollPosition(id:) scrolls with the same jank as one
    without it.
  • Containers clear these values for their content, so nested scroll views (such as carousels in rows) don't act on them.

Notes for review

  • List.swift: the LazyColumn is wrapped in an environment scope and re-indented. The diff is 9 lines added and 12
    removed with whitespace ignored.
  • Lazy containers only measure visible items, so offsets and content sizes in lazy containers are estimates from the
    visible items' average size; they're exact for uniform rows.
  • Offsets exclude the safe-area insets iOS includes, and contentInsets is zero.
  • SkipLib has no CGVector, so ScrollEffects.swift adds a minimal Kotlin-only one for
    ScrollPhaseChangeContext.velocity.
  • Kotlin needs a few annotations that Swift doesn't:
    • An unusedp: parameter on scrollPosition(_:anchor:), because erasure gives both scrollPosition overloads the same
      JVM signature.
    • A Binding<out Hashable?> declaration on scrollPosition(id:), so $position resolves to the ScrollPosition
      overload.
    • suspend declarations on the driver methods, which run Compose scroll calls in effect coroutines.

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device
  • REQUIRED: I have checked whether this change requires a corresponding update in the Skip Fuse UI repository (link related PR if applicable)
  • OPTIONAL: I have added an example of any UI changes to the Showcase sample app

This fixes a bug where, after the first withAnimation, every later change kept animating on Android
Adds support for ScrollPosition, onScrollGeometryChange and onScrollPhaseChange.
Adds ScrollPosition, scroll geometry, and scroll phase support
Adds document for scroll position, geometry and phase support
@cla-bot cla-bot Bot added the cla-signed label Oct 16, 2026
Take upstream's List.swift section rendering (skiptools#583) and re-apply this
branch's clearScrollContainerValues wrapper around the LazyColumn. Combine
upstream's idealSizeOnUnboundedScrollAxes with the ScrollViewDriver size
tracking in ScrollView.swift.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants