sid
sid

Reputation: 158

Filtering a @Binding array var in a ForEach in SwiftUI returns values based on unfiltered array

I'm a Windows C# developer, new to iOS/SwiftUI development and I think I've worked myself into a hole here.

I have a view with a @Binding variable:

struct DetailView: View {
    @Binding var project: Project

The project is an object which contains an array of Tasks. I am looping through the tasks of the project to display its name and a toggle whose state is determined by the Task's variable, isComplete.

    ForEach(filteredTasks.indices, id: \.self) { idx in
        HStack {
            Text(filteredTasks[idx].phase)
                .font(.caption)
            Spacer()
            Text(filteredTasks[idx].name)
            Spacer()
            Toggle("", isOn: self.$filteredTasks[idx].isComplete)
        }
    }
}

This took quite a while for me to get to this piece of code, and I found that I had to follow an example with the 'indices' option to get the toggle to work on each Task individually, and to make sure that its isComplete value was saved.

Next, I wanted to filter the list of Tasks based on a Task variable, phase, which has values of Planning, Construction, or Final. So I created 4 buttons (one for each phase, and then an 'All Tasks' to get back to the full, unfiltered list), and after a lot of trial and error (creating filtered arrays that no longer were bound correctly, etc., etc.) I tried this, basically working only with the original array.

            List {
                ForEach(project.tasks.filter({ $0.phase.contains(filterValue) }).indices, id: \.self) { idx in
                    HStack {
                        Text(project.tasks[idx].phase)
                            .font(.caption)
                        Spacer()
                        Text(project.tasks[idx].name)
                        Spacer()
                        Toggle("", isOn: self.$project.tasks[idx].isComplete)
                    }
                }
            }

And of course, this seemed to work because I can do a test:

    func CreateTestArray() {
        let testFilterArray = project.tasks.filter({ $0.phase.contains(filterValue) })
    }

And that will give me the filtered list I want. However, in my ForEach view, it's not working correctly and I'm not sure how to work around it.

For example, I have 128 tasks, 10 of which have a value of 'Final' and when I use a button setting the filterValue to Final, the testFilterArray actually contains the correct 10 tasks - but in the ForEach view I'm getting the first ten tasks in the original array (which are of the type 'Planning' - the original array is sorted by Planning/Construction/Final); obviously the ForEach, in spite of the filter statement, is working on the original array. The Planning button sends the filterValue = "Planning", and I get the correct results because the filter returns 0-19 indices for the 20 Planning tasks I have in the original array, and since they're first in the original array, it 'appears' that the Planning filter is working correctly, tho in actually it's just by chance that it works, if the array were sorted differently it would not.

Any ideas how I can approach this so that I can actually filter on this array, display the isComplete toggle correctly for each item in the array, as well as update the toggle state dynamically? I feel like I need to start from scratch once again here because I've let these constraints work me into a tiny Swift corner.

Thanks!

Update: Thank you, @jnpdx, for your quick response - and I should definitely have included objects (which I list below). However, looking back over my object definitions, I wonder if I've made an even more basic error in managing the objects which is why I've gotten boxed in to the situation I have (i.e., in earlier iterations I'd attempted some of your suggestion). At any rate, my published object is 'projects', which is a list of projects I pass to the project list view, and then that view passes a single project to a project view, and then that view lists the tasks in that particular project.

I feel like your answer is pointing me in the right decision, I just need to back back up and look at those object definitions/management and see how to get to a situation where a straightforward solution is possible.

Task:

 struct Task:  Identifiable, Codable {
    let id: UUID
    var phase: String
    var category: String
    var name: String
    var isComplete: Bool
    
    init(id: UUID = UUID(), phase: String, category: String, name: String, isComplete: Bool) {
        self.id = id
        self.phase = phase
        self.category = category
        self.name = name
        self.isComplete = isComplete
    }
}

The Project:

 struct Project: Identifiable, Codable {
    
    var id: UUID
    var name: String
    var type: String
    var tasks: [Task]
    var isComplete: Bool

    
    init(id: UUID = UUID(), name: String, type: String, tasks: [Task] = [], isComplete: Bool) {
        self.id = id
        self.name = name
        self.type = type
        self.tasks = tasks
        self.isComplete = isComplete
    }

}

and the Project model:

 class ProjectData: ObservableObject {
    
    // code to access the json file is here
    
    // An accessible list of projects from the saved file
    @Published var projects: [Project] = []
    
    // load and save functions follow

Update: Thanks, @jnpdx, your solution worked after making, as you said I would need to, the tweaks to get it to function within my particular model design. Here are the snippets that finally worked in my case.

In my view:

 List {
  ForEach(project.tasks.filter({ $0.phase.contains(filterValue) })) { task in
    HStack {
      Text(task.name)
      Toggle("", isOn: self.makeBinding(item: task))
      }
   }
}

And the called function:

     func makeBinding(item: Task) -> Binding<Bool> {
        let i = self.project.tasks.firstIndex { $0.id == item.id }!
        return .init(
            get: { self.project.tasks[i].isComplete },
            set: { self.project.tasks[i].isComplete = $0 }
        )
    }

Upvotes: 6

Views: 3184

Answers (1)

jnpdx
jnpdx

Reputation: 52367

Let's look at the following line from your code:

ForEach(project.tasks.filter({ $0.phase.contains(filterValue) }).indices, id: \.self) { idx in

In the first part, you filter tasks and then ask for the indices. My suspicion is that you're hoping it would return something like [1, 5, 10, 11, 12], meaning their original positions in the array. But, in reality, you're going to get a contiguous array like [0,1,2,3,4] because it's giving you indices from the newly-created array (the result of filter).

There are a couple of ways to solve this, which also relate to the previous ForEach that you had.

It's more idiomatic to do ForEach and iterate over structs/objects rather than indices. You don't show what Task is made up of, but let's say it's this:

struct Task : Hashable {
  var id = UUID()
  var name: String
  var phrase: String
  var isComplete: Bool
}

To iterate on it, you could do:

ForEach(task, id: \.id) { task in 
  Text(task.name)
  Toggle("Done?", isOn: project.taskCompletedBinding(id: task.id)) //explained later
}

I asked about the type of Project in my comment because I'm not totally clear why it's a @Binding. It seems like maybe it's an object? If it's a view model, which would be nice, you can handle your Toggle logic there. Something like:

class Project : ObservableObject {
    @Published var tasks : [Task] = [Task(name: "1", phrase: "phase", isComplete: false),Task(name: "2", phrase: "phase", isComplete: true),Task(name: "3", phrase: "phase2", isComplete: false)]

    var completedTasks : [Task] {
        return tasks.filter { $0.isComplete }
    }
    
    func taskCompletedBinding(id: UUID) -> Binding<Bool> {
        Binding<Bool>(get: {
            self.tasks.first(where: { $0.id == id})?.isComplete ?? false
        }, set: { newValue in
            self.tasks = self.tasks.map { t in
                if t.id == id {
                    var tCopy = t
                    tCopy.isComplete = newValue
                    return tCopy
                } else {
                    return t
                }
            }
        })
    }
}

And you can test that it works doing this:

struct ContentView: View {
    @ObservedObject var project = Project()
    
    var body: some View {
        ForEach(project.tasks, id: \.id) { task in
            Text(task.name)
            Toggle("Done?", isOn: project.taskCompletedBinding(id: task.id))
        }
    }
}

If Project is a struct and not an object, it might be good to wrap it in an ObservableObject view model like I did above.

Upvotes: 3

Related Questions