ScrollViewProxy scrollTo will crash when scrolling outside of bounds of previous (not current) List data source/array in iPad and iOS 16 beta

Hi, It seems that there's a bug in the iOS and iPadOS 16 betas (i've not test in macOS 13 yet), that when after updating a List data source with a bigger array, scrolling to a row of an offset bigger than the previous data array, with ScrollViewProxy, will crash the app with an EXC_BREAKPOINT (code=1, subcode=0x1099d6004) error.

Below I attached a sample code to reproduce the crash. In it when opening there's an empty list. When pressing the "Randomize & Select Last" button at the top, the list will be filled with 5 random UUIDs, followed by scrolling to the last row. The app will crash on the second try, when it will try to scroll to an offset bigger than the previous data array (the last row).

As a control there's an "Randomize & Select Random" button that will behave more or less like the "Randomize & Select Last" button but will choose a random row to select and scroll. It will only crash if this select row if of an offset bigger that the size of the previous array.

This doesn't happen in iOS 15.

I've already posted the Radar as FB11302966, however if someone has any toughs on how to solve this problem (or what I'm doing wrong) they are welcome.

Sample code:


///A simple data model for the demo. Only stores an UUID.
struct DataModel: Identifiable, Hashable {
    let id: UUID = UUID()
    var nameUUID: String {
        id.uuidString
    }
}


struct ContentView: View {

///Array with some data to show
    @State private var data: [DataModel] = []
    ///Selected row
    @State private var selection: DataModel?

    var body: some View {
        VStack(alignment: .leading) {

            HStack {
                //Create a new array for showing in the list.
                //This array will be bigger than the last one.
                //The selection will be the last element of the array (triggering the bug)
                Button {
                    //Increment the size of the new List by 5
                    let numberElements = data.count  + 5

                    //Create a new Array of DataModel with more 5 elements that the previous one
                    let newData = (0 ..< numberElements).map { _ in  DataModel() }

                    //Select the last element of the array/list.
                    //This will make sure that the scrollTo will go to the end
                    let newSelection = newData.last

                    //Update STate for the new values
                    data = newData
                    selection = newSelection

                } label: {
                    Text("Randomize & Select Last")
                }

                Spacer()
                //Create a new array for showing in the list.
                //This array will be bigger than the last one.
                //The selection will be the a random element of the array (only triggering the bug when the element is )
                Button {
                    //Increment the size of the new List by 5
                    //If empty will start with 40 (reducing the odds of triggering the bug)
                    let numberElements = data.count == 0 ? 40 : data.count + 5

                    //Create a new Array of DataModel with more 5 elements that the previous one
                    let newData = (0 ..< numberElements).map { _ in  DataModel() }

                    //Select a random element of the array/list.
                    //This will scroll if the element is 'inside' the previous list
                    //Otherwise will crash
                    let newSelection = newData.randomElement()

                    //Update State for the new values
                    data = newData
                    selection = newSelection

                } label: {
                    Text("Randomize & Select Random")
                }

            }
                .padding()

            //MARK: ScrollViewReader and List
                ScrollViewReader {
                    proxy in

                    List(data, selection: $selection) {
                        dataElement in

                        //Row (is only the UUID for the rows
                        Text(dataElement.id.uuidString)
                            .id(dataElement)
                            .tag(dataElement)

                    }
                    //action that fires when data changes
                    //it will scroll to the selection
                    .onChange(of: data, perform: { newValue in

                        if let selection {
                            proxy.scrollTo(selection)
                        }
                    })
                }
        }
    }
}

I'm getting the same. This seems to affect only List. If you modify the List to ScrollView it does not throw the error.

Beta 7 still has the bug. I hope that at least in the release candidate is fixed. This could be app breaking.

Also getting this - big problem for me.

Also seeing this issue even on the developer seed/GM. I submitted a feedback/rdr a month ago, but mine also shows as no similar recent reports.

At least it will not be solved for a lack of trying.

I'm also experiencing this on now Prod iOS 16.

Hi,  I had same problem. 

I worked around the problem by forcibly re-rendering the list. (But the list may flicker when redrawing.)

I’ve marked the changes in the original sample code with 🌟.

I hope the problem will be fixed iOS 16.1...

Outline

  1. Hide the list before updating data.
  2. Show the list from DispatchQueue.main.async when data is updated.
  3. Scroll by list.onAppear or list.onChange
import SwiftUI

///A simple data model for the demo. Only stores an UUID.
struct DataModel: Identifiable, Hashable {
    let id: UUID = UUID()
    var nameUUID: String {
        id.uuidString
    }
}

struct ContentView: View {

    ///Array with some data to show
    @State private var data: [DataModel] = []

