0

SwiftUI's scroll behavior is vexing.

So, using iOS 18 newest APIs...

If I use a custom id tag and scrollTo on its own, it works fine (actually not fine, it often scrolls to the wrong place with LazyVStack, but thats another topic)

However, if I also want to use onScrollVisibilityChange on one of my items in my ForEach and if I do, it just stops working. Everything still fires, but nothing scrolls.

I need to know when something comes on screen, and I also want to programmatically scroll to places.

It turns out, with custom ids, they must be added after the onScrollVisibilityChange modifier. Does anyone know why? This behavior is very strange, I understand view modifiers wrap each subsequent one, but one modifier shouldn't negate the custom id of another.

You can reproduce with the following code.

struct TestItem: Identifiable, Hashable {
    let id: UUID = UUID()
    let chapter: Int
    let color: Color
    
    static let preview: [TestItem] = [TestItem(chapter: 1, color: .red), TestItem(chapter: 2, color: .green), TestItem(chapter: 3, color: .blue)]
}

struct ScrollTest: View {
    let items = TestItem.preview
    @State private var scrollPosition = ScrollPosition(idType: Int.self)
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items) { item in
                    ZStack {
                        Rectangle()
                            .fill(item.color)
                            .frame(height: 900)
                        
                        Text("\(item.chapter)")
                            .font(.system(size: 90, weight: .bold))
                    }
                    .id(item.chapter)
                    // if the custom id tag is added BEFORE onScrollVisibilityChange modifier, scrollTo will not work
                    .onScrollVisibilityChange(threshold: 0.02) { visible in
                        if visible {
                            print("Item: \(item.chapter) is visible")
                        }
                    }
                    // if the custom id is instead added AFTER, it will work fine (if you don't use custom id tags then it doesn't matter to begin with)
                    // Why does it need to be this way? Why does the onScrollVisibilityChange affect the custom id?
//                    .id(item.chapter)
                    
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition($scrollPosition, anchor: .top)
        .task {
            // on start, scroll to second chapter item
            scrollPosition.scrollTo(id: 2, anchor: .top)
        }
    }
}
4
  • Please show a minimal reproducible example - something that can be copy pasted directly to a new project and reproduce your issue. Commented Jan 27 at 14:57
  • Done, and valid correction; that being said, if it was you, please don't just down vote questions and answers you don't understand. It's still a valid issue. Commented Jan 29 at 10:53
  • 1
    It was not me, but your answer could certainly be improved. You should remove the unnecessary meta-commentary ("Well I just answered my own question"), and you should not ask another question in your answer ("but why is the .id nullified if its before the other modifier?"). It'd be great if you can also show a complete code snippet. Commented Jan 29 at 10:57
  • my mistake, and you're right Commented Sep 18 at 5:01

2 Answers 2

0

Your TestItem id has a UUID type but you're telling ScrollPosition to look for an Int.

Instead, you should set ScrollPosition to use whatever the type of TestItem's id is:

@State private var scrollPosition = ScrollPosition(idType: TestItem.ID.self)

But, since you also want to scroll to a specific id, using a UUID will be tricky. For example purposes, make the TestItem id an Int and then specify the id when you initialize the test items:

struct TestItem: Identifiable, Hashable {
    // let id: UUID = UUID()
    var id: Int
    let chapter: Int
    let color: Color
    
    static let preview: [TestItem] = [
        TestItem(id: 1, chapter: 1, color: .red),
        TestItem(id: 2, chapter: 2, color: .green),
        TestItem(id: 3, chapter: 3, color: .blue)
    ]
}

Then, this will work properly:

.task {
    // on start, scroll to second chapter item
    withAnimation {
         scrollPosition.scrollTo(id: 2, anchor: .top)
    }
}

Full code:

import SwiftUI

struct TestItem: Identifiable, Hashable {
    // let id: UUID = UUID()
    var id: Int
    let chapter: Int
    let color: Color
    
    static let preview: [TestItem] = [
        TestItem(id: 1, chapter: 1, color: .red),
        TestItem(id: 2, chapter: 2, color: .green),
        TestItem(id: 3, chapter: 3, color: .blue)
    ]
}

@available(iOS 18.0, *)
struct ScrollTest: View {
    
    //State values
    @State private var scrollPosition = ScrollPosition(idType: TestItem.ID.self)
    
    //Constants
    let items = TestItem.preview

    //Body
    var body: some View {
        
        if let viewID = scrollPosition.viewID(type: TestItem.ID.self),
           let item = items.first(where: {$0.id == viewID}) {
            Text("Visible item: \(item.color)")
                .foregroundStyle(item.color)
        }
        
        ScrollView {
            LazyVStack {
                ForEach(items) { item in
                    ZStack {
                        Rectangle()
                            .fill(item.color)
                            // .frame(height: 900)
                        
                        Text("\(item.chapter)")
                            .font(.system(size: 90, weight: .bold))
                    }
                    .containerRelativeFrame(.vertical) // <- use this instead of fixed frame height
                    .onScrollVisibilityChange(threshold: 0.02) { visible in
                        if visible {
                            print("Item: \(item.chapter) is visible")
                        }
                    }
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition($scrollPosition, anchor: .top)
        .task {
            // on start, scroll to second chapter item
            withAnimation {
             scrollPosition.scrollTo(id: 2, anchor: .top)
            }
        }
    }
}

#Preview {
    if #available(iOS 18.0, *) {
        ScrollTest()
    } else {
        // Fallback on earlier versions
    }
}

enter image description here

Sign up to request clarification or add additional context in comments.

2 Comments

I realized after posting and reading everything again, that you were asking about using a custom ID. On my end, .scrollTo seems to work regardless of whether .id is placed before or after .onScrollVisibilityChange. Instead of using .id, I am curious if you notice the same outcome if you specify the id as part of the ForEach instead: ForEach(items, id: \.chapter) { item in
thanks for the detailed answer, I do apologize but since my workaround of placing it after worked I got caught up and moved on to other parts of my project. But yes exactly my issue was with using a custom ID, It's still not entirely clear to me why it works or not, but these are good suggestions to use the same id as the for each, my problem was I could not do that, I wanted a separate id mechanism from the chapter, but maybe I can revisit my whole approach
-1

Well I just answered my own question.

If you use a custom .id tag, it MUST come after the .onScrollVisibilityChange view modifier.

but why? its not logical at all. I understand how the nesting of view modifiers works, but why is the .id nullified if its before the other modifier?

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.