From 62918f67a3d014a2242c146e2f90b2f7a3682480 Mon Sep 17 00:00:00 2001 From: Jason Lew-Rapai Date: Wed, 17 Nov 2021 17:22:20 -0800 Subject: [PATCH] Add Wizard Coordinator Pattern --- MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj | 68 ++++++++++++ .../Architecture/CoordinatorAssembly.swift | 8 ++ .../Architecture/ViewModelAssembly.swift | 4 + .../AppRootCoordinator.swift | 13 +++ .../AppRootCoordinatorView.swift | 5 + .../ColorWizard/ColorWizardCoordinator.swift | 74 +++++++++++++ .../ColorWizardCoordinatorView.swift | 16 +++ .../ColorWizardConfiguration+Mock.swift | 27 +++++ .../ColorWizardConfiguration.swift | 24 ++++ .../ColorWizardConfigurationViewModel.swift | 47 ++++++++ .../Content/ColorWizardContentView.swift | 60 ++++++++++ .../Content/ColorWizardContentViewModel.swift | 60 ++++++++++ .../ColorWizardPageCoordinator.swift | 104 ++++++++++++++++++ .../ColorWizardPageCoordinatorView.swift | 19 ++++ .../UI/Landing/LandingView.swift | 7 ++ .../UI/Landing/LandingViewModel.swift | 9 ++ 16 files changed, 545 insertions(+) create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration+Mock.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfigurationViewModel.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentView.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentViewModel.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift create mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinatorView.swift diff --git a/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj b/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj index 2584c3a..dc7b996 100644 --- a/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj +++ b/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 9665D9552745C1D60055F1F6 /* ColorWizardCoordinatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9542745C1D60055F1F6 /* ColorWizardCoordinatorView.swift */; }; + 9665D9572745C1F10055F1F6 /* ColorWizardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9562745C1F10055F1F6 /* ColorWizardCoordinator.swift */; }; + 9665D95A2745C2540055F1F6 /* ColorWizardPageCoordinatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9592745C2540055F1F6 /* ColorWizardPageCoordinatorView.swift */; }; + 9665D95C2745C26F0055F1F6 /* ColorWizardPageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D95B2745C26F0055F1F6 /* ColorWizardPageCoordinator.swift */; }; + 9665D95F2745C2E00055F1F6 /* ColorWizardContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D95E2745C2E00055F1F6 /* ColorWizardContentView.swift */; }; + 9665D9612745C2FA0055F1F6 /* ColorWizardContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9602745C2FA0055F1F6 /* ColorWizardContentViewModel.swift */; }; + 9665D9632745C3410055F1F6 /* ColorWizardConfigurationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9622745C3410055F1F6 /* ColorWizardConfigurationViewModel.swift */; }; + 9665D9652745C3700055F1F6 /* ColorWizardConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9642745C3700055F1F6 /* ColorWizardConfiguration.swift */; }; + 9665D9682745C53B0055F1F6 /* ColorWizardConfiguration+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9665D9672745C53B0055F1F6 /* ColorWizardConfiguration+Mock.swift */; }; 96B4E06527432D6100EC88B3 /* MVVM_Demo_SwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E06427432D6100EC88B3 /* MVVM_Demo_SwiftUIApp.swift */; }; 96B4E06927432D6200EC88B3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 96B4E06827432D6200EC88B3 /* Assets.xcassets */; }; 96B4E06C27432D6200EC88B3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 96B4E06B27432D6200EC88B3 /* Preview Assets.xcassets */; }; @@ -70,6 +79,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 9665D9542745C1D60055F1F6 /* ColorWizardCoordinatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardCoordinatorView.swift; sourceTree = ""; }; + 9665D9562745C1F10055F1F6 /* ColorWizardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardCoordinator.swift; sourceTree = ""; }; + 9665D9592745C2540055F1F6 /* ColorWizardPageCoordinatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardPageCoordinatorView.swift; sourceTree = ""; }; + 9665D95B2745C26F0055F1F6 /* ColorWizardPageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardPageCoordinator.swift; sourceTree = ""; }; + 9665D95E2745C2E00055F1F6 /* ColorWizardContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardContentView.swift; sourceTree = ""; }; + 9665D9602745C2FA0055F1F6 /* ColorWizardContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardContentViewModel.swift; sourceTree = ""; }; + 9665D9622745C3410055F1F6 /* ColorWizardConfigurationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardConfigurationViewModel.swift; sourceTree = ""; }; + 9665D9642745C3700055F1F6 /* ColorWizardConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorWizardConfiguration.swift; sourceTree = ""; }; + 9665D9672745C53B0055F1F6 /* ColorWizardConfiguration+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ColorWizardConfiguration+Mock.swift"; sourceTree = ""; }; 96B4E06127432D6100EC88B3 /* MVVM.Demo.SwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MVVM.Demo.SwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96B4E06427432D6100EC88B3 /* MVVM_Demo_SwiftUIApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MVVM_Demo_SwiftUIApp.swift; sourceTree = ""; }; 96B4E06827432D6200EC88B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -143,6 +161,46 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9665D9532745C1B50055F1F6 /* ColorWizard */ = { + isa = PBXGroup; + children = ( + 9665D9692745CCB70055F1F6 /* Configuration */, + 9665D95D2745C2C80055F1F6 /* Content */, + 9665D9582745C2210055F1F6 /* PageCoordinator */, + 9665D9542745C1D60055F1F6 /* ColorWizardCoordinatorView.swift */, + 9665D9562745C1F10055F1F6 /* ColorWizardCoordinator.swift */, + ); + path = ColorWizard; + sourceTree = ""; + }; + 9665D9582745C2210055F1F6 /* PageCoordinator */ = { + isa = PBXGroup; + children = ( + 9665D9592745C2540055F1F6 /* ColorWizardPageCoordinatorView.swift */, + 9665D95B2745C26F0055F1F6 /* ColorWizardPageCoordinator.swift */, + ); + path = PageCoordinator; + sourceTree = ""; + }; + 9665D95D2745C2C80055F1F6 /* Content */ = { + isa = PBXGroup; + children = ( + 9665D95E2745C2E00055F1F6 /* ColorWizardContentView.swift */, + 9665D9602745C2FA0055F1F6 /* ColorWizardContentViewModel.swift */, + ); + path = Content; + sourceTree = ""; + }; + 9665D9692745CCB70055F1F6 /* Configuration */ = { + isa = PBXGroup; + children = ( + 9665D9642745C3700055F1F6 /* ColorWizardConfiguration.swift */, + 9665D9672745C53B0055F1F6 /* ColorWizardConfiguration+Mock.swift */, + 9665D9622745C3410055F1F6 /* ColorWizardConfigurationViewModel.swift */, + ); + path = Configuration; + sourceTree = ""; + }; 96B4E05827432D6100EC88B3 = { isa = PBXGroup; children = ( @@ -264,6 +322,7 @@ 96B4E0C52743312600EC88B3 /* UI */ = { isa = PBXGroup; children = ( + 9665D9532745C1B50055F1F6 /* ColorWizard */, 96B4E0C62743312F00EC88B3 /* AppRootCoordinator */, 96B4E0D22743381500EC88B3 /* Landing */, 96B4E0EC274424E800EC88B3 /* Pulse */, @@ -493,17 +552,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9665D9682745C53B0055F1F6 /* ColorWizardConfiguration+Mock.swift in Sources */, 96B4E0E427437CBA00EC88B3 /* SignInViewModel.swift in Sources */, 96B4E0AC27432F8900EC88B3 /* String+Extensions.swift in Sources */, 96B4E0AE27432FAB00EC88B3 /* UIApplication+EndEditing.swift in Sources */, 96B4E0DA2743390E00EC88B3 /* BrightBorderedButtonStyle.swift in Sources */, 96B4E0EB274386FA00EC88B3 /* Color+SystemColors.swift in Sources */, + 9665D95F2745C2E00055F1F6 /* ColorWizardContentView.swift in Sources */, + 9665D9632745C3410055F1F6 /* ColorWizardConfigurationViewModel.swift in Sources */, 96B4E0C4274330E600EC88B3 /* ViewModelAssembly.swift in Sources */, 96B4E09327432E4000EC88B3 /* HapticFeedbackProvider.swift in Sources */, 96B4E0C82743313900EC88B3 /* AppRootCoordinatorView.swift in Sources */, 96B4E09627432E6800EC88B3 /* CancelBag.swift in Sources */, 96B4E0D42743382B00EC88B3 /* LandingView.swift in Sources */, 96B4E0CF274335A900EC88B3 /* ColorService.swift in Sources */, + 9665D95A2745C2540055F1F6 /* ColorWizardPageCoordinatorView.swift in Sources */, 96B4E0CA2743314700EC88B3 /* AppRootCoordinator.swift in Sources */, 96B4E0B92743303F00EC88B3 /* View+OnReceive.swift in Sources */, 96B4E0D62743384F00EC88B3 /* LandingViewModel.swift in Sources */, @@ -516,19 +579,24 @@ 96B4E06527432D6100EC88B3 /* MVVM_Demo_SwiftUIApp.swift in Sources */, 96B4E0A727432F1C00EC88B3 /* PassthroughSubject+Extensions.swift in Sources */, 96B4E09827432E7D00EC88B3 /* Just+Void.swift in Sources */, + 9665D9552745C1D60055F1F6 /* ColorWizardCoordinatorView.swift in Sources */, 96B4E0B72743302100EC88B3 /* View+Navigation.swift in Sources */, + 9665D9612745C2FA0055F1F6 /* ColorWizardContentViewModel.swift in Sources */, 96B4E0D1274336EE00EC88B3 /* AlertService.swift in Sources */, 96B4E0BE2743309B00EC88B3 /* AppAssembler.swift in Sources */, 96B4E09C27432EB100EC88B3 /* Publisher+Extensions.swift in Sources */, 96B4E0F0274424FE00EC88B3 /* PulseViewModel.swift in Sources */, 96B4E0E227437CA200EC88B3 /* SignInView.swift in Sources */, 96B4E0DF2743396600EC88B3 /* ButtonTextStyle.swift in Sources */, + 9665D95C2745C26F0055F1F6 /* ColorWizardPageCoordinator.swift in Sources */, 96B4E0DD2743393E00EC88B3 /* TextStyle.swift in Sources */, 96B4E0B327432FEB00EC88B3 /* EdgeInsets+Extensions.swift in Sources */, 96B4E09A27432E9300EC88B3 /* Publisher+DefinedScheduler.swift in Sources */, 96B4E0EE274424F600EC88B3 /* PulseView.swift in Sources */, 96B4E0E927437D5300EC88B3 /* CardView.swift in Sources */, + 9665D9652745C3700055F1F6 /* ColorWizardConfiguration.swift in Sources */, 96B4E0CD274331EE00EC88B3 /* AuthenticationService.swift in Sources */, + 9665D9572745C1F10055F1F6 /* ColorWizardCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift b/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift index e1dfb04..3e933d6 100644 --- a/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift +++ b/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift @@ -13,5 +13,13 @@ class CoordinatorAssembly: Assembly { container.register(AppRootCoordinator.self) { r in AppRootCoordinator(resolver: r) }.inObjectScope(.container) + + container.register(ColorWizardCoordinator.self) { r in + ColorWizardCoordinator(resolver: r) + }.inObjectScope(.transient) + + container.register(ColorWizardPageCoordinator.self) { r in + ColorWizardPageCoordinator(resolver: r) + }.inObjectScope(.transient) } } diff --git a/MVVM.Demo.SwiftUI/Architecture/ViewModelAssembly.swift b/MVVM.Demo.SwiftUI/Architecture/ViewModelAssembly.swift index 2c56f20..38294bc 100644 --- a/MVVM.Demo.SwiftUI/Architecture/ViewModelAssembly.swift +++ b/MVVM.Demo.SwiftUI/Architecture/ViewModelAssembly.swift @@ -10,6 +10,10 @@ import Swinject class ViewModelAssembly: Assembly { func assemble(container: Container) { + container.register(ColorWizardContentViewModel.self) { r in + ColorWizardContentViewModel() + }.inObjectScope(.transient) + container.register(LandingViewModel.self) { r in LandingViewModel( alertService: r.resolve(AlertServiceProtocol.self)!, diff --git a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift index 284f7d8..e891cdf 100644 --- a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift +++ b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift @@ -16,6 +16,7 @@ class AppRootCoordinator: ViewModel { @Published var signInViewModel: SignInViewModel? @Published var pulseViewModel: PulseViewModel? + @Published var colorWizardCoordinator: ColorWizardCoordinator? @Published var alert: AlertService.Alert? init(resolver: Resolver) { @@ -38,6 +39,11 @@ extension AppRootCoordinator: LandingViewModelDelegate { .setup(delegate: self) } + func landingViewModelDidTapColorWizard(_ source: LandingViewModel) { + self.colorWizardCoordinator = self.resolver.resolve(ColorWizardCoordinator.self)! + .setup(configuration: ColorWizardConfiguration.mock(), delegate: self) + } + func landingViewModel(_ source: LandingViewModel, didAlertWith alert: AlertService.Alert) { DispatchQueue.main.async { self.alert = alert @@ -60,3 +66,10 @@ extension AppRootCoordinator: SignInViewModelDelegate { extension AppRootCoordinator: PulseViewModelDelegate { // Nothing yet } + +// MARK: ColorWizardCoordinatorDelegate +extension AppRootCoordinator: ColorWizardCoordinatorDelegate { + func colorWizardCoordinatorDidComplete(_ source: ColorWizardCoordinator) { + self.colorWizardCoordinator = nil + } +} diff --git a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift index ed94506..e9fc884 100644 --- a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift +++ b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift @@ -25,6 +25,11 @@ struct AppRootCoordinatorView: View { .navigation(item: self.$coordinator.pulseViewModel) { PulseView(viewModel: $0) } + .fullScreenCover(item: self.$coordinator.colorWizardCoordinator) { coordinator in + NavigationView { + ColorWizardCoordinatorView(coordiantor: coordinator) + } + } if let viewModel = self.signInViewModel { SignInView(viewModel: viewModel) diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift new file mode 100644 index 0000000..f53df87 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift @@ -0,0 +1,74 @@ +// +// ColorWizardCoordinator.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import Foundation +import Combine +import Swinject + +protocol ColorWizardCoordinatorDelegate: AnyObject { + func colorWizardCoordinatorDidComplete(_ source: ColorWizardCoordinator) +} + +class ColorWizardCoordinator: ViewModel { + private let resolver: Resolver + + private weak var delegate: ColorWizardCoordinatorDelegate? + private var configurationViewModel: ColorWizardConfigurationViewModel! + + @Published var colorWizardPageCoordinator: ColorWizardPageCoordinator! + + private var cancelBag = CancelBag() + + init(resolver: Resolver) { + self.resolver = resolver + } + + func setup(configuration: ColorWizardConfiguration, delegate: ColorWizardCoordinatorDelegate) -> Self { + self.delegate = delegate + self.configurationViewModel = ColorWizardConfigurationViewModel(configuration: configuration) + + if let firstPageViewModel = self.configurationViewModel.pages.first { + self.colorWizardPageCoordinator = self.resolver.resolve(ColorWizardPageCoordinator.self)! + .setup(currentPageViewModel: firstPageViewModel, delegate: self) + } else { + fatalError() + } + + return self + } +} + +// MARK: ColorWizardPageCoordinatorDelegate +extension ColorWizardCoordinator: ColorWizardPageCoordinatorDelegate { + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveBackFromIndex index: Int) -> Bool { + return index != 0 + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveForwardFromIndex index: Int) -> Bool { + return self.configurationViewModel.pages.count > index + 1 + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canCompleteFromIndex index: Int) -> Bool { + return self.configurationViewModel.pages.count == index + 1 + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didMoveBackFromIndex index: Int) { + fatalError("A back command should never reach this coordinator.") + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, nextPageAfterIndex index: Int) -> ColorWizardConfigurationViewModel.PageViewModel? { + let newIndex = index + 1 + guard newIndex < self.configurationViewModel.pages.count else { + return nil + } + return self.configurationViewModel.pages[newIndex] + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didCompleteFromIndex index: Int) { + self.delegate?.colorWizardCoordinatorDidComplete(self) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift new file mode 100644 index 0000000..b0cead6 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift @@ -0,0 +1,16 @@ +// +// ColorWizardCoordinatorView.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import SwiftUI + +struct ColorWizardCoordinatorView: View { + @ObservedObject var coordiantor: ColorWizardCoordinator + + var body: some View { + ColorWizardPageCoordinatorView(coordinator: self.coordiantor.colorWizardPageCoordinator) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration+Mock.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration+Mock.swift new file mode 100644 index 0000000..8070b4e --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration+Mock.swift @@ -0,0 +1,27 @@ +// +// ColorWizardConfiguration+Mock.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import Foundation + +extension ColorWizardConfiguration { + static func mock() -> ColorWizardConfiguration { + ColorWizardConfiguration(pages: [ + .page("First Color", color: .green), + .page("Second Color", color: .orange), + .page("Third Color", color: .systemIndigo), + .page("Fourth Color", color: .pink), + .page("Fifth Color", color: .purple), + .page("Summary", colors: [ + .green, + .orange, + .systemIndigo, + .pink, + .purple, + ]), + ]) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration.swift new file mode 100644 index 0000000..a08bbc0 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfiguration.swift @@ -0,0 +1,24 @@ +// +// ColorWizardConfiguration.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import SwiftUI + +struct ColorWizardConfiguration { + let pages: [Page] +} + +extension ColorWizardConfiguration { + struct Page { + let title: String + let color: Color? + let colors: [Color] + + static func page(_ title: String, color: Color? = nil, colors: [Color] = []) -> Page { + Page(title: title, color: color, colors: colors) + } + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfigurationViewModel.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfigurationViewModel.swift new file mode 100644 index 0000000..0eee111 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/Configuration/ColorWizardConfigurationViewModel.swift @@ -0,0 +1,47 @@ +// +// ColorWizardConfigurationViewModel.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import Foundation +import Combine +import SwiftUI + +class ColorWizardConfigurationViewModel: ViewModel { + let pages: [PageViewModel] + + init(configuration: ColorWizardConfiguration) { + var pageViewModels: [PageViewModel] = [] + for (index, page) in configuration.pages.enumerated() { + pageViewModels.append(PageViewModel(page: page, index: index)) + } + self.pages = pageViewModels + } +} + +extension ColorWizardConfigurationViewModel { + class PageViewModel: ViewModel { + let index: Int + let title: String + let color: Color? + let colors: [ColorViewModel] + + init(page: ColorWizardConfiguration.Page, index: Int) { + self.index = index + self.title = page.title + self.color = page.color + self.colors = page.colors.map { ColorViewModel(color: $0) } + } + } + + class ColorViewModel: ViewModel { + let id: String = UUID().uuidString + let color: Color + + init(color: Color) { + self.color = color + } + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentView.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentView.swift new file mode 100644 index 0000000..218412d --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentView.swift @@ -0,0 +1,60 @@ +// +// ColorWizardContentView.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import SwiftUI + +struct ColorWizardContentView: View { + @ObservedObject var viewModel: ColorWizardContentViewModel + + var body: some View { + ZStack { + if let color = self.viewModel.color { + Rectangle() + .fill(Color.clear) + .background(color) + } else { + ScrollView { + VStack { + ForEach(self.viewModel.colors, id: \.id) { color in + RoundedRectangle(cornerRadius: 36.0, style: .continuous) + .fill(color.color) + .frame(maxWidth: .infinity, minHeight: 54.0, idealHeight: 54.0, maxHeight: 54.0) + .padding([.leading, .trailing], 16.0) + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(self.viewModel.title) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if self.viewModel.canMoveBack { + Button(action: { self.viewModel.moveBack() }) { + Text("Back") + } + } else { + EmptyView() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if self.viewModel.canMoveForward { + Button(action: { self.viewModel.moveForward() }) { + Text("Forward") + } + } else if self.viewModel.canComplete { + Button(action: { self.viewModel.complete() }) { + Text("Done") + } + } else { + EmptyView() + } + } + } + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentViewModel.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentViewModel.swift new file mode 100644 index 0000000..aee32f4 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/Content/ColorWizardContentViewModel.swift @@ -0,0 +1,60 @@ +// +// ColorWizardContentViewModel.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import Foundation +import SwiftUI + +protocol ColorWizardContentViewModelDelegate: AnyObject { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveBackFromIndex index: Int) -> Bool + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveForwardFromIndex index: Int) -> Bool + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canCompleteFromIndex index: Int) -> Bool + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveBackFromIndex index: Int) + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveForwardFromIndex index: Int) + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didCompleteFromIndex index: Int) +} + +class ColorWizardContentViewModel: ViewModel { + private var pageViewModel: ColorWizardConfigurationViewModel.PageViewModel! + private weak var delegate: ColorWizardContentViewModelDelegate? + + var index: Int { self.pageViewModel.index } + var title: String { self.pageViewModel.title } + var color: Color? { self.pageViewModel.color } + var colors: [ColorWizardConfigurationViewModel.ColorViewModel] { self.pageViewModel.colors } + + var canMoveBack: Bool { + self.delegate?.colorWizardContentViewModel(self, canMoveBackFromIndex: self.index) ?? false + } + + var canMoveForward: Bool { + self.delegate?.colorWizardContentViewModel(self, canMoveForwardFromIndex: self.index) ?? false + } + + var canComplete: Bool { + self.delegate?.colorWizardContentViewModel(self, canCompleteFromIndex: self.index) ?? false + } + + @discardableResult + func setup(pageViewModel: ColorWizardConfigurationViewModel.PageViewModel, delegate: ColorWizardContentViewModelDelegate?) -> Self { + self.pageViewModel = pageViewModel + self.delegate = delegate + return self + } + + func moveBack() { + self.delegate?.colorWizardContentViewModel(self, didMoveBackFromIndex: self.index) + } + + func moveForward() { + self.delegate?.colorWizardContentViewModel(self, didMoveForwardFromIndex: self.index) + } + + func complete() { + self.delegate?.colorWizardContentViewModel(self, didCompleteFromIndex: self.index) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift new file mode 100644 index 0000000..cd2e3e2 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift @@ -0,0 +1,104 @@ +// +// ColorWizardPageCoordinator.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import Foundation +import Combine +import Swinject + +protocol ColorWizardPageCoordinatorDelegate: AnyObject { + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveBackFromIndex index: Int) -> Bool + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveForwardFromIndex index: Int) -> Bool + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canCompleteFromIndex index: Int) -> Bool + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didMoveBackFromIndex index: Int) + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, nextPageAfterIndex index: Int) -> ColorWizardConfigurationViewModel.PageViewModel? + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didCompleteFromIndex index: Int) +} + +class ColorWizardPageCoordinator: ViewModel { + private let resolver: Resolver + + private weak var delegate: ColorWizardPageCoordinatorDelegate? + + @Published var contentViewModel: ColorWizardContentViewModel! + @Published var nextPageCoordinator: ColorWizardPageCoordinator? + + init(resolver: Resolver) { + self.resolver = resolver + } + + func setup(currentPageViewModel: ColorWizardConfigurationViewModel.PageViewModel, delegate: ColorWizardPageCoordinatorDelegate) -> Self { + self.delegate = delegate + + self.contentViewModel = self.resolver.resolve(ColorWizardContentViewModel.self)! + .setup(pageViewModel: currentPageViewModel, delegate: self) + + return self + } +} + +// MARK: ColorWizardPageCoordinatorDelegate +extension ColorWizardPageCoordinator: ColorWizardPageCoordinatorDelegate { + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveBackFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canMoveBackFromIndex: index) ?? false + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveForwardFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canMoveForwardFromIndex: index) ?? false + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canCompleteFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canCompleteFromIndex: index) ?? false + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didMoveBackFromIndex index: Int) { + if self.contentViewModel.index < index { + self.nextPageCoordinator = nil + } else { + self.delegate?.colorWizardPageCoordinator(self, didMoveBackFromIndex: index) + } + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, nextPageAfterIndex index: Int) -> ColorWizardConfigurationViewModel.PageViewModel? { + self.delegate?.colorWizardPageCoordinator(self, nextPageAfterIndex: index) + } + + func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didCompleteFromIndex index: Int) { + self.delegate?.colorWizardPageCoordinator(self, didCompleteFromIndex: index) + } +} + +// MARK: ColorWizardContentViewModelDelegate +extension ColorWizardPageCoordinator: ColorWizardContentViewModelDelegate { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveBackFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canMoveBackFromIndex: index) ?? false + } + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveForwardFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canMoveForwardFromIndex: index) ?? false + } + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canCompleteFromIndex index: Int) -> Bool { + self.delegate?.colorWizardPageCoordinator(self, canCompleteFromIndex: index) ?? false + } + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveBackFromIndex index: Int) { + self.delegate?.colorWizardPageCoordinator(self, didMoveBackFromIndex: index) + } + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveForwardFromIndex index: Int) { + guard let nextPageViewModel = self.delegate?.colorWizardPageCoordinator(self, nextPageAfterIndex: index) else { + fatalError() + } + self.nextPageCoordinator = self.resolver.resolve(ColorWizardPageCoordinator.self)! + .setup(currentPageViewModel: nextPageViewModel, delegate: self) + } + + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didCompleteFromIndex index: Int) { + self.delegate?.colorWizardPageCoordinator(self, didCompleteFromIndex: index) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinatorView.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinatorView.swift new file mode 100644 index 0000000..e137872 --- /dev/null +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinatorView.swift @@ -0,0 +1,19 @@ +// +// ColorWizardPageCoordinatorView.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 11/17/21. +// + +import SwiftUI + +struct ColorWizardPageCoordinatorView: View { + @ObservedObject var coordinator: ColorWizardPageCoordinator + + var body: some View { + ColorWizardContentView(viewModel: self.coordinator.contentViewModel) + .navigation(item: self.$coordinator.nextPageCoordinator) { coordinator in + ColorWizardPageCoordinatorView(coordinator: coordinator) + } + } +} diff --git a/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift b/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift index feb4542..d83cb21 100644 --- a/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift +++ b/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift @@ -34,6 +34,13 @@ struct LandingView: View { .contentShape(Rectangle()) } .buttonStyle(BrightBorderedButtonStyle(color: self.pulseColor)) + + Button(action: self.viewModel.colorWizard) { + Text("Color Wizard") + .frame(maxWidth: .infinity, minHeight: 54.0, idealHeight: 54.0, maxHeight: 54.0) + .contentShape(Rectangle()) + } + .buttonStyle(BrightBorderedButtonStyle()) } .padding([.leading, .trailing], 48.0) diff --git a/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift b/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift index f5acf96..a24865a 100644 --- a/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift +++ b/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift @@ -12,6 +12,7 @@ import SwiftUI protocol LandingViewModelDelegate: AnyObject { func landingViewModelDidTapPulse(_ source: LandingViewModel) func landingViewModelDidTapSignIn(_ source: LandingViewModel) + func landingViewModelDidTapColorWizard(_ source: LandingViewModel) func landingViewModel(_ source: LandingViewModel, didAlertWith alert: AlertService.Alert) } @@ -30,6 +31,7 @@ class LandingViewModel: ViewModel { let pulse: PassthroughSubject = PassthroughSubject() let signInOrOut: PassthroughSubject = PassthroughSubject() + let colorWizard: PassthroughSubject = PassthroughSubject() private var cancelBag: CancelBag! @@ -91,6 +93,13 @@ class LandingViewModel: ViewModel { } } .store(in: &self.cancelBag) + + self.colorWizard + .sink(receiveValue: { [weak self] in + guard let self = self else { return } + self.delegate?.landingViewModelDidTapColorWizard(self) + }) + .store(in: &self.cancelBag) } }