Giter Site home page Giter Site logo

Comments (19)

luizmb avatar luizmb commented on May 23, 2024 3

Hi,

The architecture is a proposal, but you certainly have a lot of room to adapt to your needs. I used SwiftRex (and other redux implementations) with MVC, MVVM-C and MVP-C, in UIKit and SwiftUI, and definitely they have benefits and trade-offs. The model that works best for me in SwiftUI, and this is totally my personal opinion, is:

  • if your view has output only, it should be a "let" constant
  • if your view is super reusable and only extends or creates a native SwiftUI component, like a Button or TabPageView, or a custom NavigationBar, and has mostly output but eventually one input that could be either a closure (onTap) or a Binding value, it should import SwiftUI only and have either a closure or a Binding property that is initialized with the view
  • any other case, including rows in list that have at least 1 input (tap, delete, edit gesture, etc), then it should have a ViewModel.

However, again, this is my personal opinion on this topic and you're free to choose the granularity of your ViewModels, or not having view models at all. Just please keep in mind that what you see as ObservableViewModel, is only a @observableobject as you can see in any of the many SwiftUI tutorials, but with a better API for the "input" side: the dispatch(action) function.

That said, an ObservableViewModel has 2 directions, input and output, and is generic over two types: Action (input) and State (output). This is exactly the opposite about your sentence "is not very conducive to re-usability."

If you write a view that shows a String and has a tap action, you can have a viewmodel for that view as:

struct MyView: View {
  struct State: Equatable {
    let title: String
  }
  enum Action {
    case tap
  }
  @ObservedObject var viewModel: ObservableViewModel<Action, State>

  var body: some View {
    Text(viewModel.state.title)
      .onTapGesture { viewModel.dispatch(.tap) }
  }
}

This is completely decoupled from you app logic, this is all about view input and output.
Who creates this view will have to glue, somehow, "tap" to something "business logic" and something "business logic" to "title". The view itself can be used and reused in any context.

This "somehow" is what SwiftRex calls "projection"

let viewModel = store.project(action: { viewEvent -> appAction }, state: { appState -> viewState })
    .asObservableViewModel(initial: .empty)
let view = MyView(viewModel: viewModel)

This glue is what connects all parts together, and with a strong compiler help. There's a lot of type-safety everywhere so writing bugs is a bit harder. Usually this glue, in my apps, are in the ViewProducers (which I mentioned in the other github issue).

Because you can make different kinds of projections to the same view, is exactly why "granted some Views are tightly linked to the logic" is not exactly precise. The views will be tighly linked to the actions they offer and the Strings they show, but nothing else, they don't even know what "tap" will become or where "title" is coming from.

Ok, but is there any other ways? Yes, glad you asked! You can pretty much hold view models only in your top level views and from there on use closures, Bindings and let constants to distribute this to subviews. From my experience, this always leads to some eventual redux violation (a @State here or there that is not routing through the store, actions that are handled in-loco without being ever dispatched to the store, etc). This might not be a big issue too, sometimes you may even want that, and that's perfectly fine, as long as you understand what you're doing and the risks. I personally find that a more rigid architecture works better for me to protect from myself :D

So, how would it be that alternative?

I'll use your toggle cell as example, in two flavors: binding and closure, you pick your poison.

Example 1a: closure (action is a simple Void)

import SwiftUI

struct ToggleCellView: View {

    let title: String
    let imageName: String
    let toggle: () -> Void

    var body: some View {
        HStack {
            Image(systemName: imageName)
                .resizable()
                .frame(width: 20, height: 20)
                .onTapGesture(perform: toggle)
            Text(title)
        }
    }
}

import CombineRex
struct ContainerView: View {
    @ObservedObject var viewModel: ObservableViewModel<Void, (title: String, image: String)>

    var body: some View {
        ToggleCellView(title: viewModel.state.title, imageName: viewModel.state.image) {
            viewModel.dispatch(())
        }
    }
}

Example 1b: closure (proper action type)

import SwiftUI

struct ToggleCellView: View {

    let title: String
    let imageName: String
    let toggle: () -> Void

    var body: some View {
        HStack {
            Image(systemName: imageName)
                .resizable()
                .frame(width: 20, height: 20)
                .onTapGesture(perform: toggle)
            Text(title)
        }
    }
}

