stardust4891
stardust4891

Reputation: 2550

FetchedResult views do not update after navigating away from view & back

SwiftUI FetchedResult views fail to update when you navigate away from them and return.

I have a simple todo list app I've created as an example. This app consists of 2 entities:

First, here are my core data models:

TodoList Entity

TodoItem Entity

For the entities, I am using Class Definition in CodeGen.

There are only 4 small views I am using in this example.

TodoListView:

struct TodoListView: View {
    @Environment(\.managedObjectContext) var managedObjectContext
    @FetchRequest(
        entity: TodoList.entity(),
        sortDescriptors: []
    ) var todoLists: FetchedResults<TodoList>
    @State var todoListAdd: Bool = false

    var body: some View {
        NavigationView {
            List {
                ForEach(todoLists, id: \.self) { todoList in
                    NavigationLink(destination: TodoItemView(todoList: todoList), label: {
                        Text(todoList.title ?? "")
                    })
                }
            }
            .navigationBarTitle("Todo Lists")
            .navigationBarItems(trailing:
                Button(action: {
                    self.todoListAdd.toggle()
                }, label: {
                    Text("Add")
                })
                .sheet(isPresented: $todoListAdd, content: {
                    TodoListAdd().environment(\.managedObjectContext, self.managedObjectContext)
                })
            )
        }
    }
}

This simply fetches all TodoList(s) and spits them out in a list. There is a button in the navigation bar which allows for adding new todo lists.

TodoListAdd:

struct TodoListAdd: View {
    @Environment(\.presentationMode) var presentationMode
    @Environment(\.managedObjectContext) var managedObjectContext
    @State var todoListTitle: String = ""

    var body: some View {
        NavigationView {
            Form {
                TextField("Title", text: $todoListTitle)

                Button(action: {
                    self.saveTodoList()
                    self.presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("Save")
                })

                Button(action: {
                    self.presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("Cancel")
                })
            }
            .navigationBarTitle("Add Todo List")
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    func saveTodoList() {
        let todoList = TodoList(context: managedObjectContext)
        todoList.title = todoListTitle

        do { try managedObjectContext.save() }
        catch { print(error) }
    }
}

This simply saves a new todo list and then dismisses the modal.

TodoItemView:

struct TodoItemView: View {
    @Environment(\.managedObjectContext) var managedObjectContext
    var todoList: TodoList
    @FetchRequest var todoItems: FetchedResults<TodoItem>
    @State var todoItemAdd: Bool = false

    init(todoList: TodoList) {
        self.todoList = todoList
        self._todoItems = FetchRequest(
            entity: TodoItem.entity(),
            sortDescriptors: [],
            predicate: NSPredicate(format: "todoList == %@", todoList)
        )
    }

    var body: some View {
        List {
            ForEach(todoItems, id: \.self) { todoItem in
                Button(action: {
                    self.checkTodoItem(todoItem: todoItem)
                }, label: {
                    HStack {
                        Image(systemName: todoItem.checked ? "checkmark.circle" : "circle")
                        Text(todoItem.title ?? "")
                    }
                })
            }
        }
        .navigationBarTitle(todoList.title ?? "")
        .navigationBarItems(trailing:
            Button(action: {
                self.todoItemAdd.toggle()
            }, label: {
                Text("Add")
            })
            .sheet(isPresented: $todoItemAdd, content: {
                TodoItemAdd(todoList: self.todoList).environment(\.managedObjectContext, self.managedObjectContext)
            })
        )
    }

    func checkTodoItem(todoItem: TodoItem) {
        todoItem.checked = !todoItem.checked

        do { try managedObjectContext.save() }
        catch { print(error) }
    }
}

This view fetches all of the TodoItem(s) that belong to the TodoList that was tapped. This is where the problem is occurring. I'm not sure if it is because of my use of init() here, but there is a bug. When you first enter this view, you can tap a todo item in order to "check" it and the changes show up in the view immediately. However, when you navigate to a different TodoItemView for a different TodoList and back, the views no longer update when tapped. The checkmark image does not show up, and you need to leave that view and then re-enter it in order for said changes to actually appear.

TodoItemAdd:

struct TodoItemAdd: View {
    @Environment(\.presentationMode) var presentationMode
    @Environment(\.managedObjectContext) var managedObjectContext
    var todoList: TodoList
    @State var todoItemTitle: String = ""

    var body: some View {
        NavigationView {
            Form {
                TextField("Title", text: $todoItemTitle)

                Button(action: {
                    self.saveTodoItem()
                    self.presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("Save")
                })

                Button(action: {
                    self.presentationMode.wrappedValue.dismiss()
                }, label: {
                    Text("Cancel")
                })
            }
            .navigationBarTitle("Add Todo Item")
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    func saveTodoItem() {
        let todoItem = TodoItem(context: managedObjectContext)
        todoItem.title = todoItemTitle
        todoItem.todoList = todoList

        do { try managedObjectContext.save() }
        catch { print(error) }
    }
}

This simply allows the user to add a new todo item.

As I mentioned above, the views stop updating automatically when you leave and re-enter the TodoItemView. Here is a recording of this behaviour:

https://i.imgur.com/q3ceNb1.mp4

What exactly am I doing wrong here? If I'm not supposed to use init() because views in navigation links are initialized before they even appear, then what is the proper implementation?

Upvotes: 1

Views: 482

Answers (1)

stardust4891
stardust4891

Reputation: 2550

Found the solution after hours of googling various different phrases of the issue: https://stackoverflow.com/a/58381982/10688806

You must use a "lazy view".

Code:

struct LazyView<Content: View>: View {
    let build: () -> Content

    init(_ build: @autoclosure @escaping () -> Content) {
        self.build = build
    }

    var body: Content {
        build()
    }
}

Usage:

NavigationLink(destination: LazyView(TodoItemView(todoList: todoList)), label: {
    Text(todoList.title ?? "")
})

Upvotes: 1

Related Questions