From f677c218127fb12cb2dbfb0da5239f075170dcd1 Mon Sep 17 00:00:00 2001 From: Jason Lew-Rapai Date: Wed, 19 Apr 2023 23:16:08 -0700 Subject: [PATCH] iOS 16 Update * iOS 16 Update Updated the app to use iOS 16 patterns and features. * Accessibility updates * Refactor Color view out of ColorService Someone on the internet didn't understand abstraction and they were mad that I used Color (the View) inside a service. I refactored it out with this update which really doesn't change much. * Update README with project architecture description * Add older OS reference to the README --- MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj | 55 +++++---- .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../Architecture/CoordinatorAssembly.swift | 4 - .../Architecture/ServiceAssembly.swift | 14 ++- .../Architecture/ViewModel.swift | 12 +- .../Core/SwiftUI/ObjectNavigationStack.swift | 80 ++++++++++++++ .../Core/SwiftUI/View+Navigation.swift | 70 ------------ MVVM.Demo.SwiftUI/MVVM_Demo_SwiftUIApp.swift | 2 + .../Services/AuthenticationService.swift | 18 ++- MVVM.Demo.SwiftUI/Services/ColorService.swift | 22 +++- .../AppRootCoordinator.swift | 7 +- .../AppRootCoordinatorView.swift | 8 +- .../ColorWizard/ColorWizardCoordinator.swift | 33 +++--- .../ColorWizardCoordinatorView.swift | 7 +- .../ColorWizardPageCoordinator.swift | 104 ------------------ .../ColorWizardPageCoordinatorView.swift | 19 ---- .../UI/Landing/LandingView.swift | 38 +++++-- .../UI/Landing/LandingViewModel.swift | 2 +- MVVM.Demo.SwiftUI/UI/Pulse/PulseView.swift | 6 +- .../UI/Pulse/PulseViewModel.swift | 13 ++- MVVM.Demo.SwiftUI/UI/SignIn/SignInView.swift | 74 ++++++++++--- .../BrightBorderedButtonStyle.swift | 10 ++ .../Styles/TextStyles/ButtonTextStyle.swift | 4 +- .../AuthenticationService+Tests.swift | 3 +- .../Services/ColorService+Tests.swift | 8 +- README.md | 41 +++++++ 26 files changed, 374 insertions(+), 289 deletions(-) create mode 100644 MVVM.Demo.SwiftUI/Core/SwiftUI/ObjectNavigationStack.swift delete mode 100644 MVVM.Demo.SwiftUI/Core/SwiftUI/View+Navigation.swift delete mode 100644 MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift delete 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 9c5cc4e..8b862fa 100644 --- a/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj +++ b/MVVM.Demo.SwiftUI.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 6FD86CD9293DE9060052C759 /* ObjectNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD86CD8293DE9060052C759 /* ObjectNavigationStack.swift */; }; + 6FD86CDC2940033C0052C759 /* BusyIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 6FD86CDB2940033C0052C759 /* BusyIndicator */; }; 9647788B2774F0EE002203DE /* AnyPublisher+AwaitResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9647788A2774F0EE002203DE /* AnyPublisher+AwaitResults.swift */; }; 964778902774F240002203DE /* AuthenticationService+Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9647788F2774F240002203DE /* AuthenticationService+Tests.swift */; }; 964778922774F64A002203DE /* Result+TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964778912774F64A002203DE /* Result+TestExtensions.swift */; }; 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 */; }; @@ -40,7 +40,6 @@ 96B4E0AE27432FAB00EC88B3 /* UIApplication+EndEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0AD27432FAB00EC88B3 /* UIApplication+EndEditing.swift */; }; 96B4E0B027432FD000EC88B3 /* Button+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0AF27432FD000EC88B3 /* Button+Extensions.swift */; }; 96B4E0B327432FEB00EC88B3 /* EdgeInsets+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0B227432FEB00EC88B3 /* EdgeInsets+Extensions.swift */; }; - 96B4E0B72743302100EC88B3 /* View+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0B62743302100EC88B3 /* View+Navigation.swift */; }; 96B4E0B92743303F00EC88B3 /* View+OnReceive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0B82743303F00EC88B3 /* View+OnReceive.swift */; }; 96B4E0BE2743309B00EC88B3 /* AppAssembler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0BD2743309B00EC88B3 /* AppAssembler.swift */; }; 96B4E0C0274330B400EC88B3 /* CoordinatorAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B4E0BF274330B400EC88B3 /* CoordinatorAssembly.swift */; }; @@ -86,13 +85,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 6FBF3B6729F10AA2008BB5B7 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; + 6FD86CD8293DE9060052C759 /* ObjectNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectNavigationStack.swift; sourceTree = ""; }; 9647788A2774F0EE002203DE /* AnyPublisher+AwaitResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+AwaitResults.swift"; sourceTree = ""; }; 9647788F2774F240002203DE /* AuthenticationService+Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationService+Tests.swift"; sourceTree = ""; }; 964778912774F64A002203DE /* Result+TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+TestExtensions.swift"; sourceTree = ""; }; 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 = ""; }; @@ -120,7 +119,6 @@ 96B4E0AD27432FAB00EC88B3 /* UIApplication+EndEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+EndEditing.swift"; sourceTree = ""; }; 96B4E0AF27432FD000EC88B3 /* Button+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Extensions.swift"; sourceTree = ""; }; 96B4E0B227432FEB00EC88B3 /* EdgeInsets+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EdgeInsets+Extensions.swift"; sourceTree = ""; }; - 96B4E0B62743302100EC88B3 /* View+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Navigation.swift"; sourceTree = ""; }; 96B4E0B82743303F00EC88B3 /* View+OnReceive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+OnReceive.swift"; sourceTree = ""; }; 96B4E0BD2743309B00EC88B3 /* AppAssembler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembler.swift; sourceTree = ""; }; 96B4E0BF274330B400EC88B3 /* CoordinatorAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorAssembly.swift; sourceTree = ""; }; @@ -155,6 +153,7 @@ files = ( 96B4E0A227432EEE00EC88B3 /* CombineExt in Frameworks */, 96B4E0A527432EFC00EC88B3 /* Swinject in Frameworks */, + 6FD86CDC2940033C0052C759 /* BusyIndicator in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -199,22 +198,12 @@ 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 = ( @@ -268,6 +257,7 @@ 96B4E06327432D6100EC88B3 /* MVVM.Demo.SwiftUI */ = { isa = PBXGroup; children = ( + 6FBF3B6729F10AA2008BB5B7 /* README.md */, 96B4E0BC2743306A00EC88B3 /* Architecture */, 96B4E08F27432DEB00EC88B3 /* Core */, 96B4E0CB274331DB00EC88B3 /* Services */, @@ -344,7 +334,7 @@ 96B4E0AF27432FD000EC88B3 /* Button+Extensions.swift */, 96B4E0EA274386FA00EC88B3 /* Color+SystemColors.swift */, 96B4E0B227432FEB00EC88B3 /* EdgeInsets+Extensions.swift */, - 96B4E0B62743302100EC88B3 /* View+Navigation.swift */, + 6FD86CD8293DE9060052C759 /* ObjectNavigationStack.swift */, 96B4E0B82743303F00EC88B3 /* View+OnReceive.swift */, ); path = SwiftUI; @@ -494,6 +484,7 @@ packageProductDependencies = ( 96B4E0A127432EEE00EC88B3 /* CombineExt */, 96B4E0A427432EFC00EC88B3 /* Swinject */, + 6FD86CDB2940033C0052C759 /* BusyIndicator */, ); productName = MVVM.Demo.SwiftUI; productReference = 96B4E06127432D6100EC88B3 /* MVVM.Demo.SwiftUI.app */; @@ -570,6 +561,7 @@ packageReferences = ( 96B4E0A027432EEE00EC88B3 /* XCRemoteSwiftPackageReference "CombineExt" */, 96B4E0A327432EFC00EC88B3 /* XCRemoteSwiftPackageReference "Swinject" */, + 6FD86CDA2940033C0052C759 /* XCRemoteSwiftPackageReference "BusyIndicator" */, ); productRefGroup = 96B4E06227432D6100EC88B3 /* Products */; projectDirPath = ""; @@ -627,7 +619,6 @@ 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 */, @@ -640,7 +631,6 @@ 96B4E0A727432F1C00EC88B3 /* PassthroughSubject+Extensions.swift in Sources */, 96B4E09827432E7D00EC88B3 /* Just+Void.swift in Sources */, 9665D9552745C1D60055F1F6 /* ColorWizardCoordinatorView.swift in Sources */, - 96B4E0B72743302100EC88B3 /* View+Navigation.swift in Sources */, 968D344D27CD941700340C18 /* AlertService.AlertPackage+View.swift in Sources */, 9665D9612745C2FA0055F1F6 /* ColorWizardContentViewModel.swift in Sources */, 96B4E0D1274336EE00EC88B3 /* AlertService.swift in Sources */, @@ -649,13 +639,13 @@ 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 */, E9959A7328666409006FDF5A /* AlertManager+Environment.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 */, + 6FD86CD9293DE9060052C759 /* ObjectNavigationStack.swift in Sources */, 9665D9652745C3700055F1F6 /* ColorWizardConfiguration.swift in Sources */, 96B4E0CD274331EE00EC88B3 /* AuthenticationService.swift in Sources */, 9665D9572745C1F10055F1F6 /* ColorWizardCoordinator.swift in Sources */, @@ -833,11 +823,12 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = "com.jasonrapai.MVVM-Demo-SwiftUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -862,11 +853,12 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.0.0; PRODUCT_BUNDLE_IDENTIFIER = "com.jasonrapai.MVVM-Demo-SwiftUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -884,7 +876,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QGV9TK3SFM; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -909,7 +901,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QGV9TK3SFM; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -933,6 +925,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QGV9TK3SFM; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -956,6 +949,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QGV9TK3SFM; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1013,6 +1007,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 6FD86CDA2940033C0052C759 /* XCRemoteSwiftPackageReference "BusyIndicator" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "git@github.com:jasonjrr/BusyIndicator.git"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; 96B4E0A027432EEE00EC88B3 /* XCRemoteSwiftPackageReference "CombineExt" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CombineCommunity/CombineExt"; @@ -1032,6 +1034,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 6FD86CDB2940033C0052C759 /* BusyIndicator */ = { + isa = XCSwiftPackageProductDependency; + package = 6FD86CDA2940033C0052C759 /* XCRemoteSwiftPackageReference "BusyIndicator" */; + productName = BusyIndicator; + }; 96B4E0A127432EEE00EC88B3 /* CombineExt */ = { isa = XCSwiftPackageProductDependency; package = 96B4E0A027432EEE00EC88B3 /* XCRemoteSwiftPackageReference "CombineExt" */; diff --git a/MVVM.Demo.SwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MVVM.Demo.SwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2cd3ce4..4d226e0 100644 --- a/MVVM.Demo.SwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MVVM.Demo.SwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "busyindicator", + "kind" : "remoteSourceControl", + "location" : "git@github.com:jasonjrr/BusyIndicator.git", + "state" : { + "revision" : "dd4d5e4160372accad6ff54beb69c3354c85f63b", + "version" : "1.0.0" + } + }, { "identity" : "combineext", "kind" : "remoteSourceControl", diff --git a/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift b/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift index 3e933d6..99b941e 100644 --- a/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift +++ b/MVVM.Demo.SwiftUI/Architecture/CoordinatorAssembly.swift @@ -17,9 +17,5 @@ class CoordinatorAssembly: Assembly { 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/ServiceAssembly.swift b/MVVM.Demo.SwiftUI/Architecture/ServiceAssembly.swift index ecdd54b..0fa841d 100644 --- a/MVVM.Demo.SwiftUI/Architecture/ServiceAssembly.swift +++ b/MVVM.Demo.SwiftUI/Architecture/ServiceAssembly.swift @@ -7,6 +7,7 @@ import Foundation import Swinject +import BusyIndicator class ServiceAssembly: Assembly { func assemble(container: Container) { @@ -19,11 +20,22 @@ class ServiceAssembly: Assembly { }.inObjectScope(.container) container.register(AuthenticationServiceProtocol.self) { r in - AuthenticationService() + AuthenticationService( + busyIndicatorService: r.resolve(BusyIndicatorServiceProtocol.self)!) }.inObjectScope(.container) container.register(ColorServiceProtocol.self) { r in ColorService() }.inObjectScope(.container) + + assembleThirdParties(container: container) + } + + func assembleThirdParties(container: Container) { + container.register(BusyIndicatorServiceProtocol.self) { _ in + let config = BusyIndicatorConfiguration() + config.showBusyIndicatorDelay = 0 + return BusyIndicatorService(configuration: config) + }.inObjectScope(.container) } } diff --git a/MVVM.Demo.SwiftUI/Architecture/ViewModel.swift b/MVVM.Demo.SwiftUI/Architecture/ViewModel.swift index cfdfc3d..c451d05 100644 --- a/MVVM.Demo.SwiftUI/Architecture/ViewModel.swift +++ b/MVVM.Demo.SwiftUI/Architecture/ViewModel.swift @@ -7,6 +7,16 @@ import Foundation -typealias ViewModelDefinition = (ObservableObject & Identifiable & HapticFeedbackProvider) +typealias ViewModelDefinition = (ObservableObject & Identifiable & Hashable & HapticFeedbackProvider) protocol ViewModel: ViewModelDefinition {} + +extension ViewModel { + static func ==(lhs: Self, rhs: Self) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/MVVM.Demo.SwiftUI/Core/SwiftUI/ObjectNavigationStack.swift b/MVVM.Demo.SwiftUI/Core/SwiftUI/ObjectNavigationStack.swift new file mode 100644 index 0000000..bb765c8 --- /dev/null +++ b/MVVM.Demo.SwiftUI/Core/SwiftUI/ObjectNavigationStack.swift @@ -0,0 +1,80 @@ +// +// ObjectNavigationStack.swift +// MVVM.Demo.SwiftUI +// +// Created by Jason Lew-Rapai on 12/5/22. +// + +import SwiftUI + +struct ObjectNavigationStack: View where Content : View { + @ObservedObject var path: ObjectNavigationPath + let content: () -> Content + + var body: some View { + NavigationStack(path: self.$path.path, root: self.content) + } +} + +class ObjectNavigationPath: ObservableObject { + typealias NavigationObject = AnyObject & Hashable & Equatable + @Published fileprivate var path: NavigationPath = NavigationPath() + private var objects: [any NavigationObject] = [] + + private let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) + + var last: (any NavigationObject)? { + self.objects.last + } + + func append(_ object: some NavigationObject) { + self.semaphore.wait() + self.objects.append(object) + self.path.append(object) + self.semaphore.signal() + } + + func removeLast() { + self.semaphore.wait() + self.objects.removeLast() + self.path.removeLast() + self.semaphore.signal() + } + + @discardableResult + func removeLast(through graphObject: Element) -> Element? { + self.semaphore.wait() + var removeCount: Int = 0 + defer { + self.path.removeLast(removeCount) + self.semaphore.signal() + } + + while let object = self.objects.popLast() { + removeCount = removeCount + 1 + if graphObject === object { + return graphObject + } + } + return nil + } + + @discardableResult + func removeLast(through clause: (any NavigationObject) -> Bool) -> (any NavigationObject)? { + self.semaphore.wait() + var removeCount: Int = 0 + defer { + self.path.removeLast(removeCount) + self.semaphore.signal() + } + + while let object = self.objects.popLast() { + removeCount = removeCount + 1 + if clause(object) { + return object + } + } + return nil + } +} + diff --git a/MVVM.Demo.SwiftUI/Core/SwiftUI/View+Navigation.swift b/MVVM.Demo.SwiftUI/Core/SwiftUI/View+Navigation.swift deleted file mode 100644 index b2c6216..0000000 --- a/MVVM.Demo.SwiftUI/Core/SwiftUI/View+Navigation.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// View+Navigation.swift -// MVVM.Demo.SwiftUI -// -// Created by Jason Lew-Rapai on 11/15/21. -// -// https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/ - -import SwiftUI - -extension View { - func onNavigation(_ action: @escaping () -> Void) -> some View { - let isActive = Binding( - get: { false }, - set: { newValue in - if newValue { - action() - } - } - ) - return NavigationLink( - destination: EmptyView(), - isActive: isActive - ) { - self - } - } - - func navigation(item: Binding, @ViewBuilder destination: (Item) -> Destination) -> some View { - let isActive = Binding( - get: { item.wrappedValue != nil }, - set: { value in - if !value { - item.wrappedValue = nil - } - } - ) - return navigation(isActive: isActive) { - item.wrappedValue.map(destination) - } - } - - func navigation(isActive: Binding, @ViewBuilder destination: () -> Destination) -> some View { - overlay( - NavigationLink( - destination: isActive.wrappedValue ? destination() : nil, - isActive: isActive, - label: { EmptyView() } - ) - ) - } -} - -extension NavigationLink { - init(item: Binding, @ViewBuilder destination: (T) -> D, @ViewBuilder label: () -> Label) where Destination == D? { - let isActive = Binding( - get: { item.wrappedValue != nil }, - set: { value in - if !value { - item.wrappedValue = nil - } - } - ) - self.init( - destination: item.wrappedValue.map(destination), - isActive: isActive, - label: label - ) - } -} diff --git a/MVVM.Demo.SwiftUI/MVVM_Demo_SwiftUIApp.swift b/MVVM.Demo.SwiftUI/MVVM_Demo_SwiftUIApp.swift index 69272eb..5be7e5a 100644 --- a/MVVM.Demo.SwiftUI/MVVM_Demo_SwiftUIApp.swift +++ b/MVVM.Demo.SwiftUI/MVVM_Demo_SwiftUIApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import BusyIndicator private let appAssembler: AppAssembler = AppAssembler() @@ -17,6 +18,7 @@ struct MVVM_Demo_SwiftUIApp: App { coordinator: appAssembler.resolver.resolve(AppRootCoordinator.self)! ) .alertManager(appAssembler.resolver.resolve(AlertManager.self)!) + .busyIndicator(appAssembler.resolver.resolve(BusyIndicatorServiceProtocol.self)!.busyIndicator) } } } diff --git a/MVVM.Demo.SwiftUI/Services/AuthenticationService.swift b/MVVM.Demo.SwiftUI/Services/AuthenticationService.swift index 07ed071..5f68835 100644 --- a/MVVM.Demo.SwiftUI/Services/AuthenticationService.swift +++ b/MVVM.Demo.SwiftUI/Services/AuthenticationService.swift @@ -9,6 +9,7 @@ import Foundation import Combine import CombineExt import SwiftUI +import BusyIndicator protocol AuthenticationServiceProtocol: AnyObject { var user: AnyPublisher { get } @@ -20,6 +21,8 @@ protocol AuthenticationServiceProtocol: AnyObject { } class AuthenticationService: AuthenticationServiceProtocol { + private let busyIndicatorService: BusyIndicatorServiceProtocol + private let _user: CurrentValueSubject = CurrentValueSubject(nil) var user: AnyPublisher { self._user.eraseToAnyPublisher() } @@ -30,6 +33,10 @@ class AuthenticationService: AuthenticationServiceProtocol { private var cancelBag = CancelBag() + init(busyIndicatorService: BusyIndicatorServiceProtocol) { + self.busyIndicatorService = busyIndicatorService + } + func signIn(username: String, password: String) -> AnyPublisher { let user = User(username: username, password: password) self._user.send(user) @@ -50,11 +57,15 @@ class AuthenticationService: AuthenticationServiceProtocol { } func signOut() -> AnyPublisher { + let busySubject = self.busyIndicatorService.enqueue() self._user.send(nil) return self._user .prefix(1) .setFailureType(to: Error.self) + // This delay is to the the BusyIndicator at work. + .delay(for: 3.0, scheduler: DispatchQueue.global(qos: .userInitiated)) .flatMapLatest { (newUser: User?) -> AnyPublisher in + defer { busySubject.dequeue() } if newUser == nil { return Just() .setFailureType(to: Error.self) @@ -68,7 +79,10 @@ class AuthenticationService: AuthenticationServiceProtocol { } func signOutAsync() { - self.signOut().sink().store(in: &self.cancelBag) + self.signOut() + .debug("## signout") + .sink() + .store(in: &self.cancelBag) } } @@ -84,6 +98,6 @@ struct User: Equatable { static func ==(lhs: User, rhs: User) -> Bool { return lhs.username == rhs.username - && lhs.password == rhs.password + && lhs.password == rhs.password } } diff --git a/MVVM.Demo.SwiftUI/Services/ColorService.swift b/MVVM.Demo.SwiftUI/Services/ColorService.swift index 5b89a47..21c4be2 100644 --- a/MVVM.Demo.SwiftUI/Services/ColorService.swift +++ b/MVVM.Demo.SwiftUI/Services/ColorService.swift @@ -6,16 +6,26 @@ // import Foundation -import SwiftUI import Combine +enum ColorModel { + case blue + case green + case orange + case pink + case purple + case red + case yellow + case white +} + protocol ColorServiceProtocol: AnyObject { - func getNextColor() -> Color - func generateColors(runLoop: RunLoop) -> AnyPublisher + func getNextColor() -> ColorModel + func generateColors(runLoop: RunLoop) -> AnyPublisher } extension ColorServiceProtocol { - func generateColors(runLoop: RunLoop = .main) -> AnyPublisher { + func generateColors(runLoop: RunLoop = .main) -> AnyPublisher { generateColors(runLoop: runLoop) } } @@ -23,7 +33,7 @@ extension ColorServiceProtocol { class ColorService: ColorServiceProtocol { private var index: Int = 0 - func getNextColor() -> Color { + func getNextColor() -> ColorModel { let selection = self.index % 7 self.index = self.index + 1 switch selection { @@ -38,7 +48,7 @@ class ColorService: ColorServiceProtocol { } } - func generateColors(runLoop: RunLoop = .main) -> AnyPublisher { + func generateColors(runLoop: RunLoop = .main) -> AnyPublisher { return Timer.publish(every: 1.0, on: runLoop, in: .default) .autoconnect() .map { timer in diff --git a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift index 66eec54..53a96a6 100644 --- a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift +++ b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinator.swift @@ -14,8 +14,9 @@ class AppRootCoordinator: ViewModel { @Published private(set) var landingViewModel: LandingViewModel! + let path = ObjectNavigationPath() + @Published var signInViewModel: SignInViewModel? - @Published var pulseViewModel: PulseViewModel? @Published var colorWizardCoordinator: ColorWizardCoordinator? init(resolver: Resolver) { @@ -29,8 +30,8 @@ class AppRootCoordinator: ViewModel { // MARK: LandingViewModelDelegate extension AppRootCoordinator: LandingViewModelDelegate { func landingViewModelDidTapPulse(_ source: LandingViewModel) { - self.pulseViewModel = self.resolver.resolve(PulseViewModel.self)! - .setup(delegate: self) + self.path.append(self.resolver.resolve(PulseViewModel.self)! + .setup(delegate: self)) } func landingViewModelDidTapSignIn(_ source: LandingViewModel) { diff --git a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift index 6dabd0a..46ce6c4 100644 --- a/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift +++ b/MVVM.Demo.SwiftUI/UI/AppRootCoordinator/AppRootCoordinatorView.swift @@ -20,17 +20,15 @@ struct AppRootCoordinatorView: View { } var body: some View { - NavigationView { + ObjectNavigationStack(path: self.coordinator.path) { ZStack { LandingView(viewModel: self.coordinator.landingViewModel) .zIndex(0) - .navigation(item: self.$coordinator.pulseViewModel) { + .navigationDestination(for: PulseViewModel.self) { PulseView(viewModel: $0) } .fullScreenCover(item: self.$coordinator.colorWizardCoordinator) { coordinator in - NavigationView { - ColorWizardCoordinatorView(coordinator: coordinator) - } + ColorWizardCoordinatorView(coordinator: coordinator) } if let viewModel = self.signInViewModel { diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift index 5a00033..f87b75d 100644 --- a/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinator.swift @@ -19,7 +19,9 @@ class ColorWizardCoordinator: ViewModel { private weak var delegate: ColorWizardCoordinatorDelegate? private var configurationViewModel: ColorWizardConfigurationViewModel! - @Published var colorWizardPageCoordinator: ColorWizardPageCoordinator! + let path = ObjectNavigationPath() + + private(set) var rootContentViewModel: ColorWizardContentViewModel! init(resolver: Resolver) { self.resolver = resolver @@ -30,8 +32,8 @@ class ColorWizardCoordinator: ViewModel { self.configurationViewModel = ColorWizardConfigurationViewModel(configuration: configuration) if let firstPageViewModel = self.configurationViewModel.pages.first { - self.colorWizardPageCoordinator = self.resolver.resolve(ColorWizardPageCoordinator.self)! - .setup(currentPageViewModel: firstPageViewModel, delegate: self) + self.rootContentViewModel = self.resolver.resolve(ColorWizardContentViewModel.self)! + .setup(pageViewModel: firstPageViewModel, delegate: self) } else { fatalError() } @@ -40,33 +42,36 @@ class ColorWizardCoordinator: ViewModel { } } -// MARK: ColorWizardPageCoordinatorDelegate -extension ColorWizardCoordinator: ColorWizardPageCoordinatorDelegate { - func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveBackFromIndex index: Int) -> Bool { +// MARK: ColorWizardContentViewModelDelegate +extension ColorWizardCoordinator: ColorWizardContentViewModelDelegate { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveBackFromIndex index: Int) -> Bool { return index != 0 } - func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canMoveForwardFromIndex index: Int) -> Bool { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, canMoveForwardFromIndex index: Int) -> Bool { return self.configurationViewModel.pages.count > index + 1 } - func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, canCompleteFromIndex index: Int) -> Bool { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, 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 colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveBackFromIndex index: Int) { + self.path.removeLast(through: source) } - func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, nextPageAfterIndex index: Int) -> ColorWizardConfigurationViewModel.PageViewModel? { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, didMoveForwardFromIndex index: Int) { let newIndex = index + 1 guard newIndex < self.configurationViewModel.pages.count else { - return nil + fatalError() } - return self.configurationViewModel.pages[newIndex] + let nextPageViewModel = self.configurationViewModel.pages[newIndex] + + self.path.append(self.resolver.resolve(ColorWizardContentViewModel.self)! + .setup(pageViewModel: nextPageViewModel, delegate: self)) } - func colorWizardPageCoordinator(_ source: ColorWizardPageCoordinator, didCompleteFromIndex index: Int) { + func colorWizardContentViewModel(_ source: ColorWizardContentViewModel, 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 index 4b620e3..07d06d8 100644 --- a/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift +++ b/MVVM.Demo.SwiftUI/UI/ColorWizard/ColorWizardCoordinatorView.swift @@ -11,6 +11,11 @@ struct ColorWizardCoordinatorView: View { @ObservedObject var coordinator: ColorWizardCoordinator var body: some View { - ColorWizardPageCoordinatorView(coordinator: self.coordinator.colorWizardPageCoordinator) + ObjectNavigationStack(path: self.coordinator.path) { + ColorWizardContentView(viewModel: self.coordinator.rootContentViewModel) + .navigationDestination(for: ColorWizardContentViewModel.self) { + ColorWizardContentView(viewModel: $0) + } + } } } diff --git a/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift b/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift deleted file mode 100644 index cd2e3e2..0000000 --- a/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinator.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// 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 deleted file mode 100644 index e137872..0000000 --- a/MVVM.Demo.SwiftUI/UI/ColorWizard/PageCoordinator/ColorWizardPageCoordinatorView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// 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 d83cb21..ba6ba45 100644 --- a/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift +++ b/MVVM.Demo.SwiftUI/UI/Landing/LandingView.swift @@ -8,6 +8,9 @@ import SwiftUI struct LandingView: View { + @ScaledMetric private var buttonPadding: CGFloat = 8.0 + @ScaledMetric private var inverseHorizontalPadding: CGFloat = 8.0 + @ObservedObject var viewModel: LandingViewModel @State private var isAuthenticated: Bool = false @@ -21,29 +24,33 @@ struct LandingView: View { Text(self.isAuthenticated ? "Sign Out, \(self.username)" : "Sign In") .multilineTextAlignment(.center) .lineLimit(nil) - .padding(8.0) - .frame(maxWidth: .infinity, minHeight: 54.0, maxHeight: .infinity) - .fixedSize(horizontal: false, vertical: true) + .padding(self.buttonPadding) + .frame(maxWidth: .infinity, minHeight: 54.0) .contentShape(Rectangle()) } - .buttonStyle(BrightBorderedButtonStyle()) + .buttonStyle(.brightBorderedButton) + .busyOverlay() + .clipShape(RoundedRectangle(cornerRadius: 16.0, style: .continuous)) Button(action: self.viewModel.pulse) { Text("Pulse") - .frame(maxWidth: .infinity, minHeight: 54.0, idealHeight: 54.0, maxHeight: 54.0) + .padding(self.buttonPadding) + .frame(maxWidth: .infinity, minHeight: 54.0) .contentShape(Rectangle()) } - .buttonStyle(BrightBorderedButtonStyle(color: self.pulseColor)) + .buttonStyle(.brightBorderedButton(color: self.pulseColor)) Button(action: self.viewModel.colorWizard) { Text("Color Wizard") - .frame(maxWidth: .infinity, minHeight: 54.0, idealHeight: 54.0, maxHeight: 54.0) + .lineLimit(1) + .minimumScaleFactor(0.75) + .padding(self.buttonPadding) + .frame(maxWidth: .infinity, minHeight: 54.0) .contentShape(Rectangle()) } - .buttonStyle(BrightBorderedButtonStyle()) + .buttonStyle(.brightBorderedButton) } - - .padding([.leading, .trailing], 48.0) + .padding([.leading, .trailing], max(56.0 - self.inverseHorizontalPadding, 4.0)) } .navigationBarHidden(true) .onReceive(self.viewModel.isAuthenticated.receive(on: .main)) { @@ -53,7 +60,16 @@ struct LandingView: View { self.username = $0 } .onReceive(self.viewModel.pulseColor.receive(on: .main), withAnimation: .easeInOut) { - self.pulseColor = $0 + switch $0 { + case .blue: self.pulseColor = .blue + case .green: self.pulseColor = .green + case .orange: self.pulseColor = .green + case .pink: self.pulseColor = .pink + case .purple: self.pulseColor = .purple + case .red: self.pulseColor = .red + case .white: self.pulseColor = .white + case .yellow: self.pulseColor = .yellow + } } } } diff --git a/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift b/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift index 3393498..d642c77 100644 --- a/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift +++ b/MVVM.Demo.SwiftUI/UI/Landing/LandingViewModel.swift @@ -26,7 +26,7 @@ class LandingViewModel: ViewModel { var username: AnyPublisher { self.authenticationService.user.map { $0?.username ?? .empty }.eraseToAnyPublisher() } - let pulseColor: AnyPublisher + let pulseColor: AnyPublisher let pulse: PassthroughSubject = PassthroughSubject() let signInOrOut: PassthroughSubject = PassthroughSubject() diff --git a/MVVM.Demo.SwiftUI/UI/Pulse/PulseView.swift b/MVVM.Demo.SwiftUI/UI/Pulse/PulseView.swift index f847497..25065f4 100644 --- a/MVVM.Demo.SwiftUI/UI/Pulse/PulseView.swift +++ b/MVVM.Demo.SwiftUI/UI/Pulse/PulseView.swift @@ -16,11 +16,15 @@ struct PulseView: View { ZStack { ForEach(self.viewModel.colors) { item in PulseCircle(viewModel: item) + .frame( + width: max(UIScreen.main.bounds.size.width, UIScreen.main.bounds.size.height), + height: max(UIScreen.main.bounds.size.width, UIScreen.main.bounds.size.height) + ) } } .navigationTitle(self.title) .navigationBarTitleDisplayMode(.inline) - .overlay(VisualEffectView(effect: UIBlurEffect(style: .regular)).edgesIgnoringSafeArea(.all)) + .overlay(.thinMaterial) .onReceive(self.viewModel.title.receive(on: .main)) { self.title = $0 } diff --git a/MVVM.Demo.SwiftUI/UI/Pulse/PulseViewModel.swift b/MVVM.Demo.SwiftUI/UI/Pulse/PulseViewModel.swift index 113103d..b82256c 100644 --- a/MVVM.Demo.SwiftUI/UI/Pulse/PulseViewModel.swift +++ b/MVVM.Demo.SwiftUI/UI/Pulse/PulseViewModel.swift @@ -66,8 +66,17 @@ extension PulseViewModel { let color: Color @Published var opacity: Double = 0.0 - init(color: Color) { - self.color = color + init(color model: ColorModel) { + switch model { + case .blue: self.color = .blue + case .green: self.color = .green + case .orange: self.color = .green + case .pink: self.color = .pink + case .purple: self.color = .purple + case .red: self.color = .red + case .white: self.color = .white + case .yellow: self.color = .yellow + } } } } diff --git a/MVVM.Demo.SwiftUI/UI/SignIn/SignInView.swift b/MVVM.Demo.SwiftUI/UI/SignIn/SignInView.swift index dcd0db9..89022a3 100644 --- a/MVVM.Demo.SwiftUI/UI/SignIn/SignInView.swift +++ b/MVVM.Demo.SwiftUI/UI/SignIn/SignInView.swift @@ -8,14 +8,22 @@ import SwiftUI struct SignInView: View { + @ScaledMetric private var buttonFontSize: CGFloat = 18.0 + @ScaledMetric private var inverseCardPadding: CGFloat = 16.0 + @ObservedObject var viewModel: SignInViewModel @State private var showCard: Bool = false @State private var signInDisabled: Bool = true + @FocusState private var focusState: FocusField? + enum FocusField { + case username + case password + } + var body: some View { - VStack { - Spacer() + HStack { if self.showCard { CardView(color: Color.systemBackground, cornerRadius: .large) { VStack { @@ -23,20 +31,43 @@ struct SignInView: View { Text("User Name") .padding(EdgeInsets(horizontal: 8.0, vertical: 0.0)) TextField("User Name", text: self.$viewModel.username, prompt: nil) + .focused(self.$focusState, equals: .username) .padding() - .background(RoundedRectangle(cornerRadius: 3.0).stroke(Color.systemGroupedBackground)) + .background(RoundedRectangle(cornerRadius: 8.0).stroke(Color.systemGray)) + .contentShape(Rectangle()) + .onTapGesture { + if self.focusState != .username { + self.focusState = .username + } + } Text("Password").padding(.top) .padding(EdgeInsets(horizontal: 8.0, vertical: 0.0)) SecureField("Password", text: self.$viewModel.password, prompt: nil) + .focused(self.$focusState, equals: .password) .padding() - .background(RoundedRectangle(cornerRadius: 3.0).stroke(Color.systemGroupedBackground)) + .background(RoundedRectangle(cornerRadius: 8.0).stroke(Color.systemGray)) + .contentShape(Rectangle()) + .onTapGesture { + if self.focusState != .password { + self.focusState = .password + } + } } .padding(16.0) + .onSubmit { + switch self.focusState { + case .none: break + case .username: self.focusState = .password + case .password: self.focusState = nil + } + } HStack { Button(action: self.viewModel.cancel) { Text("Cancel") - .font(.system(size: 18.0)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .font(.system(size: self.buttonFontSize)) .bold() .frame(maxWidth: .infinity, minHeight: 48.0, idealHeight: 48.0, maxHeight: 48.0) .contentShape(Rectangle()) @@ -44,8 +75,10 @@ struct SignInView: View { Button(action: self.viewModel.signIn) { Text("Sign In") - .font(.system(size: 18.0)) - .frame(maxWidth: .infinity, minHeight: 48.0, idealHeight: 48.0, maxHeight: 48.0) + .lineLimit(1) + .minimumScaleFactor(0.75) + .font(.system(size: self.buttonFontSize)) + .frame(maxWidth: .infinity, minHeight: 48.0) .contentShape(Rectangle()) } .disabled(self.signInDisabled) @@ -57,23 +90,36 @@ struct SignInView: View { .fixedSize(horizontal: false, vertical: true) .clipped() .shadow(radius: 3.0) - .padding(36.0) + .padding(max(52.0 - self.inverseCardPadding, 4.0)) .transition(.scale(scale: 0.0)) } - Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - ProgressiveVisualEffectView(effect: UIBlurEffect(style: .regular), intensity: 0.25) - .edgesIgnoringSafeArea(.all) - ) + .background(.ultraThinMaterial) .onAppear { - withAnimation(.spring()) { + withAnimation(.spring(response: 0.325, dampingFraction: 0.825, blendDuration: 0.2)) { self.showCard = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.focusState = .username + } } .onReceive(self.viewModel.canSignIn) { self.signInDisabled = !$0 } } } + +#if DEBUG +struct SignInView_Previews: PreviewProvider { + static let appAssembler = AppAssembler() + static let viewModel = appAssembler.resolver.resolve(SignInViewModel.self)! + + static var previews: some View { + Group { + SignInView(viewModel: viewModel) + .edgesIgnoringSafeArea(.all) + } + } +} +#endif diff --git a/MVVM.Demo.SwiftUI/UI/Styles/ButtonStyles/BrightBorderedButtonStyle.swift b/MVVM.Demo.SwiftUI/UI/Styles/ButtonStyles/BrightBorderedButtonStyle.swift index ba679cf..b5f40c3 100644 --- a/MVVM.Demo.SwiftUI/UI/Styles/ButtonStyles/BrightBorderedButtonStyle.swift +++ b/MVVM.Demo.SwiftUI/UI/Styles/ButtonStyles/BrightBorderedButtonStyle.swift @@ -31,3 +31,13 @@ struct BrightBorderedButtonStyle: ButtonStyle { .opacity(configuration.isPressed ? 0.5 : 1.0) } } + +extension ButtonStyle where Self == BrightBorderedButtonStyle { + static var brightBorderedButton: BrightBorderedButtonStyle { + BrightBorderedButtonStyle() + } + + static func brightBorderedButton(color: Color = .accentColor, cornerRadius: CGFloat = 16.0, borderWidth: CGFloat = 2.0) -> BrightBorderedButtonStyle { + BrightBorderedButtonStyle(color: color, cornerRadius: cornerRadius, borderWidth: borderWidth) + } +} diff --git a/MVVM.Demo.SwiftUI/UI/Styles/TextStyles/ButtonTextStyle.swift b/MVVM.Demo.SwiftUI/UI/Styles/TextStyles/ButtonTextStyle.swift index e1e39cd..0dcb94f 100644 --- a/MVVM.Demo.SwiftUI/UI/Styles/TextStyles/ButtonTextStyle.swift +++ b/MVVM.Demo.SwiftUI/UI/Styles/TextStyles/ButtonTextStyle.swift @@ -8,9 +8,11 @@ import SwiftUI struct ButtonTextStyle: TextStyle { + @ScaledMetric private var fontSize: CGFloat = 18.0 + func body(content: Content) -> some View { content - .font(.system(size: 18.0, weight: .semibold, design: .rounded)) + .font(.system(size: self.fontSize, weight: .semibold, design: .rounded)) } } diff --git a/MVVM.Demo.SwiftUITests/Services/AuthenticationService+Tests.swift b/MVVM.Demo.SwiftUITests/Services/AuthenticationService+Tests.swift index 0f5abe8..ec1f7c7 100644 --- a/MVVM.Demo.SwiftUITests/Services/AuthenticationService+Tests.swift +++ b/MVVM.Demo.SwiftUITests/Services/AuthenticationService+Tests.swift @@ -10,13 +10,14 @@ import Foundation import XCTest import Combine import CombineExt +import BusyIndicator class AuthenticationServiceTest: XCTestCase { var subject: AuthenticationService! override func setUp() { super.setUp() - self.subject = AuthenticationService() + self.subject = AuthenticationService(busyIndicatorService: BusyIndicatorService()) } } diff --git a/MVVM.Demo.SwiftUITests/Services/ColorService+Tests.swift b/MVVM.Demo.SwiftUITests/Services/ColorService+Tests.swift index 815df53..0840a6c 100644 --- a/MVVM.Demo.SwiftUITests/Services/ColorService+Tests.swift +++ b/MVVM.Demo.SwiftUITests/Services/ColorService+Tests.swift @@ -23,7 +23,7 @@ class ColorServiceTest: XCTestCase { class ColorService_when_getNextColor_is_called: ColorServiceTest { func test_then_colors_are_returned_in_expected_order() { - let actualColors: [Color] = [ + let actualColors: [ColorModel] = [ self.subject.getNextColor(), self.subject.getNextColor(), self.subject.getNextColor(), @@ -36,7 +36,7 @@ class ColorService_when_getNextColor_is_called: ColorServiceTest { self.subject.getNextColor(), ] - let expectedColors: [Color] = [ + let expectedColors: [ColorModel] = [ .blue, .green, .orange, @@ -55,7 +55,7 @@ class ColorService_when_getNextColor_is_called: ColorServiceTest { class ColorService_when_generateNextColor_is_called: ColorServiceTest { var subscription: AnyCancellable? - var values: [Color] = [] + var values: [ColorModel] = [] override func setUp() { super.setUp() @@ -85,7 +85,7 @@ class ColorService_when_generateNextColor_is_called: ColorServiceTest { XCTAssertEqual(values.count, 10) - let possibleColors: [Color] = [ + let possibleColors: [ColorModel] = [ .blue, .green, .orange, diff --git a/README.md b/README.md index a88a674..5a57817 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ # MVVM.Demo.SwiftUI +# MVVM - Model View ViewModel +MVVM Wiki: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel + +## Overview +### View +- UI elements +- Reacts to and interprets ViewModel through bindings
 +- Contains no business logic +- Stateless1 +- Holds a reference to the ViewModel + +### ViewModel +- Model interpretation
 +- Business logic
 +- Bindable properties +- Domain model dependencies injected as Protocols
 +- May contain child ViewModels
 +- Does not know about View + +### Model +- Anything that provides data or state to the ViewModel +- Does not know about the View or ViewModel + +1 No meaningful state is stored within the view. Anything needed by the model layer is immediately sent to the ViewModel from the View. + +# Coordinators +- Coordinators are aware of the user’s navigation context within the app +- Primarily responsible for Navigation and ViewModel injection +- Can contain child coordinators which are responsible for a narrowed context +- Only object to have a reference to the Dependency Injection container/resolver + +# Dependency Injection Container +- Manages references and lifetimes of registered objects
 +- Reduces the burden of dependency injection by defining all injections in one place
 +- Greatly improves the ability to unit test your code by registering contained objects as Protocols which can be substituted with mocks +- Makes sharing observable data throughout the app trivial while keeping each class/service focused and independent + +# Looking for an iOS 14 or 15 Example? +https://github.com/jasonjrr/MVVM.Demo.SwiftUI/releases/tag/2.1.0 + +Version `2.1.0` was built on iOS 14 and solves the navigation problems most developers experiences with `NavigationView`.