diff --git a/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift new file mode 100644 index 000000000..a5332c1fc --- /dev/null +++ b/RiotSwiftUI/Modules/Common/StateStore/StateStorePublisherExtensions.swift @@ -0,0 +1,26 @@ +import Foundation +import Combine + +@available(iOS 14.0, *) +extension Publisher { + + func sinkDispatchTo(_ store: StateStoreViewModel) where SA == Output, Failure == Never { + return self + .subscribe(on: DispatchQueue.main) + .sink { [weak store] (output) in + guard let store = store else { return } + store.dispatch(action: output) + } + .store(in: &store.cancellables) + } + + + func dispatchTo(_ store: StateStoreViewModel) -> Publishers.HandleEvents> where SA == Output, Failure == Never { + return self + .subscribe(on: DispatchQueue.main) + .handleEvents(receiveOutput: { [weak store] action in + guard let store = store else { return } + store.dispatch(action: action) + }) + } +} diff --git a/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift new file mode 100644 index 000000000..78ffe52d0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/ViewModel/StateStoreViewModel.swift @@ -0,0 +1,93 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +import Foundation +import Combine + +protocol BindableState { + associatedtype BindStateType = Void + var bindings: BindStateType { get set } +} + +extension BindableState where BindStateType == Void { + var bindings: Void { + get { + () + } + set { + fatalError("Can't bind to the default Void binding.") + } + } +} + +@available(iOS 14, *) +class ViewModelContext: ObservableObject { + + private var cancellables = Set() + + let inputActions: PassthroughSubject + @Published var inputState: ViewState.BindStateType + @Published fileprivate(set) var viewState: ViewState + + init(initialViewState: ViewState) { + self.inputState = initialViewState.bindings + self.inputActions = PassthroughSubject() + self.viewState = initialViewState + if !(initialViewState.bindings is Void) { + self.$inputState + .weakAssign(to: \.viewState.bindings, on: self) + .store(in: &cancellables) + } + } +} + +@available(iOS 14, *) +class StateStoreViewModel { + + typealias Context = ViewModelContext + + let state: CurrentValueSubject + + var cancellables = Set() + var context: Context + + init(initialViewState: State) { + + self.context = Context(initialViewState: initialViewState) + self.state = CurrentValueSubject(initialViewState) + self.state.weakAssign(to: \.context.viewState, on: self) + .store(in: &cancellables) + self.context.inputActions.sink { [weak self] action in + guard let self = self else { return } + self.process(viewAction: action) + } + .store(in: &cancellables) + } + func dispatch(action: StateAction) { + reducer(state: &state.value, action: action) + } + + func reducer(state: inout State, action: StateAction) { + + } + + func process(viewAction: ViewAction) { + + } +} diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift index 5818b543b..6d4a8780c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Coordinator/TemplateUserProfileCoordinator.swift @@ -39,8 +39,8 @@ final class TemplateUserProfileCoordinator: Coordinator { @available(iOS 14.0, *) init(parameters: TemplateUserProfileCoordinatorParameters) { self.parameters = parameters - let viewModel = TemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileService(session: parameters.session)) - let view = TemplateUserProfile(viewModel: viewModel) + let viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileService(session: parameters.session)) + let view = TemplateUserProfile(viewModel: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) templateUserProfileViewModel = viewModel templateUserProfileHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift index 1634b8c1d..e7e7b9317 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Model/TemplateUserProfileViewState.swift @@ -16,7 +16,7 @@ import Foundation -struct TemplateUserProfileViewState { +struct TemplateUserProfileViewState: BindableState { let avatar: AvatarInputProtocol? let displayName: String? var presence: TemplateUserProfilePresence diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift index 4388c4d1d..549716975 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/Service/Mock/MockTemplateUserProfileScreenState.swift @@ -50,11 +50,11 @@ enum MockTemplateUserProfileScreenState: MockScreenState, CaseIterable { case .longDisplayName(let displayName): service = MockTemplateUserProfileService(displayName: displayName) } - let viewModel = TemplateUserProfileViewModel(templateUserProfileService: service) + let viewModel = TemplateUserProfileViewModel.makeTemplateUserProfileViewModel(templateUserProfileService: service) // can simulate service and viewModel actions here if needs be. - return AnyView(TemplateUserProfile(viewModel: viewModel) + return AnyView(TemplateUserProfile(viewModel: viewModel.context) .addDependency(MockAvatarService.example)) } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift index 64cbf3ca4..ebde27b1c 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/View/TemplateUserProfile.swift @@ -27,9 +27,10 @@ struct TemplateUserProfile: View { // MARK: Public - @ObservedObject var viewModel: TemplateUserProfileViewModel + @ObservedObject var viewModel: TemplateUserProfileViewModel.Context var body: some View { + EmptyView() VStack { TemplateUserProfileHeader( avatar: viewModel.viewState.avatar, @@ -50,12 +51,12 @@ struct TemplateUserProfile: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button(VectorL10n.done) { - viewModel.process(viewAction: .cancel) + viewModel.inputActions.send(.done) } } ToolbarItem(placement: .cancellationAction) { Button(VectorL10n.cancel) { - viewModel.process(viewAction: .cancel) + viewModel.inputActions.send(.cancel) } } } diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift index ca1644b79..5b781c494 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModel.swift @@ -16,33 +16,36 @@ import SwiftUI import Combine - -@available(iOS 14.0, *) -class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewModelProtocol { + + + +@available(iOS 14, *) +typealias TemplateUserProfileViewModelType = StateStoreViewModel +@available(iOS 14, *) +class TemplateUserProfileViewModel: TemplateUserProfileViewModelType, TemplateUserProfileViewModelProtocol { // MARK: - Properties // MARK: Private + private let templateUserProfileService: TemplateUserProfileServiceProtocol - private var cancellables = Set() // MARK: Public - @Published private(set) var viewState: TemplateUserProfileViewState var completion: ((TemplateUserProfileViewModelResult) -> Void)? // MARK: - Setup - init(templateUserProfileService: TemplateUserProfileServiceProtocol, initialState: TemplateUserProfileViewState? = nil) { + + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol { + return TemplateUserProfileViewModel(templateUserProfileService: templateUserProfileService) + } + + fileprivate init(templateUserProfileService: TemplateUserProfileServiceProtocol) { self.templateUserProfileService = templateUserProfileService - self.viewState = initialState ?? Self.defaultState(templateUserProfileService: templateUserProfileService) - - templateUserProfileService.presenceSubject - .map(TemplateUserProfileStateAction.updatePresence) - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] action in - self?.dispatch(action:action) - }) - .store(in: &cancellables) + super.init(initialViewState: Self.defaultState(templateUserProfileService: templateUserProfileService)) + setupPresenceObserving() } private static func defaultState(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewState { @@ -53,8 +56,15 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod ) } + private func setupPresenceObserving() { + templateUserProfileService.presenceSubject + .map(TemplateUserProfileStateAction.updatePresence) + .sinkDispatchTo(self) + } + // MARK: - Public - func process(viewAction: TemplateUserProfileViewAction) { + + override func process(viewAction: TemplateUserProfileViewAction) { switch viewAction { case .cancel: cancel() @@ -62,13 +72,6 @@ class TemplateUserProfileViewModel: ObservableObject, TemplateUserProfileViewMod done() } } - - // MARK: - Private - /// Send state actions to mutate the state. - /// - Parameter action: The `TemplateUserProfileStateAction` to trigger the state change. - private func dispatch(action: TemplateUserProfileStateAction) { - Self.reducer(state: &self.viewState, action: action) - } /// A redux style reducer /// diff --git a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift index 4f038ae32..271ec3c38 100644 --- a/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Template/SimpleUserProfileExample/ViewModel/TemplateUserProfileViewModelProtocol.swift @@ -17,5 +17,10 @@ import Foundation protocol TemplateUserProfileViewModelProtocol { + var completion: ((TemplateUserProfileViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + static func makeTemplateUserProfileViewModel(templateUserProfileService: TemplateUserProfileServiceProtocol) -> TemplateUserProfileViewModelProtocol + @available(iOS 14, *) + var context: TemplateUserProfileViewModelType.Context { get } }