9

I am trying to scroll to a newly appended view in a SwiftUI List using ScrollViewReader but keep crashing with EXC_BAD_INSTRUCTION in scrollTo(_:) after adding a few items. I am using Xcode 14.0.1 and iOS 16.0 simulator.

Here is a minimal demo that exhibits the issue:

struct ContentView: View {

    @State var items = [Item]()
    @State var scrollItem: UUID? = nil
    
    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                List {
                    ForEach(items) { item in
                        Text(item.id.uuidString)
                            .id(item.id)
                    }
                }
                .listStyle(.inset)
                .onChange(of: scrollItem) { newValue in
                    proxy.scrollTo(newValue)
                }
            }
            .navigationTitle("List Demo")
            .toolbar {
                Button("Add") {
                    addItem()
                }
            }
        }
    }

    func addItem() {
        items.append(Item())
        scrollItem = items.last?.id
    }
}

struct Item: Identifiable {
    let id = UUID()
}

I can get past the issue using a ScrollView instead of a List, but I would like to use the native swipe-to-delete functionality in the real project.

2
  • Instead of list try : ScrollView / LazyVStack and put the ScrollViewReader in a VStack Commented Oct 9, 2022 at 18:57
  • 2
    That is my current work-around, but I was hoping to use a List for the baked in editing functionality and swipe-to-delete. Commented Oct 10, 2022 at 16:02

2 Answers 2

2

List is not supported well in ScrollViewReader. See this thread.

This solution is ugly, but works. The bad thing is that list blinks when you add a new item. I used one of the ideas from the thread above.

import SwiftUI

struct ContentView: View {

    @State var items = [Item]()
    @State var scrollItem: UUID? = nil
    @State var isHidingList = false

    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                if isHidingList {
                    list.hidden()
                } else {
                    list
                }
            }
            .onChange(of: scrollItem) { _ in
                DispatchQueue.main.async {
                    self.isHidingList = false
                }
            }
            .navigationTitle("List Demo")
            .toolbar {
                Button("Add") {
                    addItem()
                }
            }
        }
    }

    var list: some View {
        ScrollViewReader { proxy in
            List {
                ForEach(items) { item in
                    Text(item.id.uuidString)
                        .id(item.id)
                }
            }
            .listStyle(.inset)
            .onChange(of: scrollItem) { newValue in
                guard !isHidingList else { return }
                proxy.scrollTo(newValue)
            }
            .onAppear() {
                guard !isHidingList else { return }
                proxy.scrollTo(scrollItem)
            }
        }
    }

    func addItem() {
        isHidingList = true
        items.append(Item())
        scrollItem = items.last?.id
    }

}

struct Item: Identifiable {
    let id = UUID()
}
Sign up to request clarification or add additional context in comments.

3 Comments

I’m going to file a radar on this issue as per the link you posted above, thanks!
Filed as FB11672199
Nice, thank you. I will be watching.
0

Here's a version using the introspect library to find the underlying UIScrollView and scrolling it directly.

Two different .introspect() modifiers are used because in iOS 16 List is implemented with UICollectionView whereas in earlier versions UITableView is used.

There's no flickering / forced rendering using this method as it interacts with the .setContentOffset() directly.

struct ScrollListOnChangeIos16: View {
    @State private var items: [String]
    
    init() {
        _items = State(initialValue: Array(0...25).map { "Placeholder \($0)" } )
    }

    // The .introspectX view modifiers will populate scroller
    // they are actually UITableView or UICollectionView which both decend from UIScrollView
    // https://github.com/siteline/SwiftUI-Introspect/releases/tag/0.1.4
    @State private var scroller: UIScrollView?
    
    func scrollToTop() {
        scroller?.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
    }
    
    func scrollToBottom() {
        // Making this async seems to make scroll more consistent to happen after
        // items has been updated. *shrug?*
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {        
            guard let scroller = self.scroller else { return }
            let yOffset = scroller.contentSize.height - scroller.frame.height
            if yOffset < 0 { return }
            scroller.setContentOffset(CGPoint(x: 0, y: yOffset), animated: true)
        }
    }
        
    var body: some View {
        VStack {
            
            HStack {
                Button("Top") {
                    scrollToTop()
                }
                Spacer()
                
                Button("Add Item") {
                    items.append(Date.timeIntervalSinceReferenceDate.description)
                    scrollToBottom()
                }.buttonStyle(.borderedProminent)

                Spacer()
                Button("Bottom") {
                    scrollToBottom()
                }
            }.padding()

            // The source of all my pain ...
            List{
                ForEach(items, id: \.self) {
                    Text($0)
                }
                .onDelete { offsets in
                    items.remove(atOffsets: offsets)
                }
            }
            .listStyle(.plain)
            .padding(.bottom, 50)
            
        }
            
        /* in iOS 16 List is backed by UICollectionView, no out of the box .introspectMethod ... nbd. */
        .introspect(selector: TargetViewSelector.ancestorOrSiblingContaining, customize: { (collectionView: UICollectionView) in
            guard #available(iOS 16, *) else { return }
            self.scroller = collectionView
        })

        /* in iOS 15 List is backed by UITableView ... */
        .introspectTableView(customize: { tableView in
            guard #available(iOS 15, *) else { return }
            self.scroller = tableView
        })
    }
}

2 Comments

Thanks for this, I’m going to give it a try as well. I was hoping to avoid adding another library, but it may be worth it to avoid the flickering list.
The question was how to scroll to .id(item.id) like this proxy.scrollTo(scrollItemId), but you are scrolling to the top and bottom of the list, which absolutely can't help here and not answering the question.

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.