import CombineRex
struct ContainerView: View {
    @ObservedObject var viewModel: ObservableViewModel<Action, (title: String, image: String)>

    var body: some View {
        ToggleCellView(title: viewModel.state.title, imageName: viewModel.state.image) {
            viewModel.dispatch(.toggle)
        }
    }

    enum Action {
        case toggle
    }
}

Example 2: binding

import SwiftUI

struct ToggleCellView: View {

    let title: String
    var checked: Binding<Bool>

    var body: some View {
        HStack {
            Image(systemName: checked.wrappedValue ? "checkmark.circle.fill" : "circle")
                .onTapGesture {
                    checked.wrappedValue.toggle()
                }
            Text(title)
        }
    }
}

import CombineRex
struct ContainerView: View {
    @ObservedObject var viewModel: ObservableViewModel<Action, (title: String, isChecked: Bool)>

    var body: some View {
        ToggleCellView(
            title: viewModel.state.title,
            checked: viewModel.binding[\.isChecked] { Action.toggle($0) }
        )
    }

    enum Action {
        case toggle(Bool)
    }
}

In these examples the container is adding nothing to the toggle. And because of that, the container is generic enough to be reusable as well, even having a view model. That's because the generic parameters are... well.. very generic. An action with a single case, that could even be replaced by a Void (example 1a), and a state that is a pair of strings or a string + bool and are exactly what the toggle itself offers.

I hope you have now a plenty of options to play with, please let me know if you're happy with the answer or if something is missing.

Best regards,

Luiz

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024 2

Hi @npvisual,

Excellent question! Starting from the bottom, yes, projection is available for StoreType protocol, and both real Store, StoreProjection and ObservableViewModel as StoreTypes. So you can have a projection of a projection (of a projection, of a projection...)

struct TaskList: View {
    @ObservedObject var viewModel: ObservableViewModel<TaskListViewModel.Action, TaskListViewModel.State>
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                List {
                    ForEach(self.viewModel.state.tasks, id: \.id) { (item: Task) in
                        CheckmarkCellContainerView(
                            viewModel: viewModel
                                .projection(
                                    action: TaskListViewModel.Action.cellAction,
                                    state: { masterState in
                                        CheckmarkCellContainerView.State(title: item.title, imageName: item.completed ? "checkmark.circle.fill" : "circle")
                                    }
                                )
                                .asObservableViewModel(initialState: CheckmarkCellContainerView.State(title: "", imageName: "circle"))
                        )
                    }
                    .onDelete { indexSet in
                        viewModel.dispatch(.delete(indexSet: indexSet))
                    }
                }
                Button(action: {}) {
                    HStack {
                        Image(systemName: "plus.circle.fill")
                            .resizable()
                            .frame(width: 20, height: 20)
                        Text("New Task")
                    }
                }
                .padding()
                .accentColor(Color(UIColor.systemRed))
            }
            .navigationBarTitle("Tasks")
            .navigationBarItems(trailing: EditButton())
        }
    }
}

For that, please notice that you have to account for the event coming from the cell. So either you switch/case on that and convert the cell event into master event, or you wrap the item event into another case of the parent and let the reducers do the job.

extension TaskListViewModel {
    struct State: Equatable {
        let tasks: [Task]
    }

    enum Action {
        case tapComplete(id: UUID)
        case delete(indexSet: IndexSet)
        case cellAction(CheckmarkCellContainerView.Action)
    }
}