    ///Selected row
    @State private var selection: DataModel?

    // 🌟 In some situations, the initial value should be true.
    @State private var isHidingList = false

    var body: some View {
        VStack(alignment: .leading) {
            HStack {

                //Create a new array for showing in the list.
                //This array will be bigger than the last one.
                //The selection will be the last element of the array (triggering the bug)
                Button {

                    //Increment the size of the new List by 5
                    let numberElements = data.count  + 5

                    //Create a new Array of DataModel with more 5 elements that the previous one
                    let newData = (0 ..< numberElements).map { _ in  DataModel() }

                    //Select the last element of the array/list.
                    //This will make sure that the scrollTo will go to the end
                    let newSelection = newData.last

                    // 🌟 1. hide list before updating data.
                    //Update State for the new values
                    isHidingList = true
                    data = newData
                    selection = newSelection

                } label: {
                    Text("Randomize & Select Last")
                }

                Spacer()

                //Create a new array for showing in the list.
                //This array will be bigger than the last one.
                //The selection will be the a random element of the array (only triggering the bug when the element is )

                Button {
                    //Increment the size of the new List by 5
                    //If empty will start with 40 (reducing the odds of triggering the bug)
                    let numberElements = data.count == 0 ? 40 : data.count + 5

                    //Create a new Array of DataModel with more 5 elements that the previous one
                    let newData = (0 ..< numberElements).map { _ in  DataModel() }

                    //Select a random element of the array/list.
                    //This will scroll if the element is 'inside' the previous list
                    //Otherwise will crash
                    let newSelection = newData.randomElement()

                    // 🌟 1. hide list before updating data.
                    //Update State for the new values
                    isHidingList = true
                    data = newData
                    selection = newSelection

                } label: {
                    Text("Randomize & Select Random")
                }
            }
                .padding()

            // 🌟
            //MARK: ScrollViewReader and List
            if isHidingList {
                list.hidden()
            } else {
                list
            }
        }

        // 🌟 2. Show the list from DispatchQueue.main.async when the data is updated.
        .onChange(of: data) { _ in
            DispatchQueue.main.async {
                self.isHidingList = false
            }
        }
    }    

    private var list: some View {
        ScrollViewReader {
            proxy in          
            List(data, selection: $selection) {
                dataElement in              
                //Row (is only the UUID for the rows
                Text(dataElement.id.uuidString)
                    .id(dataElement)
                    .tag(dataElement)
            }

            // 🌟3.  Scroll
            .onAppear() {
                guard !isHidingList else { return }
                if let selection {
                    proxy.scrollTo(selection)
                }
            }
            // 🌟 This will  not be called in this sample.
            .onChange(of: data, perform: { newValue in
                guard !isHidingList else { return }
                if let selection {
                    proxy.scrollTo(selection)
                }
            })
        }
    }
}

I don't add sections to my list, but I do add items to the existing sections. When they tap the item at the top of the section, that section expands with additional items. I want the section to scroll to top. But because of this bug, one you expand and scroll to a section, then try to expand and scroll to another section that comes after the first one expanded, the app crashes.

To mitigate the crash, I give the list a view Id, which, for iOS 16, is based on the expanded section. Now when the expandedItemId changes, the whole list reloads and the scroll happens without crashing!

It's not perfect, but it gets the item to the top without crashing.

Now, when I tap the item again to collapse it, when the list reloads, it is positioned at the top of the list. That's why I added the scrolledToTopItemId. I set it when the item was expanded and the list scrolled to that item. Now when the item is collapsed, and the view is refreshed (because of the list view id), I can scroll to the previously selected item so that the list maintains its context.

I'm open for betters ideas. This isn't perfect, but so far its as good as I've been able to come up with

    @State private var expandedItemId: String?
    @State private var scrolledToTopItemId: String?
    private var iOS16CrashAvoidingViewId: String {
        guard #available(iOS 16, *) else { return "-view" }
        // This prevents a iOS 16 crash when to you expand a key indicator further down the screen from the 1st one you expanded. With this solution,
        // each key indicator doesn't scroll quite to the top: 1st one does, 2nd a little lower, third one yet lower, etc. But at least it doesn't crash!
        // Changing the view ID causes the whole to get reloaded when ever the expanded section changes.
        return "\(expandedItemId ?? "")-view"
    }


    var body: some View {
        ScrollViewReader { scrollView in
            List {
                statusSection(title: "not_submitted".localized, items: agent.initiatedForms)
                statusSection(title: "pending".localized,       items: agent.pendingForms)
                statusSection(title: "returned".localized,      items: agent.rejectedForms)
                statusSection(title: "success".localized,       items: agent.successForms)
            }
            .id(iOS16CrashAvoidingViewId)
            .onChange(of: expandedItemId) { newValue in
                withAnimation {
                    if let value = newValue {
                        scrollView.scrollTo(value, anchor: .top)
                        scrolledToTopItemId = value
                    }
                    DispatchQueue.main.async {
                        guard scrolledToTopItemId != nil else { return }
                        withAnimation {
                            scrollView.scrollTo(scrolledToTopItemId, anchor: .top)
                        }
                    }
                }
            }
        }
        .navigationTitle("Submitted Forms")
    }

