Skip to content

npvisual/ToDoList-Redux

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ToDoList-Redux

Topic : Redux based ToDo / Task application

Objectives

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.

Enters Redux

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.

ToDo / Task application

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 :

Iterations

1. First Redux Concepts (swiftrex-option1b)

The first approach is based on a very light introduction of Redux and SwiftRex concepts :

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.

2. Container / Rendering View (swiftrex-containerview)

This iteration was my attempt to implement what Luis had described as "Option 1b" :

  • it creates an abstration level, i.e. the "Container" concept that Majid described in his post, between the actual view and the parent view. But with a little twist... by pushing the notion of State and Actions to that container,
  • it still leaves the implementation of the Rendering View and puts the Container View in charge of doing the data binding job,
  • the Container View uses Combine's @ObservedObject to receive an ObservableViewModel from the calling View,
  • that now forces us to use a projection of our original view model -- we're a little more Redux-like.

3. 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 :

  • it uses an extension of the Rendering View to define the Redux-specific concepts,
  • it still leaves the Rendering View itself very clean, when doing so...
  • the separation of (Redux) concerns in the extension (and here) allows us to define more Redux-like concepts, like 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),
  • it allows us to have a fairly clean call from the parent View, because we've abstracted most of the binding logic out in the extensions.

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.

4. Router construct and precursor to ViewProducer (swiftrex-router)

This next phase packs a lot of changes (most of them provided by Luiz).

Router

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.

Item abstraction

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).

More Actions

We've also introduced some changes to the Actions :

  • we have a new .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.
  • the .add action doesn't need any parameter since we just create a new empty Task when the user clicks on the "+ New Task" button.

Questions

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 :