Topic : Redux based ToDo / Task application
Lately I have been looking at migrating part of the code from the application I am developing from UIKit to SwiftUI. While the pure transition from UIKit to SwiftUI was actually pretty smooth in my first tribulation, the architectural framework I am using for UIKit, just doesn't seem to fit very well to SwiftUI. This led me on a wild-goose chase for other, more suited, framework for SwiftUI.
While on my search I came across Redux (deja vu ?) and more specifically SwiftRex. There are a ton of other Redux frameworks for Swift out there, but SwiftRex seemed to line up pretty well with what I was looking for, and its creator, @luizmb, was super responsive and very helpful in getting me started.
As I was going through the evaluation of the different Swift Redux frameworks, I saw numerous "Counter" examples. This seems like the "Hello World !" equivalent for Redux. However it's really not nearly enough to get a decent feel for how well it will suit one's needs.
So I used a fairly basic Task application to compare those different Swift Redux framework. I initially started reading about how to model app state using Store object using ObservableObject
. Thanks to some really good reading from Peter Friese and Majid Jabrayilov, I settled on a design for the initial Task application (read those links if you want to learn more about the different designs).
Jump directly to what interests you :
The first approach is based on a very light introduction of Redux and SwiftRex concepts :
@StateObject
and @ObservedObject
to "link" with SwiftRex's ObservableViewModel
. This is (roughly) how you pass down Redux's Store (or projections of the Store) to your Views,You can find some great explanations from Luis here on the different possible approaches I was going after.
However, while this was a good first step, it was nowehre near what Luis described as "Option 1b", and it still wasn't making much use of the Redux framework since it was directly tying the cell view into the ForEach of the TaskList.
This iteration was my attempt to implement what Luis had described as "Option 1b" :
@ObservedObject
to receive an ObservableViewModel
from the calling View,ObservableViewModel
in the Rendering View itself (swiftrex-observableviewmodel)Another approach suggested by Luis was to integrate the View Model directly in the Rendering View (i.e. bypassing the Container View), while still keeping the View independent of any "higher level" Application Logic.
While I didn't like this approach initially because the presence of the ObservableViewModel
forces the creator of the Rendering View to know about this particular SwiftRex concept, it somewhat grew on me, because of its simplicity :
projection
, at the appropriate layer ; see for example how the TaskList extension allows us to define a projection specifically for the CheckmarkCellView
(i.e. abstracting that mapping logic where it makes sense),Now, a lot of the changes between #2 and #3 were purely cosmetic and a lot of the simplifications introduced in #3 could have been made for CheckmarkCellContainerView
as well. But we're slowly getting to what I consider a more "Redux-like" approach.
Note : I am still a little confused about "lift vs. projection" but the last iteration helped in understanding where projection comes in (notice that I have not made any additional use of lift
, outside of the App level reducer). Might do that in the next iteration.
This next phase packs a lot of changes (most of them provided by Luiz).
Overall, at the App level, those changes are introducing a new concept : the Router. It's a set of static functions that provide Views, given the global store.
import SwiftRex
struct Router {
static func taskListView<S: StoreType>(store: S) -> TaskList
where S.StateType == AppState, S.ActionType == AppAction {
TaskList(
viewModel: TaskList.viewModel(store: store.projection(action: AppAction.list, state: \AppState.tasks)),
rowView: { id in taskListRowView(store: store, taskId: id) }
)
}
static func taskListRowView<S: StoreType>(store: S, taskId: String) -> CheckmarkCellView
where S.StateType == AppState, S.ActionType == AppAction {
CheckmarkCellView(
viewModel: CheckmarkCellView.viewModel(
store: store.projection(action: AppAction.task, state: \AppState.tasks),
taskId: taskId
)
)
}
}
This is slowly taking us toward the ViewProducer concept that Luiz mentioned a little while ago. This is important because it now allows us to "direct" the traffic via taskListView
and taskListRowView
directly from the global Store.
So the state, that was somehow stored at the view model level, was moved back where it should have been all along : at the top of the Application, in AppState.
struct AppState: Equatable {
var appLifecycle: AppLifecycle
var tasks: [Task]
...
}
So projections, which are view / context specific, can be utilized as a "reduced" domain of expertise for each view.
The other important change was the use of a "generic" Item in the TaskList
(which we could certainly have renamged ItemList
as it is becoming more and more generic). In essence the view model for TaskList
is really only concerned about IDs and that's it -- because the actual binding of cell-specific values to the cell elements are handled outside, thanks to the Router.
struct State: Equatable {
var title: String
var tasks: [Item]
...
}
It also allowed us to split list-related actions and task-related actions,
enum AppAction {
case appLifecycle(AppLifecycleAction)
case list(ListAction)
case task(TaskAction)
}
enum ListAction {
case add
case remove(IndexSet)
case move(IndexSet, Int)
}
enum TaskAction {
case toggle(String)
case update(String, String)
}
as well as break down the Reducers into their respective areas of expertise so we can compose the App Reducer from them all :
extension Reducer where ActionType == AppAction, StateType == AppState {
static let app =
Reducer<TaskAction, [Task]>.task.lift(
action: \AppAction.task,
state: \AppState.tasks
) <> Reducer<ListAction, [Task]>.list.lift(
action: \AppAction.list,
state: \AppState.tasks
) <> Reducer<AppLifecycleAction, AppLifecycle>.lifecycle.lift(
action: \AppAction.appLifecycle,
state: \AppState.appLifecycle
)
}
extension Reducer where ActionType == ListAction, StateType == [Task] {
static let list = Reducer { action, state in
var state = state
switch action {
case .add:
state.append(Task(title: "", priority: .medium, completed: false))
case let .remove(offset):
state.remove(atOffsets: offset)
case let .move(offset, index):
state.move(fromOffsets: offset, toOffset: index)
}
return state
}
}
extension Reducer where ActionType == TaskAction, StateType == [Task] {
static let task = Reducer { action, state in
var state = state
switch action {
case let .toggle(id):
if let index = state.firstIndex(where: { $0.id == id }) {
state[index].completed.toggle()
}
case let .update(id, title):
if let index = state.firstIndex(where: { $0.id == id }) {
state[index].title = title
}
}
return state
}
}
The final outcome is that we have a now much more streamlined View creation in the ForEach
:
struct TaskList: View {
@ObservedObject var viewModel: ObservableViewModel<Action, State>
let rowView: (String) -> CheckmarkCellView
var body: some View {
NavigationView {
VStack(alignment: .leading) {
List {
ForEach(self.viewModel.state.tasks) { rowView($0.id) }
.onDelete { viewModel.dispatch(.remove($0)) }
.onMove { viewModel.dispatch(.move($0, $1)) }
}
...
and we're probably just one step away from being able to abstract the return type of rowView
to make TaskList
completely generic (currently tied to CheckmarkCellView
).
We've also introduced some changes to the Actions :
.update(String)
Action that is used with our new TextField. That gives us the ability to update the State when the user inputs or modifies the title of a Task..add
action doesn't need any parameter since we just create a new empty Task when the user clicks on the "+ New Task" button.While those were marked as resolved, there are several interesting questions that were asked, or derived work that was produced, in the course of making this demo application :