Issue when rearranging List item in detail view using SwiftUI Navigation View and Sorted FetchRequest
Asked Answered
V

2

9

I have a NavigationView with a list showing tasks from a CoreData FetchRequest. The FetchRequest is sorted ascending on Task.dueDate. The TaskDetail view basically consists of a TextField for the title and a date picker for the date. Changing the values in the detail view works. Though I get some weird behaviour every time I try to change the date value. The date gets changed but the Navigation view automatically exits the detail view and goes back to the list view. It only happens when I change the date in such a way that the list gets rearranged due to the sorting.

How do I prevent this weird behaviour described above??

enter image description here

//
//  ContentView.swift

import SwiftUI
import CoreData

struct ContentView: View {

    @Environment(\.managedObjectContext) var moc
    @FetchRequest(fetchRequest: Task.requestAllTasks()) var tasks: FetchedResults<Task>

    var body: some View {
        NavigationView {
            List(tasks, id: \.id) { task in
                NavigationLink(destination: TaskDetail(task: task)) {
                    Text("\(task.title)")
                }
            }.navigationBarTitle("Tasks").navigationBarItems(trailing: Button("new") {self.addTask()})
        }
    }

    func addTask() -> Void {
        let newTask = Task(context: self.moc)
        newTask.id = UUID()
        newTask.title = "task \(tasks.count)"
        newTask.dueDate = Date()
        print("created new Task")
        if (self.moc.hasChanges) {
            try? self.moc.save()
            print("saved MOC")
        }
        print(self.tasks)
    }

}

struct TaskDetail : View {

    @ObservedObject var task: Task

    var body: some View {
        VStack{
            TextField("name", text: $task.title)
            DatePicker("dueDate", selection: $task.dueDate, displayedComponents: .date)
                .labelsHidden()
        }
    }
}

//
//  Task.swift

import Foundation
import CoreData

public class Task: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var dueDate: Date
    @NSManaged public var title: String

    static func requestAllTasks() -> NSFetchRequest<Task> {
        let request: NSFetchRequest<Task> = Task.fetchRequest() as! NSFetchRequest<Task>

        let sortDescriptor = NSSortDescriptor(key: "dueDate", ascending: true)
        request.sortDescriptors = [sortDescriptor]

        return request
    }
}

To create a running minimal reproducible version of this...do:

  1. Create new Xcode "Single View App" Project. Make sure to check the CoreData checkbox.
  2. Copy the code for ContentView above and paste/replace in ContentView.swift.
  3. Create a new Swift file named Task. Copy the code for Task and paste in Task.swift.
  4. Add the entities in the ProjectName.xcdatamodeld according to the image below.
  5. Run

enter image description here

I am on Xcode 11.4.

Let me know if you need me to provide more information. Any help is much appreciated! Thanks!

Vegetable answered 3/4, 2020 at 5:23 Comment(11)
As far as I understood your operation result in deleting navigation link from stack. Low chance, but try to set NavigationLink().id(task). If do not work then change design, eg. editing temporary data object which applied to database on end editing.Sedative
what about showing us a copyable compileable code which we can reproduce instead just guess....?Oboe
@Oboe Thanks for the reply. I added some more code.Vegetable
@Sedative adding .id(task) or .id(task.id) did not make any difference. Saving to database on leaving the detail view or when pressing a save button is not an option for this app unfortunately. Also, it would become quite weird on the iPad as an iPad app will show the list and the detail view simultaneously (standard behaviour).Vegetable
I did not mean making it at UI level... anyways it needs some reproducible example to test.Sedative
@Sedative ok. I updated the code and also provided some instructions on how to get a reproducible example set up.Vegetable
i could reproduce the behaviour. i think it is because he updates the value directly in the list, and because it is sorted, it jumps out because it now has another task (and not the one from the original navigaiton detail). i think a solution could be, if you copy the task you are working on in detail and change this working copy in the database after committing the change in the detailviewOboe
@Oboe nice. Yeah, I guess it somehow looses track of the task for some time. What you suggest will probably work but then I won't get the UX I require. As I said to Asperi I need the app to update the values as the user is changing them and while the user still has the detail view open.Vegetable
ah ok, then my "solution" is not what you want :(Oboe
I believe this is a bug in iOS 13.4. If you run the code on iOS 13.3 (make sure to change the Target under Deployment Info in the Target's General settings tab), the app will work as expected. iOS 13.4 has other serious issues with Core Data, like losing the connection to the persistent store coordinator: stackoverflow.com/questions/60843114 (in my edited answer to that question you will see an animated gif showing a similar buggy behaviour you are experiencing here). Suggest you create a feedback to Apple.Mcquoid
Bug persists in iOS 14 beta 1.Mcquoid
M
5

UPDATE 2 (iOS 14 beta 3)

The issue seems to be fixed in iOS 14 beta 3: the Detail view does no longer pop when making changes that affect the sort order.


UPDATE

It seems Apple sees this as a feature, not a bug; today they replied to my feedback (FB7651251) about this issue as follows:

We would recommend using isActive and managing the push yourself using the selection binding if this is the behavior you desire. As is this is behaving correctly.

This is because the identity of the pushed view changes when you change the sort order.


As mentioned in my comment above I believe this is a bug in iOS 13.4.

A workaround could be to use a NavigationLink outside of the List and define the List rows as Buttons that

a) set the task to be edited (a new @State var selectedTask) and

b) trigger the NavigationLink to TaskDetail(task: selectedTask!).

