nalexn / clean-architecture-swiftui

SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Question] How would you react to state changes in view

alexanderwe opened this issue · comments

Hi there !

At first thank you for providing this repository and your related blog posts. I enjoyed reading them and they helped me a lot.

But I have a question about reacting to @State changes in Views in this architecture.


For example I have the following case in my application:

I have a view which displays a list of tasks for a specific date interval. It has your dependency injection container injected:

@Environment(\.injected) private var injected: DIContainer
@State private var dateInterval: DateInterval = DateInterval(start: Date().startOfWeek ?? Date(), end: Date().endOfWeek ?? Date())

Initially the data is fetched with a method that looks like this:

func fetch() {
    injected.interactors.myInteractor
        .fetchData(range: self.dateInterval)
        .store(in: cancelBag)
}

The view has a child which accepts a binding to the dateInterval and is able to change it:

 WeeksTaskGraph(dateInterval: self.$dateInterval, tasks: tasks)

Now I need to refetch data when the binding changes. So basically I would need to run the fetch method again when the dateInterval changes. I tried to create an ObservableObject ViewModel class for the view, for encapsulating this kind of functionality. It looks roughly like this:

class DateIntervalModel: ObservableObject {
    
    private let cancelBag = CancelBag()
    
    @Environment(\.injected) private var injected: DIContainer
    
    @Published var dateInterval: DateInterval = DateInterval(start: Date().startOfWeek ?? Date(), end: Date().endOfWeek ?? Date()) {
        didSet {
            print("Date interval changed")
             injected.interactors.myInteractor.fetchData(range: self.dateInterval)
                .store(in: cancelBag)
        }
    }
}

But as seen this class would then need access to the @EnvironmentObject which I could not find a solution how to achieve that - since it is not part of the view hierarchy.

Do you maybe have an approach or a suggestion how this can be achieved with the clean architecture ?

Any help is appreciated ! If this is the wrong place for asking this kind of questions feel free to close the issue or tell me so.

I think the best option for maintaining the unidirectional data flow in clean architecture is to have a closure callback for Binding mutation, from where you can trigger the side effect (call that fetch function).

So here is how I'd approach this:

// The extension that allows for attaching a callback to a binding:
extension Binding {
    func didSet(_ callback: @escaping (Value) -> Void) -> Self {
        return .init(get: { () -> Value in
            self.wrappedValue
        }, set: { value in
            self.wrappedValue = value
            callback(value)
        })
    }
}
WeeksTaskGraph(dateInterval: self.$dateInterval.didSet({ dateValue in
     self.fetch(dateValue)
}), tasks: tasks)

Let me know if this solves the problem!

@nalexn Thanks a lot for your code snippet - it worked like a charm and solved my problem !

One question though: Do you think it would make sense to introduce view models in this kind of architecture or would this be a "no go" since it is basically mixing up two architectures ?

@alexanderwe there cannot be a "no go" in mixing different approaches. The only thing that matters is the possible collateral damage of making the codebase harder to grasp. I never was a fan of VIPER, but one of the reasons why it's good is because VIPER's module is standardized: any developer who's familiar with VIPER will quickly figure out how specific screen works even if he's seeing the code for the first time.

What should not be mixed, IMO, are architectures with unidirectional data flows and multi-directional data flows. For example, using NotificationCenter for data transfers for some screens and REDUX for the others. This will be a huge mess.

@nalexn Thanks a lot for your time and thoughts on that. I primarily asked because I created some ObservableObject classes as "ViewModels" which will connect to the dependency injector and then my views are using these models to react to state changes etc. I think I'll simply try it out and see how it goes.

I definitely agree with your last part. I also discourage the use of different data flows.

I'll close this since everything is answered, thanks ! :)

@alexanderwe EnvironmentObject and Environment injections work best for something centric or used by multiple screens. In this sample project, Interactors serve more like "services" rather than as 1-to-1 view models for the views, because multiple screens can use the same Interactor if they partially share certain business logic.
As opposed to the "services", ViewModel has 1-to-1 correspondence with the View, and ideally, other views should not have access to other view's ViewModels. If you include your ViewModels in injected container, you will inevitably expose all ViewModels to all Views, which is not ideal. I'd recommend looking into breaking up the injected into multiple independent pieces or using the @ObservedObject injection

@nalexn I think I said it wrong. I do not expose the view models via the container and rather inject the container into the view models. An example a view model could look like this.

class MyViewModel: ObservableObject {
    @Published var ...
      
    private let cancelBag = CancelBag()
    private var injected: DIContainer    
    init(injected: DIContainer) {
        self.injected = injected
    }
}

And then the idea is to use it like this:

struct ContainerView: View {
    
    @Environment(\.injected) private var injected: DIContainer
    
    var body: some View {
        MyView(viewModel: MyViewModel(injected: injected))
    }
}
struct MyView: View {
    @ObservedObject var model: MyViewModel
    
    init(viewModel: MyViewModel) {
        self.model = viewModel
    }
}

But I am not entirely sure if I get into trouble with this kind of approach if the app grows in size - or if I should rather keep everything inside the views themselves.

@alexanderwe that's a great idea! This is a more modular approach that I've initially proposed, where "services" (Interactors) are fed to the ViewModels. So it should be even more scalable (at a cost of adding more modules).

Share your thoughts on which one you end up using!

@nalexn I just wanted to share what I ended up using now.

So I decided to go on with the approach I posted earlier of injecting the container into view models and then my views take the view models as a parameter. With that I can keep the views clean of handling all of the state and let the view models take care of that. My app is currently rather small with round about 5 view models in total so I can not talk about scalability that much, but for now it seems as the go to solution for my use case.

@alexanderwe Thanks for sharing! Are the VMs backed by the centralized state or how did you organize the data flow?

@nalexn The VMs are reacting to changes in a centralized state. So for example I have a view which shows an overview of a list of tasks. The corresponding view model is as follows:

class OverviewViewModel: ObservableObject {
    
    @Published var dateInterval: DateInterval = DateInterval(start: Date().startOfWeek?.startOfDay ?? Date(), end: Date().endOfWeek?.endOfDay ?? Date()) {
          didSet {
            self.fetch()
          }
      }
     @Published var tasks: Loadable<[Task]> = .initial
      
    private let cancelBag = CancelBag()
    private var injected: DIContainer
    private let taskUpdates: AnyPublisher<Loadable<[Task]>, Never>
    
    init(injected: DIContainer) {
        self.injected = injected
        self.taskUpdates = injected.appState.updates(for: \.userData.weeksTasks)
        
        taskUpdates.sink {
            self.tasks = $0
        }.store(in: cancelBag)
    }
    
    // MARK: Side Effects
    func fetch() {
        injected.interactors.apiInteractor
            .fetchWeeksTasksBy(range: self.dateInterval)
            .store(in: cancelBag)
    }
}

So basically what I did is to "free" the views of handling the states and side effects and move them up to the view model.

struct Overview: View {
    @ObservedObject var model: OverviewViewModel
    
    init(viewModel: OverviewViewModel) {
        self.model = viewModel
    }
  ...
}

@alexanderwe I like it! This should be intrinsically more testable 👍

@nalexn I hope so too ! Until now this pattern served me well and I'll continue using it. Maybe there are some downsides to it with very complex data states but for now I did not encounter any problems