Reputation: 175
Trying to programmatically determine when an item is being displayed on screen in a ScrollView in SwiftUI. I understand that a ScrollView renders at one time rather than rendering as items appear (like in List), but I am constrained to using ScrollView as I have .scrollTo actions.
I also understand that in UIKit with UIScrollView, it is possible to use a CGRectIntersectsRect between the item frame and the ScrollView frame in the UIScrollViewDelegate but I would prefer to find a solution in SwiftUI if possible.
Example code looks like this:
ScrollView {
ScrollViewReader { action in
ZStack {
VStack {
ForEach(//array of chats) { chat in
//chat display bubble
.onAppear(perform: {chatsOnScreen.append(chat)})
}.onReceive(interactionHandler.$activeChat, perform: { _ in
//scroll to active chat
})
}
}
}
}
Ideally, when a user scrolls, it would check which items are on screen and zoom the view to fit the largest item on screen.
Upvotes: 17
Views: 15004
Reputation: 1472
In iOS 18, we now have the onScrollVisibilityChange
modifier. So simply attach it to the item whose visibility you want to track.
There's also a onScrollTargetVisibilityChange
modifier for the scroll view.
Upvotes: 1
Reputation: 21
Inspired by this answer: https://stackoverflow.com/a/75823183/22499987.
As mentioned above, the issue with using onAppear
is it only is guaranteed to be called the first time each of the sub-views is loaded, but not when you scroll back.
The below solution shows an example of a LazyHStack of horizontal Text elements, and detects which index we are on using GeometryReader. We wrap the inner Text elements each in their own instance of a GeometryReader which we use to find the current MidPoint x coordinate. If it is within the threshold of the screen midpoint, we set our currentIndex as this index.
Note that this onChange function will be called for all Texts that are visible on the screen. For example, as we are swiping from 1 -> 2, onChange will track the x for 1 which is around the screenMid. As 2 becomes visible, the x for 2 which will actually be a negative value since it's midpoint is left of 0 (left edge of screen).
struct SampleView: View {
@State private var currIndex: Int = 1
var body: some View {
let screenWidth = UIScreen.main.bounds.width
let numLoops = 10
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(1 ..< numLoops + 1, id: \.self) { index in
GeometryReader { inner in
Text("\(index)")
.onChange(of: inner.frame(in: .global).midX) {
// x will be the midPoint of the current view
let x = inner.frame(in: .global).midX
let screenMid = screenWidth / 2
let threshold: CGFloat = screenWidth / 10
// check if the midpoint of the current view is within our range of the screen mid
if x > screenMid - threshold && x < screenMid + threshold {
print("\(index) is in the middle")
currIndex = index
}
}
}
.frame(width: screenWidth, height: 150)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.frame(height: 150)
}
}
Upvotes: 2
Reputation: 257583
When you use VStack
in ScrollView
all content is created at once at build time, so onAppear
does not fit your intention, instead you should use LazyVStack
, which will create each subview only before it appears on screen, so onAppear
will be called when you expect it.
So it should be like
ScrollViewReader { action in
ScrollView {
LazyVStack { // << this !!
ForEach(//array of chats) { chat in
//chat display bubble
.onAppear(perform: {chatsOnScreen.append(chat)})
}.onReceive(interactionHandler.$activeChat, perform: { _ in
//scroll to active chat
})
}
}
}
Upvotes: 14