This setup will uncouple the selected task from its position in the sorted list thus avoiding the misbehaviour caused by the re-sort potentially caused by editing the dueDate.

To achieve this:

  1. add these two @State variables to struct ContentView
    @State private var selectedTask: Task?
    @State private var linkIsActive = false
  1. update the body of struct ContentView as follows
    var body: some View {
        NavigationView {
            ZStack {
                NavigationLink(
                    destination: linkDestination(selectedTask: selectedTask),
                    isActive: self.$linkIsActive) {
                    EmptyView()
                }
                List(tasks) { task in
                    Button(action: {
                        self.selectedTask = task
                        self.linkIsActive = true
                    }) {
                        NavigationLink(destination: EmptyView()){
                            Text("\(task.title)")
                        }
                    }
                }
            }
            .navigationBarTitle("Tasks").navigationBarItems(trailing: Button("new") {self.addTask()})
        }
    }
  1. add the following struct to ContentView.swift
    struct linkDestination: View {
        let selectedTask: Task?
        var body: some View {
            return Group {
                if selectedTask != nil {
                    TaskDetail(task: selectedTask!)
                } else {
                    EmptyView()
                }
            }
        }
    }
Mcquoid answered 6/4, 2020 at 17:26 Comment(1)
I love getting yelled at by clients because Apple decides of some breaking features... How should changes like this be handled for apps in production ?Sandlin
U
1

I encountered the same problem and could not find a good solution. But I have another workaround.

I made the Fetchrequest dynamic and changed the sortdescriptor, when the link is active. This has the negative sideeffect that the list sorts itself with an animation every time you navigate back to the ContentView.

If you add the following struct for your list:

struct TaskList: View {
    @Environment(\.managedObjectContext) var moc
    @FetchRequest var tasks: FetchedResults<Task>
    @Binding var activeTaskLink: Int?

    init(activeTaskLink: Binding<Int?>, currentSortKey: String) {
        self._activeTaskLink = activeTaskLink
        self._tasks = Task.requestAllTask(withSortKey: currentSortKey)
    }

    var body: some View {
        List(tasks, id: \.id) { task in
            NavigationLink(destination: TaskDetail(task: task), tag: task.objectId.hashValue, selection: self.$activeTaskLink) {
                Text("\(task.title)")
            }
        }
    }

}

Then change the requestAllTask function in Task.swift:

static func requestAllTasks(withSortKey key: String) -> NSFetchRequest<Task> {
    let request: NSFetchRequest<Task> = Task.fetchRequest() as! NSFetchRequest<Task>

    let sortDescriptor = NSSortDescriptor(key: key, ascending: true)
    request.sortDescriptors = [sortDescriptor]

    return request
}

Then add a state for the activeTask in the ContentView

@State var activeTaskLink: Int? = nil

and change the body to

var body: some View {
    TaskList(activeTaskLink: self.$activeTaskLink, currentSortKey: self.activeNavLink != nil ? "id" : "dueDate")
    .navigationBarTitle("Tasks")
    .navigationBarItems(trailing: Button("new") {self.addTask()})
}
Unlade answered 15/4, 2020 at 10:34 Comment(2)
If I understand this correctly the list won't get sorted until you leave the detail view? But what happens when you run the app on an iPad and the master and detail view is shown at the same time? Will it not get weird`Vegetable
Yeah, you're right. This will not work correctly with master-detail view. I did not think of that.Unlade

© 2022 - 2024 — McMap. All rights reserved.