ScrollViewReader / scrollTo(_:anchor:) not working reliably

1.4k Views Asked by At

I've build a ScrollView which contains 0-3 images and a multiline text field in a VStack. I also added a ScrollViewReader inside the scrollview and use it to scroll to the bottom of the text field upon certain events (user starts typing, image collection changes).

The point is: sometimes it works, sometimes it doesn't. When it does not work I realized, that when I scroll a little bit by hand and then try again (e.g. typing) it works.

Not sure if this is relevant, but ImageOrPlaceholderComponent first shows a placeholder as long as the image within currentEntryImages is nil, and the image after that (both states imply a change to currentEntryImages and should thus result in scrolling to the bottom of the text field).

        NavigationStack {
            ScrollView {
                ScrollViewReader { scrollview in
                    VStack {
                        // Attached images.
                        AnyLayout(VStackLayout(spacing: 2.5)) {
                            ForEach(values: currentEntryImages) { entryImage in
                                ImageOrPlaceholderComponent(image: entryImage)
                                    .clipped()
                            }
                        }

                        // Text field for the entry with toolbar.
                        TextField("...", text: $entryDTO.text, axis: .vertical)
                            .id(entryTextFieldAnchor)
                            .multilineTextAlignment(.leading)
                            .padding()
                            .focused($mainTextFieldFocused)
                            .onAppear { mainTextFieldFocused = true }

                            // Scroll to the bottom of the text field, when the user is typing ...
                            .onChange(of: entryDTO.text) { _ in
                                withAnimation {
                                    scrollview.scrollTo(entryTextFieldAnchor, anchor: .bottom)
                                }
                            }

                            // ... or the entry images have changed.
                            .onChange(of: currentEntryImages) { _ in
                                withAnimation {
                                    scrollview.scrollTo(entryTextFieldAnchor, anchor: .bottom)
                                }
                            }
                    }
      
                }
            }
        }
3

There are 3 best solutions below

1
On

Was facing a similar problem. While not a perfect solution I noticed that rate-limiting the scrollTo function solved the issue.

Once I updated, say, once every 5 times, it scrolled as should and didn't require manual scrolling first.

My guess is something breaks in the internal scrolling logic, and from what I could see ScrollViewReader doesn't expose anything to help us moderate the usage.

0
On

Found another hack, seems more solid than the rate-limiting one (leaving the answer as it is a quick and dirty solution that sometimes work)

Assuming the behavior we are seeing is some internal state breaking in ScrollView or ScrollViewReader, I came up with a way to "help" the internal state machine.

It's super hacky, but seems to work really well for me. The trick is to have two different empty views at the bottom of the scroll content, and then intermittently scroll to the bottom of each of them.

So my scroll content ends with:

Text("")
    .frame(height: 0.0)
    .id(2)
Text("")
    .frame(height: 0.0)
    .id(3)

These hacky views were better than EmptyView()'s in my tests. Again, treading black-box territory here, so will allow it :)

Then in your scrolling code you do this based on your own counting logic:

if (newStepCount % 3 == 0) {
    DispatchQueue.main.async {
        proxy.scrollTo(newStepCount % 2 == 0 ? 2 : 3, anchor: .bottom)
    }
}
0
On

I had this issue and solved it by having another marker at the top of the scrollview, and calling sequentially:

DispatchQueue.main.async {
    reader.scrollTo(topID, anchor: .top)
    reader.scrollTo(bottomID, anchor: .bottom)
}

I don't know why that works though.

Example of a ScrollView that stays scrolled at the bottom when the size changes:

struct ScrollViewPinnedBottom<Content:View>: View {

    @Namespace var bottomID
    @Namespace var topID

    @ViewBuilder var content: () -> Content

    @StateObject private var model = ScrollViewPinnedBottomModel()
    
    var body: some View {
        ScrollViewReader { reader in
            ScrollView(.vertical) {
                VStack {
                    Color.clear
                        .frame(height: .zero)
                        .id(topID)

                    content()
                    
                    Color.clear
                        .frame(height: .zero)
                        .id(bottomID)
                }
                .readSize { size in
                    if model.shouldScroll(newSize: size) {
                        DispatchQueue.main.async {
                            reader.scrollTo(topID, anchor: .top)
                            reader.scrollTo(bottomID, anchor: .bottom)
                        }
                    }
                }
            }
        }
    }

}


class ScrollViewPinnedBottomModel: ObservableObject {
    
    var lastSize: CGSize?

    func shouldScroll(newSize: CGSize) -> Bool {
        guard newSize.height != lastSize?.height else { return false }
        lastSize = newSize
        return true
    }

}

This example uses this readSize extension.