18

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.

1
  • 1
    You can use LazyVStack in ScrollView to trigger onAppear when items appear on the screen. This required iOS 14 tho. Commented Feb 14, 2021 at 18:35

3 Answers 3

15

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
            })
        }
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Thanks. That will work when scrolling down the first time, but when a user scrolls back up, those chats will be rendered already and the zoom won't update will it?
Doesn't determine if the view is in the screen, just if the OS thinks is going to be on the screen, and so can be triggered much before of the view being on the screen, even if you scroll back up and the view is never shown.
Any ideas to get it work with only real visible items? And even with a way to check how much an item is visible?
5

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.

Comments

2

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)
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.