3 - Showing the List of Tasks
In the last part of the tutorial we referenced a class called TasksListScreen
. This screen will show a List<Task>
using a JetPack Compose Column.
3-1 Create a TaskRow
views
Each row of the tasks will be represented by a SwiftUI View
called TaskRow
which takes in a Task
and two callbacks which we will use later.
- If the
task.isCompleted
istrue
, we will show a filled circle icon and a strikethrough style for thebody
. - If the
task.isCompleted
isfalse
, we will show an open circle icon. - If the user taps the
Icon
, we will call aonToggle: ((_ task: Task) -> Void)?
, we will reverse theisCompleted
fromtrue
tofalse
orfalse
totrue
- If the user taps the
Text
, we will call aonClickBody: ((_ task: Task) -> Void)?
. We will use this to navigate anEditScreen
(we will create this later)
For brevity, we will skip discussions on styling as it's best to see the code snippet below:
We've also included a TaskRow_Previews
that allows you to see the end result with some test data quickly.
import SwiftUI
struct TaskRow: View {
let task: Task
var onToggle: ((_ task: Task) -> Void)? var onClickBody: ((_ task: Task) -> Void)?
var body: some View { HStack { // 2. Image(systemName: task.isCompleted ? "circle.fill": "circle") .renderingMode(.template) .foregroundColor(.accentColor) .onTapGesture { onToggle?(task) } if task.isCompleted { Text(task.body) // 2. .strikethrough() .onTapGesture { onClickBody?(task) }
} else { // 3. Text(task.body) .onTapGesture { onClickBody?(task) } }
} }}
struct TaskRow_Previews: PreviewProvider { static var previews: some View { List { TaskRow(task: Task(body: "Get Milk", isCompleted: true)) TaskRow(task: Task(body: "Do Homework", isCompleted: false)) TaskRow(task: Task(body: "Take out trash", isCompleted: true)) } }}
3-2 Create a TasksListScreenViewModel
In the world of SwiftUI, the most important design pattern is the MVVM, which stands for Model-View-ViewModel. MVVM strives to separate all data manipulation (Model and ViewModel) and data presentation (UI or View) into distinct areas of concern. When it comes to Ditto, we recommend that you never include references to edit ditto
in View.body
. All interactions with ditto
for upsert
, update
, find
, remove
and observe
should be within a ViewModel
. The View should only render data from observable variables from the ViewModel
and only the ViewModel
should make direct edits to these variables.
Typically we create a ViewModel
per screen or per page of an application. For the TasksListScreen
we need some functionality like:
- Showing a realtime list of
Task
objects - Triggering an intention to edit a
Task
- Triggering an intention to create a
Task
- Clicking an icon to toggle the icon from
true
tofalse
orfalse
totrue
In SwiftUI we create a view model by inheriting the ObservableObject
. The ObservableObject
allows SwiftUI to watch changes to certain variables to trigger view updates intelligently. To learn more about ObservableObject
we recommend this excellent tutorial from Hacking with Swift.
- Create a file called TasksListScreenViewModel.swift in your project
- Add an
init
constructor to pass in aditto: Ditto
instance and store it in a local variable. - Create two
@Published
variables fortasks
and isPresentingEditScreen
.@Published
variables are special variables of anObservableObject
. If these variables change, SwiftUI will update the view accordingly. Any variables that are not decorated with@Published
can change but will be ignored by SwiftUI. - We also add a normal variable,
private(set) var taskToEdit: Task? = nil
. When a user is attempting to edit a task, we need to tell the view model which task the user would like to edit. This does not need to trigger a view reload, so it's a simple variable. - Here's where the magic happens. As soon as the
TasksListScreenViewModel
is initialized, we need to.observe
all the tasks by creating a live query. To prevent theliveQuery
from being prematurely deallocated, we store it as a variable. In the observe callback, we convert all the documents intoTask
objects and set it to the@Published tasks
variable. Every time to.observe
fires, SwiftUI will pick up the changes and tell the view to render the list of tasks. - We will add an eviction call to the initializer that will remove any deleted documents from the collection
- Add a function called
toggle()
. When a user clicks on a task's image icon, we need to trigger reversing theisCompleted
state. In the function body we add a standard call to find the task by its_id
and attempt to mutate theisCompleted
property. - Add a function called
clickedBody
. When the user taps theTaskRow
'sText
field, we need to store that task and change theisPresentingEditScreen
to true. This will give us enough information to present a.sheet
in theTasksListScreenViewModel
to feed to theEditScreen
- In the previous setup of the
TasksListScreen
, we added anavigationBarItem
with a plus icon. When the user clicks this button we need to tell the view model that it should show theEditScreen
. So we've set theisPresentingEditScreen
property totrue
. However, because we are attempting to create aTask
, we need to set thetaskToEdit
tonil
because we don't yet have a task.
class TasksListScreenViewModel: ObservableObject {
// 3. @Published var tasks = [Task]() @Published var isPresentingEditScreen: Bool = false
// 4. private(set) var taskToEdit: Task? = nil
let ditto: Ditto // 5. var liveQuery: DittoLiveQuery? var subscription: DittoSubscription?
init(ditto: Ditto) { self.ditto = ditto self.subscription = ditto.store["tasks"].find("!isDeleted").subscribe() self.liveQuery = ditto.store["tasks"] .find("!isDeleted") .observeLocal(eventHandler: { docs, _ in self.tasks = docs.map({ Task(document: $0) }) }) //6. ditto.store["tasks"].find("isDeleted == true").evict() }
// 7. func toggle(task: Task) { self.ditto.store["tasks"].findByID(task._id) .update { mutableDoc in guard let mutableDoc = mutableDoc else { return } mutableDoc["isCompleted"].set(!mutableDoc["isCompleted"].boolValue) } }
// 8. func clickedBody(task: Task) { taskToEdit = task isPresentingEditScreen = true }
// 9. func clickedPlus() { taskToEdit = nil isPresentingEditScreen = true }}
3-3 Render TaskRow
in a ForEach
within the TasksListScreen
Now we need to update our TasksListScreen
to properly bind any callbacks, events, and data to the TasksListScreenViewModel
.
- Back in the
TasksListScreen
view, we need to construct ourTasksListScreenViewModel
and store it as an@ObservedObject
. This@ObservedObject
tells the view to watch for specific changes in theviewModel
variable. - We will need to store our
ditto
object to pass to theEditScreen
later. - In our
body
variable, find theList
and add:
ForEach(viewModel.tasks) { task in TaskRow(task: task, onToggle: { task in viewModel.toggle(task: task) }, onClickBody: { task in viewModel.clickedBody(task: task) } )}
This will tell the list to iterate over all the viewModel.tasks
and render a TaskRow
. In each of the TaskRow
children, we need to bind the onToggle
and onClick
callbacks to the viewModel methods.
- Bind the plus button to the
viewModel.clickedPlus
event - Now we need to present a
.sheet
which will activate based on the$viewModel.isPresentingEditScreen
variable. Notice how we added the$
beforeviewModel
..sheet
can edit theisPresentingEditScreen
once it's dismissed, so we need to treat the variable as a bidirectional binding. - We've also included a
TasksListScreen_Previews
so that you can add some test data and see the result in a live view.
struct TasksListScreen: View {
// 2. let ditto: Ditto
// 1. @ObservedObject var viewModel: TasksListScreenViewModel
init(ditto: Ditto) { self.ditto = ditto self.viewModel = TasksListScreenViewModel(ditto: ditto) }
var body: some View { NavigationView { List { // 3. ForEach(viewModel.tasks) { task in TaskRow(task: task, onToggle: { task in viewModel.toggle(task: task) }, onClickBody: { task in viewModel.clickedBody(task: task) } ) } } .navigationTitle("Tasks - SwiftUI") .navigationBarItems(trailing: Button(action: { // 4 viewModel.clickedPlus() }, label: { Image(systemName: "plus") })) // 5. .sheet(isPresented: $viewModel.isPresentingEditScreen, content: { EditScreen(ditto: ditto, task: viewModel.taskToEdit) }) } }}// 6.struct TasksListScreen_Previews: PreviewProvider { static var previews: some View { TasksListScreen(ditto: Ditto()) }}
tip
Notice that we DO NOT HAVE TO manipulate the tasks
value. Calling .update
on ditto
will automatically fire the liveQuery to update the tasks
. You can always trust the liveQuery to immediately update the @Published var tasks
. There is no reason to poll or force reload. Ditto will automatically handle the state changes and SwiftUI will pick these changes up automatically.