Cannot Get ScrollViewReader to scroll reliably across platforms.

I've searched this this all over this forum and the web, and I keep seeing "solutions" that don't actually seem to solve my (simple, I think) case. I'm trying to build a scrolling "conversation" view (think iMessage) where as each new line is added at the bottom, the view scrolls to completely display it. Here's what I've got:

struct ConversationView: View {
    @Environment(GameModel.self) private var gameModel
    @State var shownSteps: Int = 0
    
    var body: some View {
        VStack {
            HStack {
                Spacer()
                Button {
                    //...random button actions...
                } label: {
                    Text("Skip Conversation")
                }
            }.padding(5)
            ScrollViewReader { proxy in
                List {
                    ForEach (gameModel.conversationPoints) {cp in
                        let ind = gameModel.conversationPoints.firstIndex(of: cp)!
                        if (ind <= shownSteps)
                        {
                            ConversationLineView(step: ind, shownSteps: $shownSteps).border(Color.blue)
                                .id(cp.id)
                        }
                    }
                }.onChange(of: shownSteps)
                { a, b in proxy.scrollTo(gameModel.conversationPoints[shownSteps].id, anchor: .bottom)}
            }
        }
    }
}

Basically "shownSteps" is how many entries there are visible at the moment (out of a pre-populated list of "conversation points"), and every time it changes, I want to scroll to that entry.

This looks to me identical to several "working" examples I've found online, and it's obviously close, because it works in some places, but not others:

macOS: Code works fine in Sequioa betas, but doesn't scroll (new messages just show up below the bottom of the scroll region) at all in Sonoma.

iPadOS: The opposite: Works in 17, doesn't work in 18. (My app doesn't run on phone, but I assume the same for iOS)

visionOS: Works in 2.0 betas, haven't checked 1.2.

Any ideas?

Answered by DTS Engineer in 795481022

Please file a bug report using Feedback Assistant and post the Feedback number here.

As a workaround use a ScrollView + VStack instead of aList. For example:

struct ContentView: View {
    @State private var position: Int? = nil
    @State private var data: [Item] = []
    
    var body: some View {
        ScrollView {
            ScrollViewReader { proxy in
                VStack {
                    ForEach(data) { item in
                        Text("\(item.value)")
                            .frame(height: 50)
                            .id(item.id) // Use item.id as the identifier
                    }
                }
                .onChange(of: data) { _, _ in
                    // Scroll to the last element when data changes
                    if let lastItem = data.last {
                        withAnimation {
                            proxy.scrollTo(lastItem.id, anchor: .bottom)
                        }
                    }
                }
            }
            .padding()
        }
        .padding()
        .overlay(alignment: .bottomTrailing) {
            Button("Add Item") {
                // add new item to data
                let newValue = (data.last?.value ?? 0) + 1
                data.append(Item(value: newValue))
            }
        }
        .task {
            for i in 0..<100 {
                data.append(Item(value: i))
            }
        }
    }
}

struct Item: Identifiable, Hashable {
    let id = UUID()
    let value: Int
}

Neglected to add: there's a "[More]" button in ConversationLineView that does the actual incrementing of $shownSteps. These "conversations" are pre-generated, this code is effectively just doing a staged disclosure of each line of an existing list.

Please file a bug report using Feedback Assistant and post the Feedback number here.

As a workaround use a ScrollView + VStack instead of aList. For example:

struct ContentView: View {
    @State private var position: Int? = nil
    @State private var data: [Item] = []
    