The ForEach extensions (https://github.com/teufelaudio/CombineRextensions/blob/master/Sources/CombineRextensions/ForEach%2BExtensions.swift) may give you some help on Identifiable cases, but it's totally optional.

But there are some things that are important to notice.

  1. Perform a projection inside of a View doesn't look very nice, imho. This adds a bit of logic into the view and after some time you may see yourself with too much code in the view that you wish was somewhere else.
  2. This approach works only when the cell is a perfect subset of the master. Which is usually the case for Lists and ForEachs, but not necessarily be the case when you have NavigationView, that requires a destination View ahead of time, and this destination may need completely different models. You don't want to add all the destination requirements in the origin, or this will create similar problem that we used to have in UIKit with dependency injection, a grandparent view needs to hold dependencies needed 3 levels deep in the navigation stack. This is not very cool.

For both cases, I like "injecting" the view.

For example, you can think of your cell as a function from taskId to CheckmarkCellContainerView.
(UUID) -> CheckmarkCellContainerView.

If this is a property in your TaskList, whoever created TaskList needs to provide this closure in its init. Whoever created TaskList has the full store. So...

struct TaskList: View {
    @ObservedObject var viewModel: ObservableViewModel<TaskListViewModel.Action, TaskListViewModel.State>
    let cellProducer: (UUID) -> CheckmarkCellContainerView
    ...
    ForEach(...) { task in cellProducer(task.id) }
}

Using that makes the code much cleaner. The requirement to create a CheckmarkCellContainerView is the task UUID. So let's see how this is possible.

func router(store: Store) -> TaskList {
    return TaskList(
        viewModel: store.projection(...).asObservableViewModel(initialState: .empty),
        cellProducer: { uuid in cellProducer(store: store, taskId: uuid) }
    )
}

func cellProducer(store: Store, taskId: UUID) -> CheckmarkCellContainerView {
    return CheckmarkCellContainerView(
        viewModel: store.projection(...).asObservableViewModel(initialState: .empty)
    )
}

Now, even if your cell needs more information than your task list can provide, you still have the full store available. This works very well with NavigationView destination, tab views, modal sheets, etc. It's optional for ForEach, as usually the cell needs not more than the list offers. But once you have a NavigationLink in your cell, and it pushes a View with much more info, then you need the full store somehow. That way I'm suggesting, you don't need to send the full store to any view. In fact, you never perform projections on the view, but in your "router". Also, the same parent view could have 2, 3 or 4 producers, each one creating a small portion of your screen. If you do that well, with ViewState having only the minimum information a section needs, SwiftUI will not update the full screen but only what changed (please notice that shouldEmit: .whenDifferent is the default mode for equatable View States and the "when different" is not comparing AppState but only the View State, smaller it is, faster your app will be).

Finally, I created ViewProducer<Context, ProducedView> to wrap functions such as (UUID) -> CheckmarkCellContainerView, you can see more examples of that on #12 . If a view doesn't need context, it's a () -> CheckmarkCellContainerView, in that case view producer would be ViewProducer<Void, CheckmarkCellContainerView>.

If something is confusing in my reply, please ask again, I won't bother giving better examples.

Best regards,

Luiz

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

This is great ! Thanks @luizmb. I am still working my way through this and your other response for #12 . But a quick glance seems to indicate that you've answered all my questions (and beyond).

Still working through some issues with Live Preview (the toggle works when I run the app, but not in live preview) but should be able to have this working soon and then will explore the bindings with CombineRextensions.

Thanks again for your prompt and very detailed answer.

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

Taking about bindings... I tried to get CombineRextensions (master) to work via SPM (worked) but none of the extensions are showing up. Could be because I am running Xcode 12 beta 4 and iOS 14 beta 4...

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024 1

Just to be clear, with ViewProducers the result would be:

struct TaskList: View {
    @ObservedObject var viewModel: ObservableViewModel<TaskListViewModel.Action, TaskListViewModel.State>
    let cellProducer: ViewProducer<UUID, CheckmarkCellContainerView>
    ...
    ForEach(...) { task in cellProducer.view(task.id) }
}

func router(store: Store) -> ViewProducer<Void, TaskList> {
    ViewProducer {
        TaskList(
            viewModel: store.projection(...).asObservableViewModel(initialState: .empty),
            cellProducer: cellProducer(store: store) }
        )
    }
}

func cellProducer(store: Store) -> ViewProducer<UUID, CheckmarkCellContainerView> {
    ViewProducer { uuid in
        CheckmarkCellContainerView(
            viewModel: store.projection(...).asObservableViewModel(initialState: .empty)
        )
    }
}

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

For that, please notice that you have to account for the event coming from the cell. So either you switch/case on that and convert the cell event into master event

