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?
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.