Reputation: 153
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
Reputation: 153
Finally I've managed to implement the list functionality whith the conditions previously listed. Based on Cenk Bilgen answer:
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()
}
}
}
struct TaskRow: View {
@ObservedObject var task: TaskViewModel
var body: some View {
HStack {
Text(task.name)
Spacer()
Toggle("", isOn: $task.done )
}
}
}
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.
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
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