Yes, that's what I ended up doing, and yes, it's pretty ugly / brutal :

                    ForEach(self.viewModel.state.tasks, id: \.id) { (item: Task) -> CheckmarkCellContainerView in
                        let cellViewModel: ObservableViewModel<CheckmarkCellContainerView.Action, CheckmarkCellContainerView.State> = viewModel.projection(
                            action: { viewaction in
                                switch viewaction {
                                    case .toggle: return .tapComplete(id: item.id)
                                }
                            },
                            state: { state in
                                CheckmarkCellContainerView.State(title: item.title,
                                                                 imageName: item.completed ? "checkmark.circle.fill" : "circle")
                            }).asObservableViewModel(initialState: CheckmarkCellContainerView.State(title: "", imageName: "circle"))

So from a code "cleanliness" viewpoint, it looks like you either stay with the simple mapping to the "rendering" view directly :

                    ForEach(self.viewModel.state.tasks, id: \.id) { (item: Task) in
                        CheckmarkCellView(title: item.title,
                                          imageName: item.completed ? "checkmark.circle.fill" : "circle") {
                            viewModel.dispatch(.tapComplete(id: item.id))}
                    }
                    .onDelete(perform: delete)

or you use the view injection method / view producer you were describing above -- otherwise it's just hard to read... So there's a point of diminishing returns in terms of complexity / clarity depending on what one wants to achieve.


If you wrap your previews in a #if DEBUG, you can use ObservableViewModel.mock (https://github.com/SwiftRex/SwiftRex/blob/develop/Sources/CombineRex/ObservableViewModel.swift#L118) and have a very easy way to mock a quick reducer in place, to have even some behaviour on your Previews.

Yes, I thought that's what I was doing :

#if DEBUG
struct TaskList_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            TaskList(viewModel: .mock(state: TaskListViewModel.State.mock))
        }
    }
}
#endif

However, I just realized I am missing the action closure so it can update with Live Preview 🤷‍♂️. So I am gonna work on that next.

As alway, thanks for the prompt and very detailed explanation.

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

FYI. Live Preview working with :

#if DEBUG
struct TaskList_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            TaskList(viewModel: .mock(state: TaskListViewModel.State.mock,
                                      action: { action, _, state in
                                        switch action {
                                            case let .tapComplete(id: id):
                                                if let index = state.tasks.firstIndex(where: {$0.id == id}) {
                                                    state.tasks[index].completed.toggle()
                                                }

                                            case let .tapDelete(indexes: index): state.tasks.remove(atOffsets: index)
                                            case let .tapAdd(title: title): print("Add task with title : \(title)"). // Not implemented yet.
                                        }
                                      }
            )
            )
        }
    }
}
#endif

Btw, I was wondering if instead of using an inout it wouldn't make more sense to return a state in the same manner the Reducer does it, so it would be more straightforward to copy / paste code from existing reducers. But you've probably gone down that rathole already...

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

Yes, I have seen both implementations in my quest for the "right"™️ Redux framework. To me, returning a State is more inline theoretically with Redux's original definition of the reducers than using inout. Especially because you should be able to define the State's properties as let instead of var if you wanted to be even stricter about immutability.


Thanks for the pointer on re-using the Reducer. That should work perfectly !

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024 1

Thanks @luizmb . Still working on it, but this could be useful to some going through the same learning process...

https://github.com/npvisual/ToDoList-Redux

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

If you wrap your previews in a #if DEBUG, you can use ObservableViewModel.mock (https://github.com/SwiftRex/SwiftRex/blob/develop/Sources/CombineRex/ObservableViewModel.swift#L118) and have a very easy way to mock a quick reducer in place, to have even some behaviour on your Previews.

About CombineRextensions to be honest I'm not using it as a Library yet. This is a couple of stuff that I open sourced from a bigger framework used by my company, but we couldn't open source the whole thing because reasons. So I'm not sure if it's working as a library, but you should be able to copy the Swift files you need locally to your project and that would be easier, until I fix that. Once I release 0.8 I'm gonna review the Rextensions. My plan is having all SwiftUI stuff in there, so who is using UIKit doesn't have to import it.

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024

ok, one more (!) question on this -- sorry for being so dense...

Since I wasn't able to use the extensions, I moved from option 1b without a ContainerView (working great !) to option 1b with a ContainerView. So far I have something very similar to what you described above :

import SwiftUI
import CombineRex

struct CheckmarkCellView: View {
    
    let title: String
    let imageName: String
    let toggle: () -> Void
    
    var body: some View {
        HStack {
            Image(systemName: imageName)
                .resizable()
                .frame(width: 20, height: 20)
                .onTapGesture(perform: toggle)
            Text(title)
        }
    }
}

struct CheckmarkCellContainerView: View {
    
