From 9998fadd3b46368370a9d597c840d16d074168eb Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Wed, 5 Apr 2023 19:19:05 +0200 Subject: [PATCH] Refactor vm context (#769) --- .../ViewModel/StateStoreViewModel.swift | 99 +++++++++---------- .../Sources/Extensions/ViewModelContext.swift | 4 +- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift index 9dad366d4..861f9a55f 100644 --- a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift @@ -17,49 +17,6 @@ import Combine import Foundation -/// A constrained and concise interface for interacting with the ViewModel. -/// -/// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact -/// ViewModel (as modelled on our previous template architecture with the addition of two-way binding): -/// - The ability read/observe view state -/// - The ability to send view events -/// - The ability to bind state to a specific portion of the view state safely. -/// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published` -/// properties which which are property wrappers and therefore can't be defined within protocols. -/// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback). -/// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks -/// can't be made into the `ViewModel`. -@dynamicMemberLookup -@MainActor -class ViewModelContext: ObservableObject { - fileprivate let viewActions: PassthroughSubject - - /// Get-able/Observable `Published` property for the `ViewState` - @Published fileprivate(set) var viewState: ViewState - - /// An optional image loading service so that views can manage themselves - /// Intentionally non-generic so that it doesn't grow uncontrollably - let imageProvider: ImageProviderProtocol? - - /// Set-able/Bindable access to the bindable state. - subscript(dynamicMember keyPath: WritableKeyPath) -> T { - get { viewState.bindings[keyPath: keyPath] } - set { viewState.bindings[keyPath: keyPath] = newValue } - } - - init(initialViewState: ViewState, imageProvider: ImageProviderProtocol?) { - self.viewActions = PassthroughSubject() - self.viewState = initialViewState - self.imageProvider = imageProvider - } - - /// Send a `ViewAction` to the `ViewModel` for processing. - /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. - func send(viewAction: ViewAction) { - viewActions.send(viewAction) - } -} - /// A common ViewModel implementation for handling of `State` and `ViewAction`s /// /// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) @@ -68,8 +25,6 @@ class ViewModelContext: ObservableObject { /// we can do it in this centralised place. @MainActor class StateStoreViewModel { - typealias Context = ViewModelContext - /// For storing subscription references. /// /// Left as public for `ViewModel` implementations convenience. @@ -77,7 +32,7 @@ class StateStoreViewModel { /// Constrained interface for passing to Views. var context: Context - + var state: State { get { context.viewState } set { context.viewState = newValue } @@ -85,13 +40,7 @@ class StateStoreViewModel { init(initialViewState: State, imageProvider: ImageProviderProtocol? = nil) { context = Context(initialViewState: initialViewState, imageProvider: imageProvider) - context.viewActions - .sink { [weak self] action in - guard let self else { return } - - self.process(viewAction: action) - } - .store(in: &cancellables) + context.viewModel = self } /// Override to handles incoming `ViewAction`s from the `ViewModel`. @@ -99,4 +48,48 @@ class StateStoreViewModel { func process(viewAction: ViewAction) { // Default implementation, -no-op } + + // MARK: - Context + + /// A constrained and concise interface for interacting with the ViewModel. + /// + /// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact + /// ViewModel (as modelled on our previous template architecture with the addition of two-way binding): + /// - The ability read/observe view state + /// - The ability to send view events + /// - The ability to bind state to a specific portion of the view state safely. + /// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published` + /// properties which which are property wrappers and therefore can't be defined within protocols. + /// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback). + /// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks + /// can't be made into the `ViewModel`. + @dynamicMemberLookup + @MainActor + final class Context: ObservableObject { + fileprivate weak var viewModel: StateStoreViewModel? + + /// Get-able/Observable `Published` property for the `ViewState` + @Published fileprivate(set) var viewState: State + + /// An optional image loading service so that views can manage themselves + /// Intentionally non-generic so that it doesn't grow uncontrollably + let imageProvider: ImageProviderProtocol? + + /// Set-able/Bindable access to the bindable state. + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { viewState.bindings[keyPath: keyPath] } + set { viewState.bindings[keyPath: keyPath] = newValue } + } + + /// Send a `ViewAction` to the `ViewModel` for processing. + /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. + func send(viewAction: ViewAction) { + viewModel?.process(viewAction: viewAction) + } + + fileprivate init(initialViewState: State, imageProvider: ImageProviderProtocol?) { + self.viewState = initialViewState + self.imageProvider = imageProvider + } + } } diff --git a/UnitTests/Sources/Extensions/ViewModelContext.swift b/UnitTests/Sources/Extensions/ViewModelContext.swift index 725eebbbe..431f0bc4c 100644 --- a/UnitTests/Sources/Extensions/ViewModelContext.swift +++ b/UnitTests/Sources/Extensions/ViewModelContext.swift @@ -16,9 +16,9 @@ @testable import ElementX -extension ViewModelContext { +extension StateStoreViewModel.Context { @discardableResult - func nextViewState() async -> ViewState? { + func nextViewState() async -> State? { await $viewState.nextValue } }