Brad Thomas
Brad Thomas

Reputation: 175

How to check if item is visible - SwiftUI ScrollView

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

Answers (3)

Eric Yuan
Eric Yuan

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

bthomp2000
bthomp2000

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

Asperi
Asperi

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

Related Questions