0

I'm trying to make a list of Core Data items in SwiftUI where adding new item will also trigger scroll to last item.

Here is a code I have. It's based on sample Core Data app in Xcode. One Entity: Item with one attribute: timestamp.

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>
    
    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                ScrollView {
                    ForEach(items, id: \.self) { item in
                        Text("Some item")
                            .padding()
                            .id(item.objectID)
                    }
                    .onDelete(perform: deleteItems)
                }
                .onChange(of: items.count) { _ in
//                  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                        withAnimation {
                            proxy.scrollTo(items.last?.objectID)
                        }
//                  }
                }
                .toolbar {
                    ToolbarItem {
                        Button(action: addItem) {
                            Label("Add Item", systemImage: "plus")
                        }
                    }
                }
            }
            Text("Select an item")
        }
    }
    
    private func addItem() {
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
            
            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)
            
            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}

It works, but I'm getting errors like this in Xcode:

2022-02-06 16:28:47.280984+0700 scroll[901:2894134] [error] precondition failure: invalid graph update (access from multiple threads?)

How to fix this? My guess is that it's something to do with concurrency. When new item is created it gets new id. Number of item changes. Core Data saves data. UI scrolls with animation. But maybe when scroll is triggered, swift is still not sure which item is last on the list?

So, things I've tried:

If you uncomment

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {

And closing bracket as well - I am not getting an error when I'm adding one item at a time. But if I tap + few times, I'm getting errors again.

Another surprising thing is that if I change ScrollView to List, I am not getting errors at all. Even without DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {

mallow
  • 2,368
  • 2
  • 22
  • 63
  • Try changing `ForEach(items, id: \.self) { item in` to `ForEach(items) { item in`. Core Data managed objects are identifiable, and using `.self` as identity in a `ForEach` can cause crashes when adding or deleting. – Yrb Feb 06 '22 at 16:17
  • Thanks for advice @Yrb, but it didn't change anything about this error I'me getting. – mallow Feb 07 '22 at 03:40
  • Then this needs a [Minimal, Reproducible Example (MRE)](https://stackoverflow.com/help/minimal-reproducible-example). – Yrb Feb 07 '22 at 12:31
  • @Yrb I think my code is exactly this. It is minimal example, it is full code needed to reproduce the issue. Error happens every time as I have described. How could I make it more simple? – mallow Feb 07 '22 at 13:06
  • I missed that you said it was based on the initial CD project. Let me play with it. – Yrb Feb 07 '22 at 14:01
  • @Yrb Cool. Thanks. If you start new iOS Core Dat project in Xcode and paste my code to ContentView, you’ll have everything the same I have. – mallow Feb 07 '22 at 14:04

2 Answers2

0

It appears that the ScrollView wants a VStack around the ForEach AND that .scrollTo() does not like an optional AND you need the DispatchQueue.main.asyncAfter(deadline:) with an actual delay. I found some references to fixing it for the VStack, but no explanation as to why it works. However, I think these are independent problems that cause the same error, so all fixes had to be made.

struct ContentView: View {

    // Nothing changed here
    
    var body: some View {
        NavigationView {
            ScrollViewReader { proxy in
                ScrollView {
                    VStack {
                        ForEach(items, id: \.self) { item in
                            Text("Some item")
                                .padding()
                                .id(item.objectID)
                        }
                        .onDelete(perform: deleteItems)
                    }
                }
                .onChange(of: items.count) { _ in
                    if let last = items.last {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                            withAnimation {
                                proxy.scrollTo(last.objectID)
                            }
                        }
                    }
                }

    // Nothing changed here
    
}
Yrb
  • 8,103
  • 2
  • 14
  • 44
0

I think using Combine Framework with debounce(for:scheduler:options:) Operator should solve your problem.

I had the same problem with my code and following solution works great.

import SwiftUI
import Combine

struct ChatView: View {
@StateObject var messagesManager: MessagesManager
@State private var cancellables: Set<AnyCancellable> = []

var body: some View {
    VStack {
        VStack {
            TitleRow()
            
            ScrollViewReader { proxy in
                ScrollView {
                VStack {
                    ForEach(messagesManager.messages) { message in
                        MessageBubble(message: message)
                    }
                }
                .padding(.top, 10)
                .background(.white)
                .cornerRadius(30, corners: [.topLeft, .topRight]) // Custom cornerRadius modifier added in Extensions file
                .onChange(of: messagesManager.lastMessageId) { id in
                    // When the lastMessageId changes, scroll to the bottom of the conversation
                    messagesManager.$lastMessageId.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
                        .sink { _ in
                            withAnimation {
                                proxy.scrollTo(id, anchor: .bottom)
                            }
                        }.store(in: &cancellables)
                    
                    }
                }
            }
        }

        .background(Color("blue-curious"))
        
        MessageField()
            .environmentObject(messagesManager)
    }.onTapGesture {
        dissmissKeybord()
    }
}

func dissmissKeybord() {
    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}

}