1

I implemented a Grouped table in SwiftUI using an ObservableObject as the data source. A nested ForEach is used to generate each section. An EditMode() button toggles that Environment property. In Edit mode, when delete action is completed, the deleted row (unexpectedly)remains on screen. (Even though the object has been removed from the data source array.) When the user returns to normal viewing mode the object is belatedly removed from the table.

enter image description here

In order to try to track down bug:

  • Data source objects conform to Hashable, Identifiable, and Equatable.

  • A simple delete action is implemented (which is to delete the first object in the @Published property)

  • Data source / view model is stored in an @EnvironmentData object

So the simple question is what did I do wrong that would cause SwiftUI not to immediately reflect delete action in EditMode on a very simple (I think) grouped (by Section) List?

struct ContentView: View {

    @EnvironmentObject var vm: AppData

    var body: some View {

        NavigationView {

            List {
                ForEach(vm.folderSource) { (folder: Folder)   in
                    return Section(header: Text(folder.title)) {
                        //this is where problem originates. When I drop in a new full-fledged View struct, UI updates stop working properly when .onDelete is called from this nested View
                        FolderView(folder: folder)
                    }
                }
            }.listStyle(GroupedListStyle())
                .navigationBarItems(trailing: EditButton())
        }
    }
}

struct FolderView: View {

    var folder: Folder

    @EnvironmentObject var vm: AppData


    var body: some View {
        //I'm using a dedicated View inside an outer ForEach loop to be able to access a data-source for each dynamic view.

        let associatedProjects = vm.projects.filter{$0.folder == folder}

        return ForEach(associatedProjects) { (project: Project) in
            Text(project.title.uppercased())
            // dumbed-down delete, to eliminate other possible issues preventing accurate Dynamic View updates
        }.onDelete{index in self.vm.delete()}
    }
}


//view model
class AppData: ObservableObject {



    let folderSource: [Folder]
    @Published var projects: [Project]

    func delete() {
        //dumbed-down static delete call to try to find ui bug
        self.projects.remove(at: 0)
        //
    }


    init() {
        let folders = [Folder(title: "folder1", displayOrder: 0), Folder(title: "folder2", displayOrder: 1), Folder(title: "folder3", displayOrder: 2)  ]

        self.folderSource = folders


        self.projects = {

            var tempArray = [Project]()
            tempArray.append(Project(title: "project 0", displayOrder: 0, folder: folders[0]  ))
            tempArray.append(Project(title: "project 1", displayOrder: 1, folder: folders[0]  ))
            tempArray.append(Project(title: "project 2", displayOrder: 2, folder: folders[0]  ))


            tempArray.append(Project(title: "project 3", displayOrder: 0, folder: folders[1]  ))
            tempArray.append(Project(title: "project 4", displayOrder: 1, folder: folders[1]  ))
            tempArray.append(Project(title: "project 5", displayOrder: 2, folder: folders[1]  ))


            tempArray.append(Project(title: "project 6", displayOrder: 0, folder: folders[2]  ))
            tempArray.append(Project(title: "project 7", displayOrder: 1, folder: folders[2]  ))
            tempArray.append(Project(title: "project 8", displayOrder: 2, folder: folders[2]  ))

            return tempArray
        }()

    }

}


//child entity many-to-one (Folder)
class Project: Hashable, Equatable, Identifiable {

    let id = UUID()
    let title: String
    let displayOrder: Int
    let folder: Folder

    init(title: String, displayOrder: Int, folder: Folder) {
        self.title = title
        self.displayOrder = displayOrder
        self.folder = folder
    }

    static func == (lhs: Project, rhs: Project) -> Bool {
        lhs.id == rhs.id

    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

//parent entity: Many Projects have one Folder
class Folder: Hashable, Equatable, Identifiable {

    let id = UUID()
    let title: String
    let displayOrder: Int


    init(title: String, displayOrder: Int) {
        self.title = title
        self.displayOrder = displayOrder
    }

    //make Equatable
    static func == (lhs: Folder, rhs: Folder) -> Bool {
        lhs.id == rhs.id
    }

    //make Hashable
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

And in SceneDelegate.swift

 // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView().environmentObject(AppData())

Small Talk
  • 747
  • 1
  • 6
  • 15
  • I'm just thinking out loud - my deletes work properly. Your list is somehow connected to `vm` which is `AppData`, correct? (Your code is rather dense.) But the only `onDelete` I see involves a `FolderView` inside a closure and appears to use `Project`? Or is it `Folder`? My suggestion is to (1) get a truly simple List working with a delete from you model, then (2) slowly add in these other things to your model (and maybe your list group) to see *specifically* what your issue is. Good luck! –  Aug 23 '19 at 14:23
  • Thanks for responding. I actually built step-by-step, after getting a flat List working with no issues. Data source (@Published property) is updating real-time as expected. But UI is not, until user clicks Done. Which is unexpected behavior. Entire codebase is included above, excepting Scene delegate call to launch ContentView. – Small Talk Aug 23 '19 at 17:59

2 Answers2

3

I deleted my previous answer, since as you noted, although it worked it was just pure coincidence.

Here you have another work around. It basically works by not encapsulating the second ForEach. So far I found that encapsulating is a good tool for evading certain bugs. In this case it is the opposite!

struct ContentView: View {

    @EnvironmentObject var vm: AppData

    var body: some View {

        NavigationView {

            List {
                ForEach(vm.folderSource) { (folder: Folder)   in
                    Section(header: Text(folder.title)) {
//                        FolderView(folder: folder)
                        ForEach(self.vm.projects.filter{$0.folder == folder}) { (project: Project) in
                            Text(project.title.uppercased())
                        }.onDelete{index in
                            self.vm.delete()
                        }
                    }
                }
            }
            .listStyle(GroupedListStyle())
            .navigationBarItems(trailing: EditButton())
        }
    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • THIS COMPLETELY WORKS, NO CAVEATS. ACCEPTED ANSWER. BUG FOUND AND WORKED-AROUND (without hacks.) There is no other explanation except that nesting a named / encapsulated View struct in an inner ForEach loop breaks predictable UI updates -- when attempting to implement .onMove and .onDelete modifiers. It's a bug in SwiftUI that will hopefully get resolved ASAP. I really appreciate you working this out. – Small Talk Aug 25 '19 at 05:48
  • Glad it worked! Let's hope it gets fixed before the GM ;-) Cheers. – kontiki Aug 25 '19 at 05:50
  • Simple demo project is here https://github.com/taskcruncher/ForEachBug.git – Small Talk Sep 21 '19 at 16:54
0

So in a strange twist, @kontiki's (helpful) solution worked by pure coincidence. It turns out that simply adding an (unused) function-type variable to FolderView as View property parameter and using that function parameter to set a State/Environment-type wrapped variable in init method solves the issue. Which is inexplicable.

WORKS (add function parameter that sets wrapped state property ['vm' is the variable name for the AppData view model, which conforms to ObservableObject]. See above.)

FolderView(folder: folder, onDelete: {self.vm.hello = "ui update bug goes away, even though this function not called"}) //function sets EnvironmentObject-type property

DOESN'T WORK (add function parameter that does NOT set wrapped state property

FolderView(folder: folder, onDelete: {print("ui update bug still here")})

DOESN'T WORK (add non-function parameter)

FolderView(folder: folder, unusedString: "ui update bug still here") 

I filed a bug report, since (to my mind) this is unexpected behavior.

Small Talk
  • 747
  • 1
  • 6
  • 15