    struct State: Equatable {
        let title: String
        let imageName: String
    }
    
    enum Action {
        case toggle
    }
    
    @ObservedObject var viewModel: ObservableViewModel<Action, State>

    var body: some View {
        CheckmarkCellView(
            title: viewModel.state.title,
            imageName: viewModel.state.imageName) { viewModel.dispatch(.toggle)}
    }
}

This is a very clean approach (to me) and as you pointed out, also very modular (and, yes, next step is to include the model view directly in the rendering view, without having the container view... just to see how it's done).

However, when I move one level back up to the TaskList view I am having some issues to lift the appropriate ObservableViewModel.

The only thing at my disposal in the TaskList view is its viewModel, which is a projection of the Store created in TaskList's parent view. While my previous approach worked very well in the ForEach, as I was passing the details of each task from the viewModel to the CheckmarkCellView , I am now faced with passing an ObservableViewModel to the CheckmarkCellContainerView instead.

struct TaskList: View {
    
    @ObservedObject var viewModel: ObservableViewModel<TaskListViewModel.Action, TaskListViewModel.State>
    
    var body: some View {
        NavigationView {
            VStack(alignment: .leading) {
                List {
                    ForEach(self.viewModel.state.tasks, id: \.id) { (item: Task) in
>------- problem -----<
                        CheckmarkCellView(title: item.title,
                                          imageName: item.completed ? "checkmark.circle.fill" : "circle") {
                            viewModel.dispatch(.tapComplete(id: item.id))}
>----------------------<
                    }
                    .onDelete(perform: delete)
                }
                Button(action: {}) {
                    HStack {
                        Image(systemName: "plus.circle.fill")
                            .resizable()
                            .frame(width: 20, height: 20)
                        Text("New Task")
                    }
                }
                .padding()
                .accentColor(Color(UIColor.systemRed))
            }
            .navigationBarTitle("Tasks")
            .navigationBarItems(trailing: EditButton())
        }
    }
....

So I guess the question is : do I need to bring in the Store global variable into TaskList (meh... 😕) or is there a way to project the current ObservableViewModel (i.e. a projection of a projection) based on each task value / id ?

The intent would be to get down to something like :

                List {
                    ForEach(self.viewModel.state.tasks, id: \.id) { (item: Task) in
                        CheckmarkCellContainerView(viewModel: newprojection)
                    }
                    .onDelete(perform: delete)

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

Yes, you've got it right: in-place projection will make things a bit unpleasant to see and maintain. If the View is too simple, you can:

  • Use it without redux, by adopting conventional closures/binding; or
  • make if a func of your main view, so the cell will have access to the same viewModel; or
  • inject from outside using closure/view producer method; or
  • pass the viewModel as it is, and the child decides how to handle that, which I don't think it's a good idea.

My favourite is 3rd, but the 1st one can be useful for very simple cases. Problem with 1st is that as your view grows and needs more properties/actions, things start getting a bit ugly again. From the point where you have more than 1 action on, I would recomend going for the 3rd option.

A fifth option is to create extensions to help on projections, especially projections of collection to item (that ForEach extension I shared, for example). But usually you don't need that.

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

My forever question is if Reducer should be inout, instead. To avoid state copy.
So far this doesn't make too much difference in any of the projects I worked, but perhaps if you hold a huge collection in your AppState, the inout can save you a lot of memory. Although Swift is pretty good with the copy-on-write strategy, inout would ensure that nothing is copied at all.

I haven't changed that because I wanted to avoid breaking changes in something so fundamental to the library.

Still, you can reuse the reducers in there:

action : { action, _, state in
  state = someReducer.reduce(action, state)
}

Or

func previewReducer<A, D, S>(_ reducer: Reducer<A, S>) -> (A, D, inout S) -> Void {
    return { action, dispatcher, state in
        state = reducer(action, state)
    }
}

.mock(state: TaskListViewModel.State.mock, action: previewReducer(myReducer))

(written without compiler, may need some changes)

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024

Still a little fuzzy on the following : lift vs. projection.

The example provided in the QuickGuide is great because it shows that the definitions for the reduced states and actions can be limited in scope : i.e. if used in a framework, the definitions of those states, actions and reducers don't need to know anything about the application state and actions. Only in the lift declaration do we encounter AppState and AppAction. This is great because the lifting is only relevant at the main (or higher level) target.

However in the subsequent example about "Store Projections and View Models" it seems that we're now pushing the main (or higher level) target down to the View Model definition 😒.

So :

  • are the two (lift vs. projection) functionally equivalent* ?
  • if they are, why use one over the other

I personally like the lift approach better because I can define View Models down in more specific frameworks without any references to the main (or higher level) target. But maybe this approach has limitations I don't quite understand yet ???

Note (*) : more like "reverse of each other with no dependencies". I.e. could I use only lift and achieve the same end result ?

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

Hi,

You can lift reducers and middlewares, to add them to a Store. This creates a totally new Reducer or Middleware that embeds the lifting functions. Every time an action arrives at the store pipeline, the lifted reducer will run the lift functions (action/state) and reduce it in case the original local reducer understands that kind of action (or ignore it otherwise). Same for middleware, the LiftMiddleware will hold the original middleware internally and check if that action is something for that original middleware to handle.

The goal of reducer.lift and middleware.lift: it allows you to write reusable reducers/middlewares in different binaries and plug them anywhere.

Store projections work outside of a store, not inside.
A Store has two roles: the first is a state publisher, it has a CurrentValueSubject (or similar in other Rx implementations), and every time a new state is published it will emit a new value to subscribers. The Store Projection offers a "map" in that CurrentValueSubject so your view gets the View State and not the full AppState.
The second role is a action handler, it offers a "dispatch" function so you can send AppAction to it (more or less like a AppAction subscriber). The Store Projection offers a "map" in that action subscriber, so your view sends View Events and not the full AppAction. It's everything about Views.

The goals of a store.projection: it allows you to write views in different binaries and plug them anywhere, and also to make your views work with a "local-to-the-view" set of actions/state (if you want, this part is totally optional).

They share the same goal, but for different entities. They also work in opposite direction: lift will make a local reducer to become a global one, or a local middleware to become a global one, while store projection will make a global store to become a local one. In fact, they don't replace each other and you'll probably need both, because although they allow the same thing - to work with subsets which unlocks modularity to your app - they do that for different layers.

Either code (lift or projection) should be in the higher level binary and either can be applied in cascade: you can lift a lifted middleware, and lift it again and again, you can lift a lifted reducer and lift again and again. You can also project a store projection, and project another from that, again and again. Because of that, a module can have lifters inside of it, depending on the size of your project. In the latest project I worked, we had modules that were responsible for specific app functionality (app onboarding, legal stuff, connection management, feature A, feature B, home screen). Each module has a ModuleState and ModuleAction, and inside each module we have lifters to make very very local reducers to become a Reducer<ModuleAction, ModuleState>. Same for middlewares. We also had view projections to make a StoreType of <ModuleAction, ModuleState> to become a Store Projection for a specific view (feature A could have around 5 or 6 views, each one with its set of events and view state). Well, this is lift and projection working inside of a module.

Then in the main binary, we have AppAction, AppState, an appReducer, an appMiddleware and the mainStore. For each module the could lift Reducer<ModuleAction, ModuleState> to Reducer<AppAction, AppState> (same for middleware) and then make a StoreType of <ModuleAction, ModuleState> out of the mainStore (<AppAction, AppState>).

About the links you mentioned, looking back now, maybe I was unfortunate in giving this example:

enum CounterViewAction {
    case tapPlus
    case tapMinus

    var asAppAction: AppAction? {
        switch self {
        case .tapPlus: return .count(.increment)
        case .tapMinus: return .count(.decrement)
        }
    }
}

This is misleading as one may think that the ViewAction/ViewEvent should always know about the model (AppAction). This is wrong. Usually the store projection rules should be somewhere that is neither the view or the model. Some people call that View Model, some people call it presenter. I use the ViewProducer for that, they are a very tiny version of a Presenter. In fact, in a functional approach View Models or Presenters should not have state and should not be anything more than a pair of functions: one from model to the user, the other from the user to the model. This is what store projections are: (ViewAction) -> AppAction and (AppState) -> ViewState, and my ViewProducers only glue these things together so I can have a lightweight Router layer.

Summary

You don't need any of these things for simple examples, this is nice because the architecture can scale and become as complex as your app grows, or remains simple when the app doesn't require such complexity. I know I lack some really complex examples to show all these powerful tools working in a real life project. None of the big projects I used SwiftRex can be open-sourced, unfortunately, so I'm working on a personal app with most of this complexity applied, however I have to share my personal time with this example and SwiftRex itself, and currently my goal is to finish 1.0 (missing tests, improved documentation, EffectMiddleware and stable API). Once this is done, I can work in complex examples and the tooling around (Middlewares, time travel, the monitor app, Instruments, most of them are already working but they need improvements).

Best regards,

Luiz

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

Hey! Thanks a lot for the effort!

I shared some ideas there, I hope you don't mind. I tried to nitpicking A LOT, not because something is wrong, but to offer alternatives so you can decide which is better for you. I'm pretty sure lots of my ideas won't be useful for everybody, but by giving as many suggestions as I could, chances are that more things could be potentially useful. Again, I hope you don't mind.

:)

Best regards,

Luiz

from swiftrex.

npvisual avatar npvisual commented on May 23, 2024

Updated with ViewProducer :
https://github.com/npvisual/ToDoList-Redux/blob/1.1.0/ToDoList-Redux/Binders/ViewProducers.swift

Still a little unclear about the need for Router in this particular case (where we only have one target View at the top). Is the Router's role more important when there are thing like Authentication views or Tabs involved ?

Is the Router simply going to present one view or another based on the AppState (i.e. authenticated session vs. unauthenticated, or a view based on which tab is selected ?

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

For most cases you don't actually need the ViewProducer, specially for this root view. But I still recommend, for:

  1. keep consistent with other views
  2. possibly reuse that between different platforms (while UIHostingController and NSHostingController will differ between platforms, the root view itself can be abstracted as a ViewProducer<Void, MyRootView> in a different binary and both platforms only glue that to their hosting controllers on Scene Delegate). For SwiftUI app lifecycle this is not super important anymore, still, you hide the router details from your app.
  3. easy replace in the future the root view, or its container type (tabs, navigation controller, etc), or authentication cases as you well mentioned.
  4. easily use this view producer from playgrounds, SwiftUI previews, tests, although you could create the view yourself, the view producer will contain the logic for sub-ViewProducers or other eventual dependency injections your view must need... In my case I have now a image cache dictionary that I inject in all my views, because I don't want these images to be in the app state... So from the state I only have the URL for these images, but the content itself is in this dictionary that I inject through ViewProducers, the same way we inject view models. This hurts redux a bit, but it is convenient when you have some performance constraints such as hundreds or thousands of images in a NSCache that can be cleanup by iOS silently in case of memory pressure... It's a very specific case, but maybe it can happen.

But you're free to go without this ViewProducer, or any view producer at all, as they are not part of the redux library, but more or less an architecture pattern that is working in my company projects. In the previous project we used to have RouterMiddlewares, RouterReducer, a NavigationTree in our AppState and the ViewProducers. This worked well, but it was TOO MUCH. Specially the NavigationTree that required us to send multiple branches of app state to app modules (for example, the branch to take care of the state itself plus the branch to take care of the navigation tree after the point where the module could be routed through, a modal, a navigation controller, etc). This showed itself too hard to manage, specially because we had to project/lift both directions of multiple-branch state with custom getters and setters.

Now, with only ViewProducer and the navigation part being part of the same state tree where the business logic is (for example, TodoList State has an optional let showingDetails: Todo.ID? which is the information required by the modal/navigation controller to present or not the details, where before this was in a navigation tree structure), it's much easier to pass the state back and fourth to sub-Modules.

Suggestions are always welcome, I personally find this model easy and flexible. Other redux libraries tried to use router middlewares to take care of navigation and I remember it used to bring several problems. ViewProducer is a simple way to check the full state and decide where you want to be, so your (State) -> WhereAmI function helps to route you through the app even in deep linking or time travel situation.

PS: Sorry I took so much time to reply this. I've been very busy with some personal matters nowadays, but I'm gonna try to help more on the Todo sample.

Best regards,

Luiz

from swiftrex.

luizmb avatar luizmb commented on May 23, 2024

I'm gonna archive this Issue for now as it has no activity for quite some time, so I guess this is all clear.
Ideally the docs should be improved and include some of this information, but there's a ticket for improving docs already (#11) so we can link here for future reference.

from swiftrex.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.