    var body: some View {
        ScrollView {
            ScrollViewReader { proxy in
                VStack {
                    ForEach(data) { item in
                        Text("\(item.value)")
                            .frame(height: 50)
                            .id(item.id) // Use item.id as the identifier
                    }
                }
                .onChange(of: data) { _, _ in
                    // Scroll to the last element when data changes
                    if let lastItem = data.last {
                        withAnimation {
                            proxy.scrollTo(lastItem.id, anchor: .bottom)
                        }
                    }
                }
            }
            .padding()
        }
        .padding()
        .overlay(alignment: .bottomTrailing) {
            Button("Add Item") {
                // add new item to data
                let newValue = (data.last?.value ?? 0) + 1
                data.append(Item(value: newValue))
            }
        }
        .task {
            for i in 0..<100 {
                data.append(Item(value: i))
            }
        }
    }
}

struct Item: Identifiable, Hashable {
    let id = UUID()
    let value: Int
}

Feedback ID is FB14324883.

The suggested code modification(ScrollView + VStack) does not work either in my case. I've also tried a LazyVStack instead of the list, as well as a ScrollView embedded inside the ScrollViewReader (I've seen examples of that on the web). All give similar results across the various platforms (works as expected on some platforms, doesn't work as expected on others, and it's not consistently "new ones work, old ones don't" or the reverse).

I've also tried delaying the scroll by a few hundred milliseconds with asyncAfter(), and that doesn't work, either.

ScrollView {
    ScrollViewReader { proxy in
        VStack {
            ForEach (gameModel.conversationPoints) {cp in
                let ind = gameModel.conversationPoints.firstIndex(of: cp)!
                if (ind <= shownSteps)
                {
                    ConversationLineView(step: ind, shownSteps: $shownSteps).border(Color.blue)
                        .id(cp.id)
                }
            }
        }.onChange(of: shownSteps)
        { _, _ in
            print("Scrolling to step \(shownSteps)")
            proxy.scrollTo(shownSteps, anchor: .bottom)
        }
    }
}

Note that one difference here is that your suggested onChange() is looking at the entire content array, and you're modifying that array by adding an element to it each time. My array is fixed in advance, and I'm simply changing an index to indicate how much of the array is drawn (and my onChange handler is looking for changes of the index, not the array). The effect should be identical, of course, but noting it in case it matters.

The print statement shows the correct index to scroll to, indicating both that the step is correct and that the onChange is being called.

The reply immediately above this has one other change: I'm passing just an index (shownSteps) into the scrollTo method rather than gameModel.conversationPoints[shownSteps].id, but the behavior is the same either way.

Interestingly, I've tried to reproduce this behavior in a smaller app, shown here, and the simpler app seems to work correctly everywhere. I have no what the difference is in the "real" app that makes it behave differently on some platforms.

import SwiftUI
import SwiftData

@main
struct SampleScrollApp: App {
    
    static var numbers = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"]
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                
        }
    }
}

struct ContentView: View {
    @State var index = 0
    
    var body: some View {
        VStack {
            HStack {
                Button {
                } label: {
                    Text("Show Full Conversation")
                }
                Spacer()
                Button {
                    
                } label: {
                    Text("Skip Conversation")
                }
            }.padding(5).background(Color.black)

            ScrollView {
                ScrollViewReader { proxy in
                    VStack {
                        ForEach(0..<12) { item in
                            if (item <= index)
                            {
                                TextyView(myIndex: item)
                                    .id(item)
                            }
                        }
                    }.onChange(of: index) { _,_ in proxy.scrollTo(index, anchor: .bottom) }
                }
            }
            Button { index = index + 1 } label: {Text("More") }
        }.frame(width:400, height: 375)
    }
}

struct TextyView : View {
    var myIndex: Int
    
    var body: some View {
        VStack {
            Text("[\(myIndex)]").font(.title)
            HStack {
                Spacer()
                Text(SampleScrollApp.numbers[myIndex])
                    .font(.title)
                    .multilineTextAlignment(.center)
            }
        }.padding(50)
        .border(Color.primary)
        .frame(maxWidth: .infinity, maxHeight: .infinity)

    }
}

#Preview {
    ContentView()
}
Cannot Get ScrollViewReader to scroll reliably across platforms.
 
 
Q