all.herranz
all.herranz

Reputation: 153

Filter @Published array in SwiftUI List removes elements in list

I am trying to implement a list functionality similar to to Handling User Input example, the interface shows a list the user can filter depending on boolean values. I want to add the following differences from the example:

I've tried many approaches without success one of them:

    class TaskListViewModel : ObservableObject  {
    
        private var cancelables = Set<AnyCancellable>()
    
        private var allTasks: [Task] =
            [ Task(id: "1",name: "Task1", description: "Description", done: false),
              Task(id: "2",name: "Task2", description: "Description", done: false)]
    
        @Published var showNotDoneOnly = false
    
        @Published var filterdTasks: [Task] = []

        init() {
        
            filterdTasks = allTasks

            $showNotDoneOnly.map { notDoneOnly in
                if notDoneOnly {
                    return self.filterdTasks.filter { task in
                        !task.done
                    }
                }
                return self.filterdTasks
            }.assign(to: \.filterdTasks, on: self)
            .store(in: &cancelables)
        }
    }
struct TaskListView: View {
    
    @ObservedObject private var taskListViewModel = TaskListViewModel()
        
    var body: some View {
        
        NavigationView {
            VStack {
                Toggle(isOn: $taskListViewModel.showNotDoneOnly) {
                    Text("Undone only")
                }.padding()
                List {
                    ForEach(taskListViewModel.filterdTasks.indices, id: \.self) { idx in
                        TaskRow(task: $taskListViewModel.filterdTasks[idx])
                    }
                }
            }.navigationBarTitle(Text("Tasks"))
        }
    }
}
struct TaskRow: View {
    
    @Binding var task: Task

    var body: some View {
        HStack {
            Text(task.name)
            Spacer()
            Toggle("", isOn: $task.done )
        }
    }

}

With this approach the list is filtered when the user enable the filter but when it is disabled the list lose the previously filtered elements. If I change the code to restore the filter elements like this:

    $showNotDoneOnly.map { notDoneOnly in
        if notDoneOnly {
            return self.filterdTasks.filter { task in
                !task.done
            }
        }
        return self.allTasks
    }.assign(to: \.filterdTasks, on: self)

The list lose the edited elements.

I've also tried making allTask property to a @Published dictionary by without success. Any idea on how to implement this? Is ther any better approach to do this in SwiftUi?

Thanks

Upvotes: 2

Views: 1517

Answers (2)

all.herranz
all.herranz

Reputation: 153

Finally I've managed to implement the list functionality whith the conditions previously listed. Based on Cenk Bilgen answer:

  • ListView:
struct TaskListView: View {
    
    @ObservedObject private var viewModel = TaskListViewModel()

    var body: some View {
        
        NavigationView {
            VStack {
                Toggle(isOn: $viewModel.filterDone) {
                    Text("Filter done")
                }.padding()
                List {
                    ForEach(viewModel.filter(), id: \.self) { task in
                        TaskRow(task: task)
                    }
                }
            }.navigationBarTitle(Text("Tasks"))
        }.onAppear {
            viewModel.fetchTasks()
        }
    }
}
  • TaskRow:
struct TaskRow: View {
    
    @ObservedObject var task: TaskViewModel

    var body: some View {
        HStack {
            Text(task.name)
            Spacer()
            Toggle("", isOn: $task.done )
        }
    }

}
  • TaskListViewModel
class TaskListViewModel : ObservableObject  {
    
    private var cancelables = Set<AnyCancellable>()
    
    @Published var filterDone = false

    @Published var tasks: [TaskViewModel] = []
    
    func filter() -> [TaskViewModel]  {
        filterDone ? tasks.filter { !$0.done } : tasks
    }
    
    func fetchTasks() {
        let id = 0
        [
         TaskViewModel(name: "Task \(id)", description: "Description"),
         TaskViewModel(name: "Task \(id + 1)", description: "Description")
        ].forEach { add(task: $0) }
    }
    
    private func add(task: TaskViewModel) {
        tasks.append(task)
        task.objectWillChange
            .sink { self.objectWillChange.send() }
            .store(in: &cancelables)

    }
}

Notice here each TaskViewModel will propagate objectWillChange event to TaskListViewModel to update the filter when a task is marked as completed.

  • TaskViewModel:
class TaskViewModel: ObservableObject, Identifiable, Hashable {
    
    var id: String { name }
    
    let name: String
    let description: String
    
    @Published var done: Bool = false
    
    init(name: String, description: String, done: Bool = false) {
        self.name = name
        self.description = description
        self.done = done
    }
    
    static func == (lhs: TaskViewModel, rhs: TaskViewModel) -> Bool {
      lhs.id == rhs.id
    }
      
    func hash(into hasher: inout Hasher) {
      hasher.combine(id)
    }
    
}

This is the main difference from the original approach: Changing the row model from a simple struct included as @Binding to an ObservableObject

Upvotes: 0

Cenk Bilgen
Cenk Bilgen

Reputation: 1435

SwiftUI architecture is really just state and view. Here, it's the state of the Task that you are most interested in (done/undone). Make the Task an Observable class that publishes it's done/undone state change. Bind the UI toggle switch in TaskRow directly to that done/undone in the Task model (remove the intermediary list of indexes), then you don't need any logic to publish state changes manually.

The second state for the app is filtered/unfiltered for the list. That part it seems you already have down.

This is one possible way to do it. EDIT: Here's a more full example on how to keep the data state and view separate. The Task model is the central idea here.

@main
struct TaskApp: App {
  @StateObject var model = Model()
  var body: some Scene {
    WindowGroup {
      TaskListView()
        .environmentObject(model)
    }
  }
}

class Model: ObservableObject {
  @Published var tasks: [Task] = [
    Task(name: "Task1", description: "Description"),
    Task(name: "Task2", description: "Description")
  ] // some initial sample data
  func updateTasks() {
    //
  }
}

class Task: ObservableObject, Identifiable, Hashable {
  var id: String { name }
  let name, description: String
  @Published var done: Bool = false
  init(name: String, description: String) {
    self.name = name
    self.description = description
  }
  static func == (lhs: Task, rhs: Task) -> Bool {
    lhs.id == rhs.id
  }
  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
}

struct TaskListView: View {
  @EnvironmentObject var model: Model
  var filter: ([Task]) -> [Task] = { $0.filter { $0.done } }
  @State private var applyFilter = false
  var body: some View {
    NavigationView {
      VStack {
        Toggle(isOn: $applyFilter) {
          Text("Undone only")
        }.padding()
        List {
          ForEach(
            (applyFilter ? filter(model.tasks) : model.tasks), id: \.self) { task in
            TaskRow(task: task)
          }
        }
      }.navigationBarTitle(Text("Tasks"))
    }
  }
}

struct TaskRow: View {
  @ObservedObject var task: Task
    var body: some View {
        HStack {
            Text(task.name)
            Spacer()
          Toggle("", isOn: $task.done).labelsHidden()
        }
    }
}

Upvotes: 1

Related Questions