Still getting the bug today. Really hoping I don't have to build a new List variant just to avoid this bug. I couldn't find your feedback from your link, could you double-check that it is correct?

Here are a few things you can try to troubleshoot this issue:

  1. Make sure that the ScrollViewProxy object you are calling scrollTo() on is not nil. If it is nil, this could cause a crash.
  2. Check the values of the arguments you are passing to the scrollTo() method. Make sure that the edge argument is a valid Edge value, and that the animated argument is a Bool.
  3. If you are using the scrollTo() method to scroll to a specific element in the scroll view, make sure that the element is actually within the scroll view's visible bounds. If the element is not visible, the scrollTo() method may not have any effect.
  4. If the problem persists, you may want to try wrapping the call to scrollTo() in a DispatchQueue.main.async {} block, as this can help ensure that the scroll view is updated on the main thread.

Kudos to Chat GPT 😅

Actually the bug is still here now..

Using scrollview it works without issue

More serioulsy this bug seems to be solved on iOS 16.4, as the scroll will happen and the app will not crash.

However the selection will not visually happen unless is made after the scroll has happened, by wrapping it inside a DispatchQueue.main.async call.

Does anyone has a better ideas how to do this?

Code sample follows:

///A simple data model for the demo. Only stores an UUID.
struct DataModel: Identifiable, Hashable {

    let id: UUID = UUID()

    var nameUUID: String {
        id.uuidString
    }
}

struct ContentView: View {

    ///Array with some data to show
    @State private var data: [DataModel] = []

    ///Selected row
    @State private var selection: DataModel?

    var body: some View {

        VStack(alignment: .leading) {
            HStack {

                //Create a new array for showing in the list.

                //This array will be bigger than the last one.

                //The selection will be the last element of the array (triggering the bug)

                Button {

                    //Increment the size of the new List by 5

                    let numberElements = data.count  + 5



                    //Create a new Array of DataModel with more 5 elements that the previous one

                    let newData = (0 ..< numberElements).map { _ in  DataModel() }

                    //Update STate for the new values

                    data = newData

                } label: {

                    Text("Randomize & Select Last")

                }
                Spacer()

                Text(selection?.id.uuidString ?? "no selection")

                Spacer()

                //Create a new array for showing in the list.

                //This array will be bigger than the last one.

                //The selection will be the a random element of the array (only triggering the bug when the element is )

                Button {

                    //Increment the size of the new List by 5

                    //If empty will start with 40 (reducing the odds of triggering the bug)

                    let numberElements = data.count == 0 ? 40 : data.count + 5



                    //Create a new Array of DataModel with more 5 elements that the previous one

                    let newData = (0 ..< numberElements).map { _ in  DataModel() }


                    //Select a random element of the array/list.

                    //This will scroll if the element is 'inside' the previous list

                    //Otherwise will crash

                    let newSelection = newData.randomElement()

                    //Update State for the new values

                    data = newData

                    selection = newSelection

                } label: {

                    Text("Randomize & Select Random")
                }
            }

            .padding()

            //MARK: ScrollViewReader and List

            ScrollViewReader {

                proxy in

                List(data, selection: $selection) {
                    dataElement in

                    //Row (is only the UUID for the rows

                    Text(dataElement.id.uuidString)
                        .id(dataElement)
                        .tag(dataElement)

                }

                //action that fires when data changes
                //it will scroll to the selection
                .onChange(of: data, perform: { newValue in

                    let lastValue = newValue.last

                    proxy.scrollTo(lastValue)

                    DispatchQueue.main.async {

                        //Select the last element of the array/list.
                        self.selection = lastValue
                    }

                })

            }

        }

    }

}

We have confirmed that this issue is resolved in iOS16.4.1. It took me a long time to get to this article and I wasted a lot of time.

Can anyone suggest a workaround for the versions below 16.4?

defining a new list ID at each view changes seems to solve the issue for me:

.id(UUID().uuidString)

ScrollViewProxy scrollTo will crash when scrolling outside of bounds of previous (not current) List data source/array in iPad and iOS 16 beta
 
 
Q