ScrollView still doesn’t support scrolling to a specific item when the user stops dragging?

It’s great to see the new ScrollViewReader and scrollTo functionality. However there still doesn’t seem to be a way to detect when the user stops scrolling, to adjust the final scroll offset so that you can “snap” the scroll view to specific locations. This is such a common requirement in picker-style UIs as well as other use cases where you need items in the scroll view to align with something else on screen, just like we have always been able to do with e.g. UIScrollViewDelegate.scrollViewWillEndDragging

Is there a way do to this with SwiftUI in iOS/iPad 14 and macOS 11?

If not is there any way this can be added during the betas? It will be extremely painful for us to not have this in SwiftUI for at least another full year. We’re trying to make ship some SwiftUI-“only” apps that need this and there do not appear to be viable workarounds (you can try to fake it without ScrollView, using a custom drag gesture and adjusting contentOffset etc. but it is super painful and does not feel natural, performance issues etc.)

Can we get access to the internal isDecelerating state so we can observe it can then call the new scrollTo?

Something like:

Code Block
ScrollViewReader { _ in ... }
.onEndDragging { scrollProxy in ... }


Thanks in advance. Happy to try to book a lab session if that helps.

(Resubmitted with correct tag because tags cannot be edited)
I tried but failed to get a lab appointment for this issue, having submitted Feedback for it "ScrollView cannot be used to "snap" child views to a specific on-screen region" (FB7784831). Here's the content of the FB and the sample code:

We need to be able to replicate the UIKit-era behaviours of UIScrollViewDelegate where we could handle scrollViewWillEndDragging and receive the anticipated scroll end point and amend it if necessary, to make items “snap” to align to a certain location on screen, rather than just permitting free scrolling.

This seems like it is still not possible in SwiftUI, if you e.g. envision creating something like the old iOS date picker which “snaps” the item nearest to a guide (e.g. center) when the user ceases scrolling.

We have tried various techniques, with the worst and most painful being using a totally custom View and custom offsetting of the content when a drag gesture onChange occurs, and projecting out the final offset when onEnded occurs. This does not feel correct on any platform as the animation inertia curves aren’t perfect and obviously has performance issues for large lists.

It feels like ScrollViewReader gets us very close to being able to do this so I would love your input on how to achieve this very old capability we’ve had in UIKit since iOS 5!

Attached is a sample project that shows an attempt to get close to this using the new ScrollViewReader.

The sample:
  • does not attempt to capture geometry of each scroll view child, for simplicity

  • attempts to capture a “did end dragging” equivalent by using a simultaneous drag gesture on each child view, but onEnded does not get called in current iOS 14 betas

  • does not attempt to scroll to the correct location because the event never triggers, and the sample does capture the geometry yet - and the scrollTo function on ScrollViewProxy does not seem to offer the required ability to either simply scroll to a specific Y offset (based on the Y offset of the child nearest to the predicted end location of the drag) or the ability to scroll item with a given ID to align a specific edge e.g. ID’s .top with a specific scroll offset in the scroll view. e.g. “align item “N” top to y = 500”.

If the above can be made to work that would be great.

However it seems a vastly preferable, if old fashioned solution, would be to have an:
Code Block
.onScrollingWillEnd { offset -> CGPoint in … }

…event modifier on ScrollView so that we could receive the anticipated scroll offset and return a modified version if we want to.

Even better would be something like:

Code Block
ScrollView {
… child views …
}
.onScrollingWillEnd { offset -> ScrollTarget in
return ScrollTarget(id: findIDOfChildNearest(to: offset),
anchor: .top, // Align the top of the view with the given ID
toAnchor: mySnapPointAnchor) // to this anchor point which might be in another coordinate space
}

Here's the full example (didn't fit in previous reply character limit, so have had to gist it) from the FB that shows what we're trying to achieve, with the gaps around how to detect the end of scrolling so we can seek to a position we want.

https://gist.github.com/marcpalmer/c48a56e26b2319e5b43c2ad6973ad4d4
It's worth noting that in the gist the simultaneous drag gesture triggers onChanged but not onEnded. This might be a bug, but maybe the simultaneous drag alongside the ScrollView drag is never considered truly "Recognised" so it does not end?

There's a possible workaround to use onChanged to set some state (the translation), monitor that state for changes using the new onChange and applying some hysteresis to this using a publisher, such that when there is no new change for ~ 50ms, we can then alter some state to trigger a call to the ScrollViewReader.scrollTo?
I have tried a workaround suggested elsewhere whereby:
  1. We use the onChange of the simultaneous drag gesture applied to each child view of the ScrollView to pass the child ID and offset to a publisher that is debounced at e.g. 50ms

  2. We onReceive the debounced publisher and use the value to call scrollProxy.scrollTo(id:,anchor:)

This mechanism works to "detect" the end of a scroll interaction by the user, but the scrollTo seems to be too "smart" in that we cannot control the exact scroll offset to move to. This makes it impossible to lock to a specific position on screen.

It seems the anchor argument to scrollTo is merely a "point relative to the sub view identified by ID which should definitely be on screen" rather than an actual location where the view with that ID should be.

I think therefore scrollTo is not currently usable for this purpose of specific a specific content offset in the ScrollView. If that is true this is all for nought and while we can hack detecting the end of scrolling like this, we still can't direct the ScrollView to a specific offset. Any chance we can get a little API improvement on this during betas?

I've updated the example to fix some bugs, new code is at:

https://gist.github.com/marcpalmer/c48a56e26b2319e5b43c2ad6973ad4d4

This example now shows that scrollTo works but only scrolls to e.g. the center of the scroll view (a unit point within it) and cannot seemingly be used to scroll item with ID to a specific Y offset such that e.g. the view with the ID's top is aligned with the desired Y. You could fake this by calculating what unit point in the ScrollView coordinate system will yield the "snap" point you want, but this will not align the view correctly to that as the anchor unitpoint is apparently shared between the ScrollView and the view you are scrolling, which seems like strange behaviour. I would suggest an API change there so that the two anchors can be specified independently.

Secondly, the sample shows that onChanged is emitted for a simultaneous drag gesture but not onEnded. We need onEnded for this mechanism of detecting the end of scrolling to work as monitoring onChange for when it "stops changing" is fraught with problems.


Not sure if you ended up solving this, but I recently had the need to track the position of a scroll, and I ended up doing it with anchor preferences. This works really well and you can get the exact offset of any item. To "snap", you just need to put a container stack, to act as a page, and then scrollTo that id.

@pygmypony I just want to thank you for documenting your journey here! Too often people don't return to their question to share what they've learned. I'm wanting to implement the same thing you are, so this thread has been very helpful!

For what it's worth, I ended up using a GeometryReader to keep track of when the ScrollView is moving, rather than watching drags.

ScrollView still doesn’t support scrolling to a specific item when the user stops dragging?
 
 
Q