Search code examples
iosswiftcore-dataswiftui

SwiftUI List backed by FetchedResults wrongly exits out of an edit mode


The sample project is an app that allows people to track the books they've read and their genres. To display the books, we have BookList view.

struct BookList: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [SortDescriptor(\.index), SortDescriptor(\.name)],
        animation: .default
    ) private var genres: FetchedResults<Genre>
    
    @State private var filter: Genre?
    @State private var isPresentingGenreManager = false
    
    var body: some View {
        NavigationView {
            Text("BookList")
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button("Genres") {
                            isPresentingGenreManager = true
                        }
                    }
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Menu {
                            Picker("Filtering options", selection: $filter) {
#warning("This is the code that is causing the issue.")
                                ForEach(genres) { genre in
                                    let tag = genre as Genre?
                                    Text(genre.name ?? "").tag(tag)
                                }
                                
                            }
                        } label: {
                            Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
                        }
                    }
                }
                .sheet(isPresented: $isPresentingGenreManager, onDismiss: {
                    try? viewContext.save()
                }) {
                    GenreManager()
                }
        }
    }
}

If the user wants to manage the genres of their book collection, they're invited to the GenreManager view.

struct GenreManager: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [SortDescriptor(\.index), SortDescriptor(\.name)],
        animation: .default
    ) private var genres: FetchedResults<Genre>
    
    var body: some View {
        NavigationView {
            List {
                ForEach(genres) { genre in
                    HStack {
                        Text(genre.name ?? "")
                        Spacer()
                        Text("index: \(genre.index)")
                            .foregroundColor(.secondary)
                    }
                }
                .onMove(perform: moveGenres)
            }
            .navigationTitle("Genres")
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                
                ToolbarItem(placement: .primaryAction) {
                    Button(action: {
                        let genre = Genre(context: viewContext)
                        genre.name = genreNames.randomElement()!
                        do {
                            try viewContext.save()
                        } catch {
                            viewContext.rollback()
                        }
                    }, label: { Label("Add", systemImage: "plus") })
                }
            }
        }
    }
    
    // The function to move items in a list backed by Core Data
    // Reference: https://stackoverflow.com/questions/59742218/swiftui-reorder-coredata-objects-in-list
    private func moveGenres(from source: IndexSet, to destination: Int) {
        var revisedItems: [Genre] = genres.map { $0 }
        revisedItems.move(fromOffsets: source, toOffset: destination)
        for reverseIndex in stride(from: revisedItems.count - 1, through: 0, by: -1) {
            revisedItems[reverseIndex].index = Int64(reverseIndex)
        }
    }
}

Genre is a simple Core Data entity that has a name of type String and an index to persist the order in a list of type Int64. I want to give the users an option to reorder the list using a simple algorithm that I took from here. So far so good.

Now I want to add a filter to the BookList so users can filter their list based on the genres that they have in place. But as soon as I add this piece of code an obscure issue presents itself.

ForEach(genres) { genre in
    let tag = genre as Genre?
    Text(genre.name ?? "").tag(tag)
}

As I now try to reorder the list in GenreManager, as soon as I move a single item, the list bails out of an edit mode and EditButton is left in an incorrect state.

I'm expecting the user to be able to comfortably edit the list without interruption caused by the list exiting out of edit mode arbitrarily.

I've tried to share fetched results from BookList superview to GenreManager subview as seen here, but that does not solve the issue. What does solve the issue is removing this code from moveGenres function.

for reverseIndex in stride(from: revisedItems.count - 1, through: 0, by: -1) {
    revisedItems[reverseIndex].index = Int64(reverseIndex)
}

But then the user isn't able to edit the list order any more. I'm guessing it's possible to defer the reindexing of the list to the point where the user quits editing mode. But I wasn't able to successfully observe edit mode changes in onChange as suggested here. And I consider building a custom edit button as a last resort since it's already provided as a native solution by Apple.

What is the root cause of this issue and what is the best way to fix it?


Solution

  • I noticed that after you move a row if you drag the sheet down slightly, the table cells re-enter edit mode to match the edit button. If you drag the sheet down another time the cells then exit edit mode! This makes me think there is a bug when List is inside of a sheet, which I reported as FB9969447. I believe the reason this also happens in your test project is because GenreManager() is init when a move is done, which the reason for is explained below. As a workaround you could use fullScreenCover until sheet is fixed. The editMode that EditButton and List use is part of the environment and sheets have always behaved a bit weird with environment vars so that is probably the reason for the bug. You could also attempt to re-architect your View structs so that GenreManager() is not init when genres is changed but that is probably futile given the bug also occurs when the sheet is dragged.

    SwiftUI features dependency tracking so if you don't call ForEach(genres) it no longer runs body when genres changes. So the problem isn't to do with the Picker itself, just the fact that body is being called in BookList when a move is made causing a change to genres. At the top of body use let _ = Self._printChanges() you'll see debug output that tells you the reason for running body. FYI there currently a bug where a View init with @FetchRequest (even with same params) always has body called because of @self changed - it's because that struct inits a new object instead of using @StateObject so SwiftUI always thinks the View has changed FB9956812.

    So I think what is happening is when the genre list is changed by the move, BookList calls body (because genres is used in the Picker) and it inits a GenreManager.

    Here is a SwiftUI tip, it's best to restructure your Views so you aren't initing too many layers of things that don't use the data that SwiftUI calls body when it detects changes. I.e. in your BookList when genres changes and body is called you create a NavigationView, .toolbar, ToolBarItem, Menu and it isn't until Picker that you actually use the genres. It's more efficient to make a struct that creates genres and uses it immediately in body. E.g. you could make a GenrePicker struct that does the FetchRequest and calls Picker first, pass in a binding to the selection if you need it outside.