From 9abef3de6a472adb99254419ec0be9874df33d67 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 12 Dec 2022 12:31:27 +0200 Subject: [PATCH] Fixes #317 - Adopt a split layout for iPad and Mac apps Rename navigation components: SplitScreenCoordinator -> NavigationSplitCoordinator, StackScreenCoordinator -> NavigationStackCoordinator and SingleScreenCoordinator -> NavigationRootCoordinator [0c161039] Tweak navigation logging [826c19cf] Move the navigation dismissal callbacks to the NavigationModule, add SingleScreenCoordinator tests [b8830d9c] Add tests [252ad119] Merge the StackScreenCoordinator and SplitScreenCoordinators into a single file and stop publicly exposing their internal workings. Add more documentation. [37671699] Cleanup navigation logging [51406184] Use the parent SplitScreenCoordinator to present embedded StackScreenCoordinator sheets [b94b04c9] Retract the room "syncing" indicator when dismissing a room [1467b0ac] Correctly move to the no room selected state when popping in compact layouts [10bf2ad8] Allow nilling root coordinators, replace present/dismiss sheet with setSheetCoordinator(?) [33716784] Add single screen coordinator fade transition animation [3cbe65e7] Prevent the timeline table view from being reused between different rooms [9c94c50b] Move files around [c10b6bc5] Adapt the user session state machine to the split layout [7115a319] Fix unit and UI tests [1ece59e8] Fix login flows [6884dc3b] Use modules everywhere the underlying object is a NavigationModule [ab08d44c] Rename navigation components to: SingleScreenCoordinator, SplitScreenCoordinator and StackScreenCoordinator [ada2be57] Add SplitNavigationController * Remove the navigationRootCoordinator from the UserSessionFlowCoordinator --- ElementX.xcodeproj/project.pbxproj | 44 +- .../Sources/Application/AppCoordinator.swift | 22 +- .../Navigation/NavigationCoordinators.swift | 526 ++++++++++++++++++ .../Navigation/NavigationModule.swift | 38 ++ .../NavigationRootCoordinator.swift | 82 +++ .../Application/NavigationController.swift | 186 ------- .../UserNotificationController.swift | 8 +- .../AuthenticationCoordinator.swift | 16 +- .../LoginScreen/LoginCoordinator.swift | 14 +- .../HomeScreen/HomeScreenCoordinator.swift | 4 +- .../RoomScreen/RoomScreenCoordinator.swift | 25 +- .../RoomScreen/RoomScreenViewModel.swift | 4 + .../Screens/RoomScreen/View/RoomScreen.swift | 1 + .../Settings/SettingsCoordinator.swift | 6 +- .../UserSessionFlowCoordinator.swift | 95 ++-- ...erSessionFlowCoordinatorStateMachine.swift | 62 +-- .../UITests/UITestsAppCoordinator.swift | 30 +- .../Sources/NavigationControllerTests.swift | 221 -------- .../NavigationRootCoordinatorTests.swift | 72 +++ .../Sources/NavigationSplitCoordinator.swift | 274 +++++++++ .../NavigationStackCoordinatorTests.swift | 217 ++++++++ changelog.d/317.feature | 1 + 22 files changed, 1407 insertions(+), 541 deletions(-) create mode 100644 ElementX/Sources/Application/Navigation/NavigationCoordinators.swift create mode 100644 ElementX/Sources/Application/Navigation/NavigationModule.swift create mode 100644 ElementX/Sources/Application/Navigation/NavigationRootCoordinator.swift delete mode 100644 ElementX/Sources/Application/NavigationController.swift delete mode 100644 UnitTests/Sources/NavigationControllerTests.swift create mode 100644 UnitTests/Sources/NavigationRootCoordinatorTests.swift create mode 100644 UnitTests/Sources/NavigationSplitCoordinator.swift create mode 100644 UnitTests/Sources/NavigationStackCoordinatorTests.swift create mode 100644 changelog.d/317.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 361b75654..951f3d397 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -131,7 +131,6 @@ 407DCE030E0F9B7C9861D38A /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 997C7385E1A07E061D7E2100 /* GZIP */; }; 414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; }; 41DFDD212D1BE57CA50D783B /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = FD43A50D9B75C9D6D30F006B /* SwiftyBeaver */; }; - 41E16904B30C529373B4E1A4 /* NavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 495D3EC4972639C1A87DDF8E /* NavigationController.swift */; }; 438FB9BC535BC95948AA5F34 /* SettingsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2F9D5C39A4494D19F33E38 /* SettingsViewModelProtocol.swift */; }; 43BD17BC8794BB9B04F2A26B /* MediaSourceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179423E34EE846E048E49CBF /* MediaSourceProxy.swift */; }; 43FD77998F33C32718C51450 /* TemplateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD460ED7ED1E03B85DEA25C /* TemplateCoordinator.swift */; }; @@ -145,6 +144,7 @@ 49E9B99CB6A275C7744351F0 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D58333B377888012740101 /* LoginViewModel.swift */; }; 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */; }; + 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */; }; 4C3365818DE1CEAEDF590FD3 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; }; 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; }; @@ -210,6 +210,7 @@ 706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */; }; 7096FA3AC218D914E88BFB70 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BE37BE2FB86E00C8D150A /* AggregratedReaction.swift */; }; 719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; + 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; 7354D094A4C59B555F407FA1 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -242,7 +243,6 @@ 8024BE37156FF0A95A7A3465 /* AnalyticsPromptUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF11DD57D9FACF2A757AB024 /* AnalyticsPromptUITests.swift */; }; 80D00A7C62AAB44F54725C43 /* PermalinkBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */; }; 8196A2E71ACC902DD69F24EE /* UserNotificationControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE6C5C756E1393202BA95CD /* UserNotificationControllerTests.swift */; }; - 834DD9E41FC42A509BAD52E3 /* NavigationControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109361C96BFFBE2FD89BF15C /* NavigationControllerTests.swift */; }; 83E5054739949181CA981193 /* LoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD667C4BB98CF4F3FE2CE3B0 /* LoginCoordinator.swift */; }; 841172E1576A863F4450132D /* WeakKeyDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ADFDC712027931F2216668 /* WeakKeyDictionary.swift */; }; 85AFBB433AD56704A880F8A0 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */; }; @@ -278,6 +278,7 @@ 9738F894DB1BD383BE05767A /* ElementSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1027BB9A852F445B7623897F /* ElementSettings.swift */; }; 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */; }; 97CECF91D68235F1D13598D7 /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */; }; + 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */; }; 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */; }; 989029A28C9E2F828AD6658A /* AppIcon.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */; }; 992477AB8E3F3C36D627D32E /* OnboardingViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BC4437C107D52ED19357DFC /* OnboardingViewModelProtocol.swift */; }; @@ -338,6 +339,7 @@ B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */; }; B5111BAF5F601C139EBBD8BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01C4C7DB37597D7D8379511A /* Assets.xcassets */; }; B5903E48CF43259836BF2DBF /* EncryptedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C1BCB9E83B09A45387FCA2 /* EncryptedRoomTimelineView.swift */; }; + B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; }; B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; B66757D0254843162595B25D /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; B6DA66EFC13A90846B625836 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 91DE43B8815918E590912DDA /* InfoPlist.strings */; }; @@ -438,9 +440,11 @@ F9981191DC408AED537C1749 /* MediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */; }; F99FB21EFC6D99D247FE7CBE /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DE8DC9B3FBA402117DC4C49F /* Kingfisher */; }; F9F6D2883BBEBB9A3789A137 /* OnboardingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */; }; + FA2BBAE9FC5E2E9F960C0980 /* NavigationCoordinators.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */; }; FA9C427FFB11B1AA2DCC5602 /* RoomProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */; }; FBCD77D557AACBE9B445133A /* MediaProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12C9E0B61A77C7F0EE7918C /* MediaProxy.swift */; }; FBF09B6C900415800DDF2A21 /* EmojiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C113E0CB7E15E9765B1817A /* EmojiProvider.swift */; }; + FCB9B475F908765531335859 /* NavigationSplitCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A32A0627A03F00A2900FE /* NavigationSplitCoordinator.swift */; }; FCD3F2B82CAB29A07887A127 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; }; FE4593FC2A02AAF92E089565 /* ElementAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1593DD87F974F8509BB619 /* ElementAnimations.swift */; }; FE79E2BCCF69E8BF4D21E15A /* RoomMessageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA154570F693D93513E584C1 /* RoomMessageFactory.swift */; }; @@ -503,7 +507,6 @@ 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = ""; }; 057B747CF045D3C6C30EAB2C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fi; path = fi.lproj/Localizable.stringsdict; sourceTree = ""; }; - 05C4897C156B01C5775F3A5D /* dev-element-x-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "dev-element-x-ios"; path = .; sourceTree = SOURCE_ROOT; }; 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = ""; }; 086B997409328F091EBA43CE /* RoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenUITests.swift; sourceTree = ""; }; 08F64963396A6A23538EFCEC /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = is; path = is.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -533,7 +536,6 @@ 1059E2AE7878CF7820592637 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = ""; }; 105D16E7DB0CCE9526612BDD /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-IN"; path = "bn-IN.lproj/Localizable.strings"; sourceTree = ""; }; - 109361C96BFFBE2FD89BF15C /* NavigationControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationControllerTests.swift; sourceTree = ""; }; 10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderProtocol.swift; sourceTree = ""; }; 1113CA0A67B4AA227AAFB63B /* UserNotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationController.swift; sourceTree = ""; }; 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -652,7 +654,6 @@ 48CE6BF18E542B32FA52CE06 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fa; path = fa.lproj/Localizable.stringsdict; sourceTree = ""; }; 49193CB0C248D621A96FB2AA /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; 4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; - 495D3EC4972639C1A87DDF8E /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = ""; }; 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; 49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = ""; }; @@ -789,12 +790,14 @@ 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = ""; }; 997783054A2E95F9E624217E /* kaa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kaa; path = kaa.lproj/Localizable.strings; sourceTree = ""; }; 99DE232F24EAD72A3DF7EF1A /* kab */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = kab; path = kab.lproj/Localizable.stringsdict; sourceTree = ""; }; + 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModule.swift; sourceTree = ""; }; 9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = ""; }; 9B1FBF8CA40199B8058B1F08 /* NotificationItemProxy+NSE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationItemProxy+NSE.swift"; sourceTree = ""; }; 9B577F829C693B8DFB7014FD /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = ""; }; 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartJSONLoaderTests.swift; sourceTree = ""; }; 9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = ""; }; 9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = ""; }; + 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackCoordinatorTests.swift; sourceTree = ""; }; 9C7F7DE62D33C6A26CBFCD72 /* IntegrationTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModel.swift; sourceTree = ""; }; 9D7D706FFF438CAF16F44D8C /* ServerSelectionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionCoordinator.swift; sourceTree = ""; }; @@ -816,6 +819,7 @@ A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = ""; }; A4B5B19A10D3F7C2BC5315DF /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; A5B0B1226DA8DB55918B34CD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; + A63A32A0627A03F00A2900FE /* NavigationSplitCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitCoordinator.swift; sourceTree = ""; }; A64F0DB78E0AC23C91AD89EF /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = mk.lproj/Localizable.strings; sourceTree = ""; }; A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; @@ -861,6 +865,7 @@ B8347789959986B374DB25DD /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = sq; path = sq.lproj/Localizable.stringsdict; sourceTree = ""; }; B83CB897B183BF3C33715F55 /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "bn-IN"; path = "bn-IN.lproj/Localizable.stringsdict"; sourceTree = ""; }; B8A56EA2A5AE726F445CB2E3 /* eo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = eo; path = eo.lproj/Localizable.stringsdict; sourceTree = ""; }; + B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = ""; }; B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; BA7B2E9CC5DC3B76ADC35A43 /* AnalyticsPromptCheckmarkItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptCheckmarkItem.swift; sourceTree = ""; }; BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; @@ -891,6 +896,7 @@ C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = ""; }; C95ADE8D9527523572532219 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hu; path = hu.lproj/Localizable.stringsdict; sourceTree = ""; }; C9A86C95340248A8B7BA9A43 /* AnalyticsPromptViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptViewModelProtocol.swift; sourceTree = ""; }; + CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = ""; }; CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; CA9D14D6F914324865C7DB9F /* ActivityCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityCoordinator.swift; sourceTree = ""; }; CAAE4A709C0A2144C103AA0F /* ang */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ang; path = ang.lproj/Localizable.strings; sourceTree = ""; }; @@ -915,6 +921,7 @@ D1A9CCCF53495CF3D7B19FCE /* MockSessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionVerificationControllerProxy.swift; sourceTree = ""; }; D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = ""; }; D2D783758EAE6A88C93564EB /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + D31DC8105C6233E5FFD9B84C /* element-x-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "element-x-ios"; path = .; sourceTree = SOURCE_ROOT; }; D33116993D54FADC0C721C1F /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; @@ -987,6 +994,7 @@ F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = ""; }; F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilder.swift; sourceTree = ""; }; F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; + F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = ""; }; F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = ""; }; FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = ""; }; FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = ""; }; @@ -1632,7 +1640,9 @@ 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */, A05707BF550D770168A406DB /* LoginViewModelTests.swift */, F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */, - 109361C96BFFBE2FD89BF15C /* NavigationControllerTests.swift */, + F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */, + A63A32A0627A03F00A2900FE /* NavigationSplitCoordinator.swift */, + 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */, 00A941F289F6AB876BA3361A /* OnboardingViewModelTests.swift */, 6FB31A32C93D94930B253FBF /* PermalinkBuilderTests.swift */, 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */, @@ -1668,6 +1678,16 @@ path = Items; sourceTree = ""; }; + 780F74C73E826685A9DB289B /* Navigation */ = { + isa = PBXGroup; + children = ( + B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */, + 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */, + CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */, + ); + path = Navigation; + sourceTree = ""; + }; 78915D878159D302395D57BF /* SupportingFiles */ = { isa = PBXGroup; children = ( @@ -1828,7 +1848,7 @@ 9413F680ECDFB2B0DDB0DEF2 /* Packages */ = { isa = PBXGroup; children = ( - 05C4897C156B01C5775F3A5D /* dev-element-x-ios */, + D31DC8105C6233E5FFD9B84C /* element-x-ios */, ); name = Packages; sourceTree = SOURCE_ROOT; @@ -1948,8 +1968,8 @@ CA89A2DD51B6BBE1DA55E263 /* Application.swift */, 263B3B811C2B900F12C6F695 /* BuildSettings.swift */, B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */, - 495D3EC4972639C1A87DDF8E /* NavigationController.swift */, 57F95CADD0A5DBD76B990FCB /* ServiceLocator.swift */, + 780F74C73E826685A9DB289B /* Navigation */, ); path = Application; sourceTree = ""; @@ -2743,7 +2763,9 @@ 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */, 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, - 834DD9E41FC42A509BAD52E3 /* NavigationControllerTests.swift in Sources */, + 981853650217B6C8ECDD998C /* NavigationRootCoordinatorTests.swift in Sources */, + FCB9B475F908765531335859 /* NavigationSplitCoordinator.swift in Sources */, + 4BB282209EA82015D0DF8F89 /* NavigationStackCoordinatorTests.swift in Sources */, F9F6D2883BBEBB9A3789A137 /* OnboardingViewModelTests.swift in Sources */, 27E9263DA75E266690A37EB1 /* PermalinkBuilderTests.swift in Sources */, 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */, @@ -2903,7 +2925,9 @@ C74EE50257ED925C2B8EFCE6 /* MockSoftLogoutScreenState.swift in Sources */, FE8D76708280968F7A670852 /* MockUserNotificationController.swift in Sources */, D8359F67AF3A83516E9083C1 /* MockUserSession.swift in Sources */, - 41E16904B30C529373B4E1A4 /* NavigationController.swift in Sources */, + FA2BBAE9FC5E2E9F960C0980 /* NavigationCoordinators.swift in Sources */, + 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */, + B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */, 8BBD3AA589DEE02A1B0923B2 /* NoticeRoomTimelineItem.swift in Sources */, 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */, 3F70E237CE4C3FAB02FC227F /* NotificationConstants.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 4fae5b962..4bc5b5129 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -20,7 +20,7 @@ import SwiftUI class AppCoordinator: AppCoordinatorProtocol { private let stateMachine: AppCoordinatorStateMachine - private let navigationController: NavigationController + private let navigationRootCoordinator: NavigationRootCoordinator private let userSessionStore: UserSessionStoreProtocol /// Common background task to resume long-running tasks in the background. /// When this task expiring, we'll try to suspend the state machine by `suspend` event. @@ -51,14 +51,14 @@ class AppCoordinator: AppCoordinatorProtocol { private(set) var notificationManager: NotificationManagerProtocol? init() { - navigationController = NavigationController() + navigationRootCoordinator = NavigationRootCoordinator() stateMachine = AppCoordinatorStateMachine() bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL) - navigationController.setRootCoordinator(SplashScreenCoordinator()) + navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator()) - ServiceLocator.shared.register(userNotificationController: UserNotificationController(rootCoordinator: navigationController)) + ServiceLocator.shared.register(userNotificationController: UserNotificationController(rootCoordinator: navigationRootCoordinator)) backgroundTaskService = UIKitBackgroundTaskService { UIApplication.shared @@ -168,12 +168,15 @@ class AppCoordinator: AppCoordinatorProtocol { } private func startAuthentication() { + let authenticationNavigationStackCoordinator = NavigationStackCoordinator() let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore) authenticationCoordinator = AuthenticationCoordinator(authenticationService: authenticationService, - navigationController: navigationController) + navigationStackCoordinator: authenticationNavigationStackCoordinator) authenticationCoordinator?.delegate = self authenticationCoordinator?.start() + + navigationRootCoordinator.setRootCoordinator(authenticationNavigationStackCoordinator) } private func startAuthenticationSoftLogout() { @@ -208,13 +211,14 @@ class AppCoordinator: AppCoordinatorProtocol { } } - navigationController.setRootCoordinator(coordinator) + navigationRootCoordinator.setRootCoordinator(coordinator) } } private func setupUserSession() { + let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator()) let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, - navigationController: navigationController, + navigationSplitCoordinator: navigationSplitCoordinator, bugReportService: bugReportService) userSessionFlowCoordinator.callback = { [weak self] action in @@ -227,6 +231,8 @@ class AppCoordinator: AppCoordinatorProtocol { userSessionFlowCoordinator.start() self.userSessionFlowCoordinator = userSessionFlowCoordinator + + navigationRootCoordinator.setRootCoordinator(navigationSplitCoordinator) } private func tearDownUserSession(isSoftLogout: Bool = false) { @@ -253,7 +259,7 @@ class AppCoordinator: AppCoordinatorProtocol { } private func presentSplashScreen(isSoftLogout: Bool = false) { - navigationController.setRootCoordinator(SplashScreenCoordinator()) + navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator()) if isSoftLogout { startAuthenticationSoftLogout() diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift new file mode 100644 index 000000000..7eb83d531 --- /dev/null +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -0,0 +1,526 @@ +// +// Copyright 2022 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 Combine +import SwiftUI + +/// Class responsible for displaying 2 coordinators side by side and collapsing them +/// into a single navigation stack on compact layouts +class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomStringConvertible { + fileprivate let placeholderModule: NavigationModule + + private var cancellables = Set() + + @Published fileprivate var sidebarModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove sidebar", oldValue) + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let sidebarModule { + logPresentationChange("Set sidebar", sidebarModule) + sidebarModule.coordinator.start() + } + + updateCompactLayoutComponents() + } + } + + /// The currently displayed sidebar coordinator + var sidebarCoordinator: (any CoordinatorProtocol)? { + sidebarModule?.coordinator + } + + @Published fileprivate var detailModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove detail", oldValue) + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let detailModule { + logPresentationChange("Set detail", detailModule) + detailModule.coordinator.start() + } + + updateCompactLayoutComponents() + } + } + + /// The currently displayed detail coordinator + var detailCoordinator: (any CoordinatorProtocol)? { + detailModule?.coordinator + } + + @Published fileprivate var sheetModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove sheet", oldValue) + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let sheetModule { + logPresentationChange("Set sheet", sheetModule) + sheetModule.coordinator.start() + } + + updateCompactLayoutComponents() + } + } + + /// The currently displayed sheet coordinator + var sheetCoordinator: (any CoordinatorProtocol)? { + sheetModule?.coordinator + } + + @Published fileprivate var compactLayoutRootModule: NavigationModule? + + var compactLayoutRootCoordinator: (any CoordinatorProtocol)? { + compactLayoutRootModule?.coordinator + } + + /// This is set as internal so that we can manipulate the compact layout stack from the unit tests. + /// Shouldn't be used otherwise. + @Published internal var compactLayoutStackModules: [NavigationModule] = [] + + var compactLayoutStackCoordinators: [any CoordinatorProtocol] { + compactLayoutStackModules.map(\.coordinator) + } + + /// Default NavigationSplitCoordinator initialiser + /// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set + init(placeholderCoordinator: CoordinatorProtocol) { + placeholderModule = NavigationModule(placeholderCoordinator) + } + + /// Set the coordinator to be used on the split's left pannel + /// - Parameters: + /// - coordinator: the sidebar coordinator + /// - dismissalCallback: called when this particular sidebar coordinator has removed/replaced + func setSidebarCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + sidebarModule = nil + return + } + + sidebarModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + /// Set the coordinator to be used on the split's right pannel + /// - Parameters: + /// - coordinator: the detail coordinator + /// - dismissalCallback: called when this particular detail coordinator has removed/replaced + func setDetailCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + detailModule = nil + return + } + + detailModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + /// Present a sheet on top of the split view + /// - Parameters: + /// - coordinator: the coordinator to display + /// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise + func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + sheetModule = nil + return + } + + sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + // MARK: - CoordinatorProtocol + + func toPresentable() -> AnyView { + AnyView(NavigationSplitCoordinatorView(navigationSplitCoordinator: self)) + } + + // MARK: - CustomStringConvertible + + var description: String { + switch (sidebarModule?.coordinator, detailModule?.coordinator) { + case (.some(let sidebarCoordinator), .some(let detailCoordinator)): + return "NavigationSplitCoordinator(\(sidebarCoordinator) | \(detailCoordinator))" + case (.some(let sidebarCoordinator), .none): + return "NavigationSplitCoordinator(\(sidebarCoordinator) | Empty)" + case (.none, .some(let detailCoordinator)): + return "NavigationSplitCoordinator(Empty | \(detailCoordinator))" + case (.none, .none): + return "NavigationSplitCoordinator(Empty | Empty)" + } + } + + // MARK: - Private + + private func logPresentationChange(_ change: String, _ module: NavigationModule) { + MXLog.info("\(self) \(change): \(module.coordinator)") + } + + /// We need to update the compact layout whenever anything changes within the split coordinator or + /// the navigation coordinators embedded into it + private func updateCompactLayoutComponents() { + // First remove all observers + cancellables.removeAll() + + // Start building the new compact layout navigation stack + var stackModules: [NavigationModule] = [] + // If the sidebar is a stackCoordinator then use it's root as the compact layout root + // and push its children to the compact layout stack + if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator { + // Observe changes on embedded stackCoordinators and reflect them in the compact layout components + observe(navigationStackCoordinator: sidebarNavigationStackCoordinator) + + if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule { + compactLayoutRootModule = sidebarRootModule + } + + stackModules.append(contentsOf: sidebarNavigationStackCoordinator.stackModules) + } else if let sidebarModule { // Otherwise just use it as a root directly + compactLayoutRootModule = sidebarModule + } + + // If the detail is a stackCoordinator then push its root and children to the compact layout stack + if let detailNavigationStackCoordinator = detailModule?.coordinator as? NavigationStackCoordinator { + // Observe changes on embedded stackCoordinators and reflect them in the compact layout components + observe(navigationStackCoordinator: detailNavigationStackCoordinator) + + if let detailRootCoordinator = detailNavigationStackCoordinator.rootModule { + stackModules.append(detailRootCoordinator) + } + + stackModules.append(contentsOf: detailNavigationStackCoordinator.stackModules) + } else if let detailModule { // Otherwise just push it entirely + stackModules.append(detailModule) + } + compactLayoutStackModules = stackModules + + // Observe and process compact layout changes + observeCompactLayoutStackChanges() + } + + /// Changes to the navigation stack while in a compact layout should be + /// reflected back onto the embedded components e.g. stackCoordinator pops + private func observeCompactLayoutStackChanges() { + $compactLayoutStackModules.sink { [weak self] stackModules in + guard let self, self.compactLayoutStackModules != stackModules else { return } + + let diffs = stackModules.difference(from: self.compactLayoutStackModules) + diffs.forEach { change in + switch change { + case .insert: + break + case .remove(_, let module, _): + self.processCompactLayoutStackModuleRemoval(module) + } + } + } + .store(in: &cancellables) + } + + /// Manually process changes to the compact layout navigation stack and update embedded components + /// We need to either: pop from the detail, nil the detail or pop from the sidebar + private func processCompactLayoutStackModuleRemoval(_ module: NavigationModule) { + if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator { + if sidebarNavigationStackCoordinator.stackModules.contains(module) { + sidebarNavigationStackCoordinator.stackModules.removeAll { $0 == module } + } + } + + if module == detailModule { + detailModule = nil + } + + if let detailNavigationStackCoordinator = detailModule?.coordinator as? NavigationStackCoordinator { + if detailNavigationStackCoordinator.stackModules.contains(module) { + detailNavigationStackCoordinator.stackModules.removeAll { $0 == module } + } else if module == detailNavigationStackCoordinator.rootModule { + detailModule = nil + } + } + } + + /// Any change to a NavigationStackCoordinator's internal state should be observed and reflected in the + /// compact layout components + private func observe(navigationStackCoordinator: NavigationStackCoordinator) { + navigationStackCoordinator.$rootModule.sink { [weak self] rootModule in + guard navigationStackCoordinator.rootModule != rootModule else { return } + DispatchQueue.main.async { self?.updateCompactLayoutComponents() } + } + .store(in: &cancellables) + + navigationStackCoordinator.$stackModules.sink { [weak self] stackModules in + guard navigationStackCoordinator.stackModules != stackModules else { return } + DispatchQueue.main.async { self?.updateCompactLayoutComponents() } + } + .store(in: &cancellables) + + navigationStackCoordinator.$sheetModule.sink { [weak self] sheetModule in + guard navigationStackCoordinator.sheetModule != sheetModule else { return } + DispatchQueue.main.async { self?.updateCompactLayoutComponents() } + } + .store(in: &cancellables) + } +} + +private struct NavigationSplitCoordinatorView: View { + @State private var columnVisibility = NavigationSplitViewVisibility.all + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @ObservedObject var navigationSplitCoordinator: NavigationSplitCoordinator + + var body: some View { + Group { + if horizontalSizeClass == .compact { + navigationStack + } else { + navigationSplitView + } + } + // This needs to be handled on the top level otherwise sheets + // will be automatically dismissed on hierarchy changes. + // Embedded NavigationStackCoordinators will present their sheets + // through the NavigationSplitCoordinator as well. + .sheet(item: $navigationSplitCoordinator.sheetModule) { module in + module.coordinator.toPresentable() + } + } + + /// The NavigationStack that will be used in compact layouts + var navigationStack: some View { + NavigationStack(path: $navigationSplitCoordinator.compactLayoutStackModules) { + navigationSplitCoordinator.compactLayoutRootModule?.coordinator.toPresentable() + .navigationDestination(for: NavigationModule.self) { module in + module.coordinator.toPresentable() + } + } + } + + /// The NavigationSplitView that will be used in non-compact layouts + var navigationSplitView: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + if let sidebarModule = navigationSplitCoordinator.sidebarModule { + sidebarModule.coordinator.toPresentable() + } else { + navigationSplitCoordinator.placeholderModule.coordinator.toPresentable() + } + } detail: { + if let detailModule = navigationSplitCoordinator.detailModule { + detailModule.coordinator.toPresentable() + } else { + navigationSplitCoordinator.placeholderModule.coordinator.toPresentable() + } + } + .navigationSplitViewStyle(.balanced) + .navigationDestination(for: NavigationModule.self) { module in + module.coordinator.toPresentable() + } + } +} + +// MARK: - NavigationStackCoordinator + +/// Class responsible for displaying a normal "NavigationController" style hierarchy +class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomStringConvertible { + private(set) weak var navigationSplitCoordinator: NavigationSplitCoordinator? + + @Published fileprivate var rootModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove root", oldValue) + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let rootModule { + logPresentationChange("Set root", rootModule) + rootModule.coordinator.start() + } + } + } + + // The stack's current root coordinator + var rootCoordinator: (any CoordinatorProtocol)? { + rootModule?.coordinator + } + + @Published fileprivate var sheetModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove sheet", oldValue) + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let sheetModule { + logPresentationChange("Set sheet", sheetModule) + sheetModule.coordinator.start() + } + } + } + + // The currently presented sheet coordinator + // Sheets will be presented through the NavigationSplitCoordinator if provided + var sheetCoordinator: (any CoordinatorProtocol)? { + if let navigationSplitCoordinator { + return navigationSplitCoordinator.sheetCoordinator + } + + return sheetModule?.coordinator + } + + @Published fileprivate var stackModules = [NavigationModule]() { + didSet { + let diffs = stackModules.difference(from: oldValue) + diffs.forEach { change in + switch change { + case .insert(_, let module, _): + logPresentationChange("Push", module) + module.coordinator.start() + case .remove(_, let module, _): + logPresentationChange("Pop", module) + module.coordinator.stop() + module.dismissalCallback?() + } + } + } + } + + // The current navigation stack. Excludes the rootCoordinator + var stackCoordinators: [any CoordinatorProtocol] { + stackModules.map(\.coordinator) + } + + /// If this NavigationStackCoordinator will be embedded into a NavigationSplitCoordinator pass it here + /// so that sheet presentations are done through it. Otherwise sheets will not be presented properly + /// and dismissed automatically in compact layouts + /// - Parameter navigationSplitCoordinator: The expected parent NavigationSplitCoordinator + init(navigationSplitCoordinator: NavigationSplitCoordinator? = nil) { + self.navigationSplitCoordinator = navigationSplitCoordinator + } + + /// Set the coordinator to be used on the stack's root + /// - Parameters: + /// - coordinator: the root coordinator + /// - dismissalCallback: called when this root coordinator has removed/replaced + func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + rootModule = nil + return + } + + popToRoot(animated: false) + + rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + /// Pushes a new coordinator on the navigation stack + /// - Parameters: + /// - coordinator: the coordinator to be displayed + /// - dismissalCallback: called when the coordinator has been popped, programatically or otherwise + func push(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) { + stackModules.append(NavigationModule(coordinator, dismissalCallback: dismissalCallback)) + } + + /// Pop all the coordinators from the stack, returning to the root coordinator + /// - Parameter animated: whether to animate the transition or not. Default is true + func popToRoot(animated: Bool = true) { + guard !stackModules.isEmpty else { + return + } + + if !animated { + // Disabling animations doesn't work through normal Transactions + // https://stackoverflow.com/questions/72832243 + UIView.setAnimationsEnabled(false) + } + + stackModules.removeAll() + + if !animated { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + UIView.setAnimationsEnabled(true) + } + } + } + + /// Removes the last coordinator from the navigation stack + func pop() { + stackModules.removeLast() + } + + /// Present a sheet on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator + /// then the presentation will be proxied to the split + /// - Parameters: + /// - coordinator: the coordinator to display + /// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise + func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + if let navigationSplitCoordinator { + navigationSplitCoordinator.setSheetCoordinator(coordinator, dismissalCallback: dismissalCallback) + return + } + + guard let coordinator else { + sheetModule = nil + return + } + + sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + // MARK: - CoordinatorProtocol + + func toPresentable() -> AnyView { + AnyView(NavigationStackCoordinatorView(navigationStackCoordinator: self)) + } + + // MARK: - CustomStringConvertible + + var description: String { + if let rootCoordinator = rootModule?.coordinator { + return "NavigationStackCoordinator(\(rootCoordinator))" + } else { + return "NavigationStackCoordinator(Empty)" + } + } + + // MARK: - Private + + private func logPresentationChange(_ change: String, _ module: NavigationModule) { + MXLog.info("\(self) \(change): \(module.coordinator)") + } +} + +private struct NavigationStackCoordinatorView: View { + @ObservedObject var navigationStackCoordinator: NavigationStackCoordinator + + var body: some View { + NavigationStack(path: $navigationStackCoordinator.stackModules) { + navigationStackCoordinator.rootModule?.coordinator.toPresentable() + .navigationDestination(for: NavigationModule.self) { module in + module.coordinator.toPresentable() + } + } + .sheet(item: $navigationStackCoordinator.sheetModule) { module in + module.coordinator.toPresentable() + } + } +} diff --git a/ElementX/Sources/Application/Navigation/NavigationModule.swift b/ElementX/Sources/Application/Navigation/NavigationModule.swift new file mode 100644 index 000000000..e1d19a75e --- /dev/null +++ b/ElementX/Sources/Application/Navigation/NavigationModule.swift @@ -0,0 +1,38 @@ +// +// Copyright 2022 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 + +/// A CoordinatorProtocol wrapper and type erasing component that allows +/// dynamically presenting arbitrary screens +struct NavigationModule: Identifiable, Hashable { + let id = UUID() + let coordinator: any CoordinatorProtocol + let dismissalCallback: (() -> Void)? + + init(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) { + self.coordinator = coordinator + self.dismissalCallback = dismissalCallback + } + + static func == (lhs: NavigationModule, rhs: NavigationModule) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/ElementX/Sources/Application/Navigation/NavigationRootCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationRootCoordinator.swift new file mode 100644 index 000000000..84dbe0fc0 --- /dev/null +++ b/ElementX/Sources/Application/Navigation/NavigationRootCoordinator.swift @@ -0,0 +1,82 @@ +// +// Copyright 2022 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 SwiftUI + +class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomStringConvertible { + @Published fileprivate var rootModule: NavigationModule? { + didSet { + if let oldValue { + oldValue.coordinator.stop() + oldValue.dismissalCallback?() + } + + if let rootModule { + logPresentationChange("Set root", rootModule) + rootModule.coordinator.start() + } + } + } + + /// The currently displayed coordinator + var rootCoordinator: (any CoordinatorProtocol)? { + rootModule?.coordinator + } + + /// Sets or replaces the presented coordinator + /// - Parameter coordinator: the coordinator to display + func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + rootModule = nil + return + } + + rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + + // MARK: - CoordinatorProtocol + + func toPresentable() -> AnyView { + AnyView(NavigationRootCoordinatorView(rootCoordinator: self)) + } + + // MARK: - CustomStringConvertible + + var description: String { + if let rootCoordinator = rootModule?.coordinator { + return "SingleScreenCoordinator(\(rootCoordinator)" + } else { + return "SingleScreenCoordinator(Empty)" + } + } + + // MARK: - Private + + private func logPresentationChange(_ change: String, _ module: NavigationModule) { + MXLog.info("\(self) \(change): \(module.coordinator)") + } +} + +private struct NavigationRootCoordinatorView: View { + @ObservedObject var rootCoordinator: NavigationRootCoordinator + + var body: some View { + ZStack { + rootCoordinator.rootModule?.coordinator.toPresentable() + } + .animation(.elementDefault, value: rootCoordinator.rootModule) + } +} diff --git a/ElementX/Sources/Application/NavigationController.swift b/ElementX/Sources/Application/NavigationController.swift deleted file mode 100644 index 0b79a1472..000000000 --- a/ElementX/Sources/Application/NavigationController.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// Copyright 2022 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 SwiftUI - -class NavigationController: ObservableObject, CoordinatorProtocol { - private var dismissalCallbacks = [UUID: () -> Void]() - - @Published fileprivate var internalRootCoordinator: AnyCoordinator? { - didSet { - if let oldValue { - oldValue.coordinator.stop() - } - - if let internalRootCoordinator { - logPresentationChange("Set root", internalRootCoordinator) - internalRootCoordinator.coordinator.start() - } - } - } - - @Published fileprivate var internalSheetCoordinator: AnyCoordinator? { - didSet { - if let oldValue { - logPresentationChange("Dismiss", oldValue) - oldValue.coordinator.stop() - dismissalCallbacks[oldValue.id]?() - dismissalCallbacks.removeValue(forKey: oldValue.id) - } - - if let internalSheetCoordinator { - logPresentationChange("Present", internalSheetCoordinator) - internalSheetCoordinator.coordinator.start() - } - } - } - - @Published fileprivate var internalNavigationStack = [AnyCoordinator]() { - didSet { - let diffs = internalNavigationStack.difference(from: oldValue) - diffs.forEach { change in - switch change { - case .insert(_, let anyCoordinator, _): - logPresentationChange("Push", anyCoordinator) - anyCoordinator.coordinator.start() - case .remove(_, let anyCoordinator, _): - logPresentationChange("Pop", anyCoordinator) - anyCoordinator.coordinator.stop() - - dismissalCallbacks[anyCoordinator.id]?() - dismissalCallbacks.removeValue(forKey: anyCoordinator.id) - } - } - } - } - - var rootCoordinator: CoordinatorProtocol? { - internalRootCoordinator?.coordinator - } - - var coordinators: [CoordinatorProtocol] { - internalNavigationStack.map(\.coordinator) - } - - var sheetCoordinator: CoordinatorProtocol? { - internalSheetCoordinator?.coordinator - } - - func setRootCoordinator(_ coordinator: any CoordinatorProtocol) { - popToRoot(animated: false) - internalRootCoordinator = AnyCoordinator(coordinator) - } - - func push(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) { - let anyCoordinator = AnyCoordinator(coordinator) - - if let dismissalCallback { - dismissalCallbacks[anyCoordinator.id] = dismissalCallback - } - - internalNavigationStack.append(anyCoordinator) - } - - func popToRoot(animated: Bool = true) { - dismissSheet() - - guard !internalNavigationStack.isEmpty else { - return - } - - if !animated { - // Disabling animations doesn't work through normal Transactions - // https://stackoverflow.com/questions/72832243 - UIView.setAnimationsEnabled(false) - } - - internalNavigationStack.removeAll() - - if !animated { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - UIView.setAnimationsEnabled(true) - } - } - } - - func pop() { - dismissSheet() - internalNavigationStack.removeLast() - } - - func presentSheet(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) { - let anyCoordinator = AnyCoordinator(coordinator) - - if let dismissalCallback { - dismissalCallbacks[anyCoordinator.id] = dismissalCallback - } - - internalSheetCoordinator = anyCoordinator - } - - func dismissSheet() { - internalSheetCoordinator = nil - } - - // MARK: - CoordinatorProtocol - - func toPresentable() -> AnyView { - AnyView(NavigationControllerView(navigationController: self)) - } - - // MARK: - Private - - private func logPresentationChange(_ change: String, _ anyCoordinator: AnyCoordinator) { - if let navigationCoordinator = anyCoordinator.coordinator as? NavigationController, let rootCoordinator = navigationCoordinator.rootCoordinator { - MXLog.info("\(change): NavigationController(\(anyCoordinator.id)) - \(rootCoordinator)") - } else { - MXLog.info("\(change): \(anyCoordinator.coordinator)(\(anyCoordinator.id))") - } - } -} - -private struct NavigationControllerView: View { - @ObservedObject var navigationController: NavigationController - - var body: some View { - NavigationStack(path: $navigationController.internalNavigationStack) { - navigationController.internalRootCoordinator?.coordinator.toPresentable() - .navigationDestination(for: AnyCoordinator.self) { anyCoordinator in - anyCoordinator.coordinator.toPresentable() - } - } - .sheet(item: $navigationController.internalSheetCoordinator) { anyCoordinator in - anyCoordinator.coordinator.toPresentable() - } - } -} - -private struct AnyCoordinator: Identifiable, Hashable { - let id = UUID() - let coordinator: any CoordinatorProtocol - - init(_ coordinator: any CoordinatorProtocol) { - self.coordinator = coordinator - } - - static func == (lhs: AnyCoordinator, rhs: AnyCoordinator) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} diff --git a/ElementX/Sources/Other/UserNotifications/UserNotificationController.swift b/ElementX/Sources/Other/UserNotifications/UserNotificationController.swift index d10d88564..9ff21ad36 100644 --- a/ElementX/Sources/Other/UserNotifications/UserNotificationController.swift +++ b/ElementX/Sources/Other/UserNotifications/UserNotificationController.swift @@ -16,7 +16,7 @@ import SwiftUI -class UserNotificationController: ObservableObject, UserNotificationControllerProtocol { +class UserNotificationController: ObservableObject, UserNotificationControllerProtocol, CustomStringConvertible { private let rootCoordinator: CoordinatorProtocol private var dismisalTimer: Timer? @@ -77,4 +77,10 @@ class UserNotificationController: ObservableObject, UserNotificationControllerPr self?.displayTimes[id] = nil } } + + // MARK: - CustomStringConvertible + + var description: String { + "UserNotificationController(\(String(describing: rootCoordinator)))" + } } diff --git a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift index 2c788c646..82515131c 100644 --- a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift @@ -24,14 +24,14 @@ protocol AuthenticationCoordinatorDelegate: AnyObject { class AuthenticationCoordinator: CoordinatorProtocol { private let authenticationService: AuthenticationServiceProxyProtocol - private let navigationController: NavigationController + private let navigationStackCoordinator: NavigationStackCoordinator weak var delegate: AuthenticationCoordinatorDelegate? init(authenticationService: AuthenticationServiceProxyProtocol, - navigationController: NavigationController) { + navigationStackCoordinator: NavigationStackCoordinator) { self.authenticationService = authenticationService - self.navigationController = navigationController + self.navigationStackCoordinator = navigationStackCoordinator } func start() { @@ -55,7 +55,7 @@ class AuthenticationCoordinator: CoordinatorProtocol { } } - navigationController.setRootCoordinator(coordinator) + navigationStackCoordinator.setRootCoordinator(coordinator) } private func startAuthentication() async { @@ -88,12 +88,12 @@ class AuthenticationCoordinator: CoordinatorProtocol { } } - navigationController.push(coordinator) + navigationStackCoordinator.push(coordinator) } private func showLoginScreen() { let parameters = LoginCoordinatorParameters(authenticationService: authenticationService, - navigationController: navigationController) + navigationStackCoordinator: navigationStackCoordinator) let coordinator = LoginCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in @@ -105,7 +105,7 @@ class AuthenticationCoordinator: CoordinatorProtocol { } } - navigationController.push(coordinator) + navigationStackCoordinator.push(coordinator) } private func showAnalyticsPrompt(with userSession: UserSessionProtocol) { @@ -117,7 +117,7 @@ class AuthenticationCoordinator: CoordinatorProtocol { self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) } - navigationController.setRootCoordinator(coordinator) + navigationStackCoordinator.setRootCoordinator(coordinator) } static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading" diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift index 1639589ad..734f7aa8e 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift @@ -21,7 +21,7 @@ struct LoginCoordinatorParameters { /// The service used to authenticate the user. let authenticationService: AuthenticationServiceProxyProtocol /// The navigation router used to present the server selection screen. - let navigationController: NavigationController + let navigationStackCoordinator: NavigationStackCoordinator } enum LoginCoordinatorAction { @@ -43,7 +43,7 @@ final class LoginCoordinator: CoordinatorProtocol { } private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService } - private var navigationController: NavigationController { parameters.navigationController } + private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator } var callback: (@MainActor (LoginCoordinatorAction) -> Void)? @@ -195,9 +195,9 @@ final class LoginCoordinator: CoordinatorProtocol { /// Presents the server selection screen as a modal. private func presentServerSelectionScreen() { - let serverSelectionNavigationController = NavigationController() + let serverSelectionNavigationStackCoordinator = NavigationStackCoordinator() - let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationController) + let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationStackCoordinator) let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService, userNotificationController: userNotificationController, @@ -209,9 +209,9 @@ final class LoginCoordinator: CoordinatorProtocol { self.serverSelectionCoordinator(coordinator, didCompleteWith: action) } - serverSelectionNavigationController.setRootCoordinator(coordinator) + serverSelectionNavigationStackCoordinator.setRootCoordinator(coordinator) - navigationController.presentSheet(userNotificationController) + navigationStackCoordinator.setSheetCoordinator(userNotificationController) } /// Handles the result from the server selection modal, dismissing it after updating the view. @@ -221,7 +221,7 @@ final class LoginCoordinator: CoordinatorProtocol { updateViewModel() } - navigationController.dismissSheet() + navigationStackCoordinator.setSheetCoordinator(nil) } /// Shows the forgot password screen. diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index d65a92567..d36336f7a 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -21,7 +21,7 @@ struct HomeScreenCoordinatorParameters { let userSession: UserSessionProtocol let attributedStringBuilder: AttributedStringBuilderProtocol let bugReportService: BugReportServiceProtocol - let navigationController: NavigationController + let navigationStackCoordinator: NavigationStackCoordinator } enum HomeScreenCoordinatorAction { @@ -94,6 +94,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol { } private func presentInviteFriends() { - parameters.navigationController.presentSheet(InviteFriendsCoordinator(userId: parameters.userSession.userID)) + parameters.navigationStackCoordinator.setSheetCoordinator(InviteFriendsCoordinator(userId: parameters.userSession.userID)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 7f0bced60..ec95a899b 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -17,7 +17,7 @@ import SwiftUI struct RoomScreenCoordinatorParameters { - let navigationController: NavigationController + let navigationStackCoordinator: NavigationStackCoordinator let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let roomName: String? @@ -29,12 +29,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol { private var parameters: RoomScreenCoordinatorParameters? private var viewModel: RoomScreenViewModelProtocol? - private var navigationController: NavigationController { + private var navigationStackCoordinator: NavigationStackCoordinator { guard let parameters else { fatalError() } - return parameters.navigationController + return parameters.navigationStackCoordinator } init(parameters: RoomScreenCoordinatorParameters) { @@ -86,19 +86,19 @@ final class RoomScreenCoordinator: CoordinatorProtocol { if params.isModallyPresented { coordinator.callback = { [weak self] _ in - self?.navigationController.dismissSheet() + self?.navigationStackCoordinator.setSheetCoordinator(nil) } - let controller = NavigationController() + let controller = NavigationStackCoordinator() controller.setRootCoordinator(coordinator) - navigationController.presentSheet(controller) + navigationStackCoordinator.setSheetCoordinator(controller) } else { coordinator.callback = { [weak self] _ in - self?.navigationController.pop() + self?.navigationStackCoordinator.pop() } - navigationController.push(coordinator) + navigationStackCoordinator.push(coordinator) } } @@ -106,10 +106,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title) let coordinator = FilePreviewCoordinator(parameters: params) coordinator.callback = { [weak self] _ in - self?.navigationController.pop() + self?.navigationStackCoordinator.pop() } - navigationController.push(coordinator) + navigationStackCoordinator.push(coordinator) } private func displayEmojiPickerScreen(for itemId: String) { @@ -123,13 +123,14 @@ final class RoomScreenCoordinator: CoordinatorProtocol { coordinator.callback = { [weak self] action in switch action { case let .emojiSelected(emoji: emoji, itemId: itemId): - self?.navigationController.dismissSheet() + self?.navigationStackCoordinator.setSheetCoordinator(nil) + MXLog.debug("Save \(emoji) for \(itemId)") Task { await timelineController.sendReaction(emoji, for: itemId) } } } - navigationController.presentSheet(coordinator) + navigationStackCoordinator.setSheetCoordinator(coordinator) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 19abe2a71..3ef61a105 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -31,6 +31,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Setup + deinit { + ServiceLocator.shared.userNotificationController.retractNotificationWithId(Constants.backPaginationIndicatorID) + } + init(timelineController: RoomTimelineControllerProtocol, timelineViewFactory: RoomTimelineViewFactoryProtocol, mediaProvider: MediaProviderProtocol, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index fc4fe05b9..ee647b446 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -33,6 +33,7 @@ struct RoomScreen: View { var timeline: some View { TimelineTableView() + .id(context.viewState.roomId) .environmentObject(context) .timelineStyle(settings.timelineStyle) .overlay(alignment: .bottomTrailing) { scrollToBottomButton } diff --git a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift index 98c5cf05b..c9449a326 100644 --- a/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsCoordinator.swift @@ -17,7 +17,7 @@ import SwiftUI struct SettingsCoordinatorParameters { - let navigationController: NavigationController + let navigationStackCoordinator: NavigationStackCoordinator let userNotificationController: UserNotificationControllerProtocol let userSession: UserSessionProtocol let bugReportService: BugReportServiceProtocol @@ -88,10 +88,10 @@ final class SettingsCoordinator: CoordinatorProtocol { break } - self?.parameters.navigationController.pop() + self?.parameters.navigationStackCoordinator.pop() } - parameters.navigationController.push(coordinator) + parameters.navigationStackCoordinator.push(coordinator) } private func showSuccess(label: String) { diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 4309f9750..eef58bb99 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -24,18 +24,26 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { private let stateMachine: UserSessionFlowCoordinatorStateMachine private let userSession: UserSessionProtocol - private let navigationController: NavigationController + private let navigationSplitCoordinator: NavigationSplitCoordinator private let bugReportService: BugReportServiceProtocol private let emojiProvider: EmojiProviderProtocol = EmojiProvider() + private let sidebarNavigationStackCoordinator: NavigationStackCoordinator + private let detailNavigationStackCoordinator: NavigationStackCoordinator + var callback: ((UserSessionFlowCoordinatorAction) -> Void)? - init(userSession: UserSessionProtocol, navigationController: NavigationController, bugReportService: BugReportServiceProtocol) { + init(userSession: UserSessionProtocol, navigationSplitCoordinator: NavigationSplitCoordinator, bugReportService: BugReportServiceProtocol) { stateMachine = UserSessionFlowCoordinatorStateMachine() self.userSession = userSession - self.navigationController = navigationController + self.navigationSplitCoordinator = navigationSplitCoordinator self.bugReportService = bugReportService + sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + setupStateMachine() } @@ -50,7 +58,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { } func tryDisplayingRoomScreen(roomId: String) { - stateMachine.processEvent(.showRoomScreen(roomId: roomId)) + stateMachine.processEvent(.selectRoom(roomId: roomId)) } // MARK: - Private @@ -61,27 +69,32 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { guard let self else { return } switch (context.fromState, context.event, context.toState) { - case (.initial, .start, .homeScreen): + case (.initial, .start, .roomList): self.presentHomeScreen() - case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)): - self.presentRoomWithIdentifier(roomId) - case(.roomScreen, .dismissedRoomScreen, .homeScreen): - break + case(.roomList(let currentRoomId), .selectRoom, .roomList(let selectedRoomId)): + guard let selectedRoomId, + selectedRoomId != currentRoomId else { + return + } - case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen): + self.presentRoomWithIdentifier(selectedRoomId) + case(.roomList, .deselectRoom, .roomList): + break + + case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen): self.presentSessionVerification() - case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen): + case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .roomList): break - - case (.homeScreen, .showSettingsScreen, .settingsScreen): + + case (.roomList, .showSettingsScreen, .settingsScreen): self.presentSettingsScreen() - case (.settingsScreen, .dismissedSettingsScreen, .homeScreen): + case (.settingsScreen, .dismissedSettingsScreen, .roomList): break - case (.homeScreen, .feedbackScreen, .feedbackScreen): + case (.roomList, .feedbackScreen, .feedbackScreen): self.presentFeedbackScreen() - case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen): + case (.feedbackScreen, .dismissedFeedbackScreen, .roomList): break default: @@ -100,7 +113,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let parameters = HomeScreenCoordinatorParameters(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(), bugReportService: bugReportService, - navigationController: navigationController) + navigationStackCoordinator: detailNavigationStackCoordinator) let coordinator = HomeScreenCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in @@ -108,7 +121,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { switch action { case .presentRoomScreen(let roomIdentifier): - self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier)) + self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier)) case .presentSettingsScreen: self.stateMachine.processEvent(.showSettingsScreen) case .presentFeedbackScreen: @@ -119,8 +132,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.callback?(.signOut) } } - - navigationController.setRootCoordinator(coordinator) + + sidebarNavigationStackCoordinator.setRootCoordinator(coordinator) } // MARK: Rooms @@ -145,7 +158,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { mediaProvider: userSession.mediaProvider, roomProxy: roomProxy) - let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController, + let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator, timelineController: timelineController, mediaProvider: userSession.mediaProvider, roomName: roomProxy.displayName ?? roomProxy.name, @@ -153,9 +166,17 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { emojiProvider: emojiProvider) let coordinator = RoomScreenCoordinator(parameters: parameters) - navigationController.push(coordinator) { [weak self] in + detailNavigationStackCoordinator.setRootCoordinator(coordinator) + navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator) { [weak self, roomIdentifier] in guard let self else { return } - self.stateMachine.processEvent(.dismissedRoomScreen) + + // Move the state machine to no room selected if the room currently being dimissed + // is the same as the one selected in the state machine. + // This generally happens when popping the room screen while in a compact layout + if case let .roomList(selectedRoomId) = self.stateMachine.state, selectedRoomId == roomIdentifier { + self.stateMachine.processEvent(.deselectRoom) + self.detailNavigationStackCoordinator.setRootCoordinator(nil) + } } } } @@ -163,11 +184,11 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { // MARK: Settings private func presentSettingsScreen() { - let settingsNavigationController = NavigationController() + let settingsNavigationStackCoordinator = NavigationStackCoordinator() - let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationController) + let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationStackCoordinator) - let parameters = SettingsCoordinatorParameters(navigationController: settingsNavigationController, + let parameters = SettingsCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator, userNotificationController: userNotificationController, userSession: userSession, bugReportService: bugReportService) @@ -176,16 +197,16 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { guard let self else { return } switch action { case .dismiss: - self.navigationController.dismissSheet() + self.navigationSplitCoordinator.setSheetCoordinator(nil) case .logout: - self.navigationController.dismissSheet() + self.navigationSplitCoordinator.setSheetCoordinator(nil) self.callback?(.signOut) } } - settingsNavigationController.setRootCoordinator(settingsCoordinator) + settingsNavigationStackCoordinator.setRootCoordinator(settingsCoordinator) - navigationController.presentSheet(userNotificationController) { [weak self] in + navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in self?.stateMachine.processEvent(.dismissedSettingsScreen) } } @@ -202,10 +223,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let coordinator = SessionVerificationCoordinator(parameters: parameters) coordinator.callback = { [weak self] in - self?.navigationController.dismissSheet() + self?.navigationSplitCoordinator.setSheetCoordinator(nil) } - navigationController.presentSheet(coordinator) { [weak self] in + navigationSplitCoordinator.setSheetCoordinator(coordinator) { [weak self] in self?.stateMachine.processEvent(.dismissedSessionVerificationScreen) } } @@ -213,9 +234,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { // MARK: Bug reporting private func presentFeedbackScreen(for image: UIImage? = nil) { - let feedbackNavigationController = NavigationController() + let feedbackNavigationStackCoordinator = NavigationStackCoordinator() - let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationController) + let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationStackCoordinator) let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService, userNotificationController: userNotificationController, @@ -223,12 +244,12 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { isModallyPresented: true) let coordinator = BugReportCoordinator(parameters: parameters) coordinator.completion = { [weak self] _ in - self?.navigationController.dismissSheet() + self?.navigationSplitCoordinator.setSheetCoordinator(nil) } - feedbackNavigationController.setRootCoordinator(coordinator) + feedbackNavigationStackCoordinator.setRootCoordinator(coordinator) - navigationController.presentSheet(userNotificationController) { [weak self] in + navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in self?.stateMachine.processEvent(.dismissedFeedbackScreen) } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift index e7253e09a..12d2ad5e8 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift @@ -24,20 +24,16 @@ class UserSessionFlowCoordinatorStateMachine { case initial /// Showing the home screen - case homeScreen - - /// Showing a particular room's timeline - /// - Parameter roomId: that room's identifier - case roomScreen(roomId: String) + case roomList(selectedRoomId: String?) + + /// Showing the session verification flows + case sessionVerificationScreen(selectedRoomId: String?) /// Showing the session verification flows - case sessionVerificationScreen - - /// Showing the session verification flows - case feedbackScreen + case feedbackScreen(selectedRoomId: String?) /// Showing the settings screen - case settingsScreen + case settingsScreen(selectedRoomId: String?) } /// Events that can be triggered on the AppCoordinator state machine @@ -47,9 +43,9 @@ class UserSessionFlowCoordinatorStateMachine { /// Request presentation for a particular room /// - Parameter roomId:the room identifier - case showRoomScreen(roomId: String) + case selectRoom(roomId: String) /// The room screen has been dismissed - case dismissedRoomScreen + case deselectRoom /// Request presentation of the settings screen case showSettingsScreen @@ -69,35 +65,39 @@ class UserSessionFlowCoordinatorStateMachine { private let stateMachine: StateMachine + var state: UserSessionFlowCoordinatorStateMachine.State { + stateMachine.state + } + init() { stateMachine = StateMachine(state: .initial) configure() } private func configure() { - stateMachine.addRoutes(event: .start, transitions: [.initial => .homeScreen]) + stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(selectedRoomId: nil)]) stateMachine.addRouteMapping { event, fromState, _ in switch (event, fromState) { - case (.showRoomScreen(let roomId), .homeScreen): - return .roomScreen(roomId: roomId) - case (.dismissedRoomScreen, .roomScreen): - return .homeScreen + case (.selectRoom(let roomId), .roomList): + return .roomList(selectedRoomId: roomId) + case (.deselectRoom, .roomList): + return .roomList(selectedRoomId: nil) - case (.showSettingsScreen, .homeScreen): - return .settingsScreen - case (.dismissedSettingsScreen, .settingsScreen): - return .homeScreen + case (.showSettingsScreen, .roomList(let selectedRoomId)): + return .settingsScreen(selectedRoomId: selectedRoomId) + case (.dismissedSettingsScreen, .settingsScreen(let selectedRoomId)): + return .roomList(selectedRoomId: selectedRoomId) - case (.feedbackScreen, .homeScreen): - return .feedbackScreen - case (.dismissedFeedbackScreen, .feedbackScreen): - return .homeScreen + case (.feedbackScreen, .roomList(let selectedRoomId)): + return .feedbackScreen(selectedRoomId: selectedRoomId) + case (.dismissedFeedbackScreen, .feedbackScreen(let selectedRoomId)): + return .roomList(selectedRoomId: selectedRoomId) - case (.showSessionVerificationScreen, .homeScreen): - return .sessionVerificationScreen - case (.dismissedSessionVerificationScreen, .sessionVerificationScreen): - return .homeScreen + case (.showSessionVerificationScreen, .roomList(let selectedRoomId)): + return .sessionVerificationScreen(selectedRoomId: selectedRoomId) + case (.dismissedSessionVerificationScreen, .sessionVerificationScreen(let selectedRoomId)): + return .roomList(selectedRoomId: selectedRoomId) default: return nil @@ -132,8 +132,8 @@ class UserSessionFlowCoordinatorStateMachine { /// Flag indicating the machine is displaying room screen with given room identifier func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool { switch stateMachine.state { - case .roomScreen(let displayedRoomId): - return roomId == displayedRoomId + case .roomList(let selectedRoomId): + return roomId == selectedRoomId default: return false } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index e0c0729d8..1b7b2ff3d 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -19,11 +19,11 @@ import UIKit class UITestsAppCoordinator: AppCoordinatorProtocol { private var currentRootCoordinator: CoordinatorProtocol? - private let navigationController: NavigationController + private let navigationStackCoordinator: NavigationStackCoordinator let notificationManager: NotificationManagerProtocol? = nil init() { - navigationController = NavigationController() + navigationStackCoordinator = NavigationStackCoordinator() ServiceLocator.shared.register(userNotificationController: MockUserNotificationController()) } @@ -39,32 +39,32 @@ class UITestsAppCoordinator: AppCoordinatorProtocol { // For example when replacing the root in the authentication flows self.currentRootCoordinator = screen.coordinator - self.navigationController.setRootCoordinator(screen.coordinator) + self.navigationStackCoordinator.setRootCoordinator(screen.coordinator) } - navigationController.setRootCoordinator(rootCoordinator) + navigationStackCoordinator.setRootCoordinator(rootCoordinator) Bundle.elementFallbackLanguage = "en" } func toPresentable() -> AnyView { - navigationController.toPresentable() + navigationStackCoordinator.toPresentable() } private func mockScreens() -> [MockScreen] { - UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationController: navigationController) } + UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationStackCoordinator: navigationStackCoordinator) } } } @MainActor class MockScreen: Identifiable { let id: UITestScreenIdentifier - let navigationController: NavigationController + let navigationStackCoordinator: NavigationStackCoordinator lazy var coordinator: CoordinatorProtocol = { switch id { case .login: return LoginCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), - navigationController: navigationController)) + navigationStackCoordinator: navigationStackCoordinator)) case .serverSelection: return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), userNotificationController: MockUserNotificationController(), @@ -78,7 +78,7 @@ class MockScreen: Identifiable { mediaProvider: MockMediaProvider()))) case .authenticationFlow: return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(), - navigationController: navigationController) + navigationStackCoordinator: navigationStackCoordinator) case .softLogout: let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org", homeserverName: "matrix.org", @@ -97,9 +97,9 @@ class MockScreen: Identifiable { return HomeScreenCoordinator(parameters: .init(userSession: session, attributedStringBuilder: AttributedStringBuilder(), bugReportService: MockBugReportService(), - navigationController: navigationController)) + navigationStackCoordinator: navigationStackCoordinator)) case .settings: - return SettingsCoordinator(parameters: .init(navigationController: navigationController, + return SettingsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, userNotificationController: MockUserNotificationController(), userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"), mediaProvider: MockMediaProvider()), @@ -117,7 +117,7 @@ class MockScreen: Identifiable { case .onboarding: return OnboardingCoordinator() case .roomPlainNoAvatar: - let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController, + let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), roomName: "Some room name", @@ -125,7 +125,7 @@ class MockScreen: Identifiable { emojiProvider: EmojiProvider()) return RoomScreenCoordinator(parameters: parameters) case .roomEncryptedWithAvatar: - let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController, + let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), roomName: "Some room name", @@ -138,8 +138,8 @@ class MockScreen: Identifiable { } }() - init(id: UITestScreenIdentifier, navigationController: NavigationController) { + init(id: UITestScreenIdentifier, navigationStackCoordinator: NavigationStackCoordinator) { self.id = id - self.navigationController = navigationController + self.navigationStackCoordinator = navigationStackCoordinator } } diff --git a/UnitTests/Sources/NavigationControllerTests.swift b/UnitTests/Sources/NavigationControllerTests.swift deleted file mode 100644 index 269fef9da..000000000 --- a/UnitTests/Sources/NavigationControllerTests.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// Copyright 2022 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 XCTest - -@testable import ElementX - -@MainActor -class NavigationControllerTests: XCTestCase { - private var navigationController: NavigationController! - - override func setUp() { - navigationController = NavigationController() - } - - func testRoot() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - } - - func testSingleSheet() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - let coordinator = SomeTestCoordinator() - navigationController.presentSheet(coordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - assertCoordinatorsEqual(coordinator, navigationController.sheetCoordinator) - - navigationController.dismissSheet() - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssertNil(navigationController.sheetCoordinator) - } - - func testMultipleSheets() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - let sheetCoordinator = SomeTestCoordinator() - navigationController.presentSheet(sheetCoordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssert(navigationController.coordinators.isEmpty) - assertCoordinatorsEqual(sheetCoordinator, navigationController.sheetCoordinator) - - let someOtherSheetCoordinator = SomeTestCoordinator() - navigationController.presentSheet(someOtherSheetCoordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssert(navigationController.coordinators.isEmpty) - assertCoordinatorsEqual(someOtherSheetCoordinator, navigationController.sheetCoordinator) - } - - func testSinglePush() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - let coordinator = SomeTestCoordinator() - navigationController.push(coordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - assertCoordinatorsEqual(coordinator, navigationController.coordinators.first) - - navigationController.pop() - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssert(navigationController.coordinators.isEmpty) - } - - func testMultiplePushes() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - var coordinators = [CoordinatorProtocol]() - for _ in 0...10 { - let coordinator = SomeTestCoordinator() - coordinators.append(coordinator) - navigationController.push(coordinator) - } - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssertEqual(navigationController.coordinators.count, coordinators.count) - - for index in coordinators.indices { - assertCoordinatorsEqual(coordinators[index], navigationController.coordinators[index]) - } - - navigationController.popToRoot() - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - XCTAssert(navigationController.coordinators.isEmpty) - } - - func testRootReplacementDimissesTheRest() { - let rootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(rootCoordinator) - - let sheetCoordinator = SomeTestCoordinator() - navigationController.presentSheet(sheetCoordinator) - - let pushedCoordinator = SomeTestCoordinator() - navigationController.push(pushedCoordinator) - - assertCoordinatorsEqual(rootCoordinator, navigationController.rootCoordinator) - assertCoordinatorsEqual(pushedCoordinator, navigationController.coordinators.first) - assertCoordinatorsEqual(sheetCoordinator, navigationController.sheetCoordinator) - - let newRootCoordinator = SomeTestCoordinator() - navigationController.setRootCoordinator(newRootCoordinator) - - assertCoordinatorsEqual(newRootCoordinator, navigationController.rootCoordinator) - XCTAssert(navigationController.coordinators.isEmpty) - XCTAssertNil(navigationController.sheetCoordinator) - } - - func testPushesDontReplaceSheet() { - let sheetCoordinator = SomeTestCoordinator() - navigationController.presentSheet(sheetCoordinator) - - let pushedCoordinator = SomeTestCoordinator() - navigationController.push(pushedCoordinator) - - assertCoordinatorsEqual(pushedCoordinator, navigationController.coordinators.first) - assertCoordinatorsEqual(sheetCoordinator, navigationController.sheetCoordinator) - - let newlyPushedCoordinator = SomeTestCoordinator() - navigationController.push(newlyPushedCoordinator) - - assertCoordinatorsEqual(pushedCoordinator, navigationController.coordinators.first) - assertCoordinatorsEqual(newlyPushedCoordinator, navigationController.coordinators.last) - assertCoordinatorsEqual(sheetCoordinator, navigationController.sheetCoordinator) - } - - func testPopDismissalCallbacks() { - let pushedCoordinator = SomeTestCoordinator() - - let expectation = expectation(description: "Wait for callback") - navigationController.push(pushedCoordinator) { - expectation.fulfill() - } - - navigationController.pop() - waitForExpectations(timeout: 1.0) - } - - func testPopToRootDismissalCallbacks() { - navigationController.push(SomeTestCoordinator()) - navigationController.push(SomeTestCoordinator()) - - let coordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationController.push(coordinator) { - expectation.fulfill() - } - - navigationController.popToRoot() - waitForExpectations(timeout: 1.0) - } - - func testSheetDismissalCallbac() { - let coordinator = SomeTestCoordinator() - let expectation = expectation(description: "Wait for callback") - navigationController.presentSheet(coordinator) { - expectation.fulfill() - } - - navigationController.dismissSheet() - waitForExpectations(timeout: 1.0) - } - - func testRootReplacmeentCallbacks() { - navigationController.setRootCoordinator(SomeTestCoordinator()) - - let popExpectation = expectation(description: "Waiting for callback") - navigationController.push(SomeTestCoordinator()) { - popExpectation.fulfill() - } - - let sheetExpectation = expectation(description: "Waiting for callback") - navigationController.presentSheet(SomeTestCoordinator()) { - sheetExpectation.fulfill() - } - - navigationController.setRootCoordinator(SomeTestCoordinator()) - - waitForExpectations(timeout: 1.0) - } - - // MARK: - Private - - private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { - guard let lhs = lhs as? SomeTestCoordinator, - let rhs = rhs as? SomeTestCoordinator else { - XCTFail("Coordinators are not the same") - return - } - - XCTAssertEqual(lhs.id, rhs.id) - } -} - -private struct SomeTestCoordinator: CoordinatorProtocol { - let id = UUID() -} diff --git a/UnitTests/Sources/NavigationRootCoordinatorTests.swift b/UnitTests/Sources/NavigationRootCoordinatorTests.swift new file mode 100644 index 000000000..7998ee0ef --- /dev/null +++ b/UnitTests/Sources/NavigationRootCoordinatorTests.swift @@ -0,0 +1,72 @@ +// +// Copyright 2022 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 XCTest + +@testable import ElementX + +@MainActor +class NavigationRootCoordinatorTests: XCTestCase { + private var navigationRootCoordinator: NavigationRootCoordinator! + + override func setUp() { + navigationRootCoordinator = NavigationRootCoordinator() + } + + func testRootChanges() { + XCTAssertNil(navigationRootCoordinator.rootCoordinator) + + let firstRootCoordinator = SomeTestCoordinator() + navigationRootCoordinator.setRootCoordinator(firstRootCoordinator) + + assertCoordinatorsEqual(firstRootCoordinator, navigationRootCoordinator.rootCoordinator) + + let secondRootCoordinator = SomeTestCoordinator() + navigationRootCoordinator.setRootCoordinator(secondRootCoordinator) + + assertCoordinatorsEqual(secondRootCoordinator, navigationRootCoordinator.rootCoordinator) + } + + func testReplacementDismissalCallbacks() { + XCTAssertNil(navigationRootCoordinator.rootCoordinator) + + let rootCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationRootCoordinator.setRootCoordinator(rootCoordinator) { + expectation.fulfill() + } + + navigationRootCoordinator.setRootCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + // MARK: - Private + + private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { + guard let lhs = lhs as? SomeTestCoordinator, + let rhs = rhs as? SomeTestCoordinator else { + XCTFail("Coordinators are not the same") + return + } + + XCTAssertEqual(lhs.id, rhs.id) + } +} + +private struct SomeTestCoordinator: CoordinatorProtocol { + let id = UUID() +} diff --git a/UnitTests/Sources/NavigationSplitCoordinator.swift b/UnitTests/Sources/NavigationSplitCoordinator.swift new file mode 100644 index 000000000..379a2352d --- /dev/null +++ b/UnitTests/Sources/NavigationSplitCoordinator.swift @@ -0,0 +1,274 @@ +// +// Copyright 2022 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 XCTest + +@testable import ElementX + +@MainActor +class NavigationSplitCoordinatorTests: XCTestCase { + private var navigationSplitCoordinator: NavigationSplitCoordinator! + + override func setUp() { + navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator()) + } + + func testSidebar() { + XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) + XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + } + + func testDetail() { + XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) + XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + } + + func testSidebarAndDetail() { + XCTAssertNil(navigationSplitCoordinator.sidebarCoordinator) + XCTAssertNil(navigationSplitCoordinator.detailCoordinator) + + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + } + + func testSingleSheet() { + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let sheetCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + assertCoordinatorsEqual(sheetCoordinator, navigationSplitCoordinator.sheetCoordinator) + + navigationSplitCoordinator.setSheetCoordinator(nil) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + XCTAssertNil(navigationSplitCoordinator.sheetCoordinator) + } + + func testMultipleSheets() { + let sidebarCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + let detailCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let sheetCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + assertCoordinatorsEqual(sheetCoordinator, navigationSplitCoordinator.sheetCoordinator) + + let someOtherSheetCoordinator = SomeTestCoordinator() + navigationSplitCoordinator.setSheetCoordinator(someOtherSheetCoordinator) + + assertCoordinatorsEqual(sidebarCoordinator, navigationSplitCoordinator.sidebarCoordinator) + assertCoordinatorsEqual(detailCoordinator, navigationSplitCoordinator.detailCoordinator) + assertCoordinatorsEqual(someOtherSheetCoordinator, navigationSplitCoordinator.sheetCoordinator) + } + + func testSidebarReplacementCallbacks() { + let sidebarCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setSidebarCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testDetailReplacementCallbacks() { + let detailCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setDetailCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testSheetDismissalCallback() { + let sheetCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationSplitCoordinator.setSheetCoordinator(sheetCoordinator) { + expectation.fulfill() + } + + navigationSplitCoordinator.setSheetCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testEmbeddedStackPresentsSheetThroughSplit() { + let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + let sheetCoordinator = SomeTestCoordinator() + sidebarNavigationStackCoordinator.setSheetCoordinator(sheetCoordinator) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + + assertCoordinatorsEqual(sheetCoordinator, sidebarNavigationStackCoordinator.sheetCoordinator) + assertCoordinatorsEqual(sheetCoordinator, navigationSplitCoordinator.sheetCoordinator) + } + + func testSplitTracksEmbeddedStackRootChanges() { + let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) + + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + let expectation = expectation(description: "Coordinators should match") + DispatchQueue.main.async { + self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, self.navigationSplitCoordinator.compactLayoutRootCoordinator) + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func testSplitTracksEmbeddedStackChanges() { + let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) + + sidebarNavigationStackCoordinator.push(SomeTestCoordinator()) + + let expectation = expectation(description: "Coordinators should match") + DispatchQueue.main.async { + XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, self.navigationSplitCoordinator.compactLayoutStackCoordinators.count) + for index in sidebarNavigationStackCoordinator.stackCoordinators.indices { + self.assertCoordinatorsEqual(sidebarNavigationStackCoordinator.stackCoordinators[index], self.navigationSplitCoordinator.compactLayoutStackCoordinators[index]) + } + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func testSplitPropagatesCompactStackChanges() { + let sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + sidebarNavigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + sidebarNavigationStackCoordinator.push(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) + + assertCoordinatorsEqual(sidebarNavigationStackCoordinator.rootCoordinator, navigationSplitCoordinator.compactLayoutRootCoordinator) + XCTAssertEqual(sidebarNavigationStackCoordinator.stackCoordinators.count, navigationSplitCoordinator.compactLayoutStackCoordinators.count) + + navigationSplitCoordinator.compactLayoutStackModules.removeAll() + + XCTAssertTrue(sidebarNavigationStackCoordinator.stackCoordinators.isEmpty) + } + + func testCompactStackCreation() { + let sidebarCoordinator = NavigationStackCoordinator() + sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) + sidebarCoordinator.push(SomeTestCoordinator()) + + let detailCoordinator = NavigationStackCoordinator() + detailCoordinator.setRootCoordinator(SomeTestCoordinator()) + detailCoordinator.push(SomeTestCoordinator()) + detailCoordinator.push(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let expectation = expectation(description: "Coordinators should match") + DispatchQueue.main.async { + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, sidebarCoordinator.stackCoordinators.first) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.rootCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[2].coordinator, detailCoordinator.stackCoordinators.first) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[3].coordinator, detailCoordinator.stackCoordinators.last) + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func testRemovesDetailRootFromCompactStack() { + let sidebarCoordinator = NavigationStackCoordinator() + sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) + + let detailCoordinator = NavigationStackCoordinator() + detailCoordinator.setRootCoordinator(SomeTestCoordinator()) + detailCoordinator.push(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let expectation = expectation(description: "Coordinators should match") + DispatchQueue.main.async { + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.rootCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[1].coordinator, detailCoordinator.stackCoordinators.first) + + detailCoordinator.setRootCoordinator(nil) + + DispatchQueue.main.async { + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutStackModules[0].coordinator, detailCoordinator.stackCoordinators.first) + } + + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + // MARK: - Private + + private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { + guard let lhs = lhs as? SomeTestCoordinator, + let rhs = rhs as? SomeTestCoordinator else { + XCTFail("Coordinators are not the same") + return + } + + XCTAssertEqual(lhs.id, rhs.id) + } +} + +private struct SomeTestCoordinator: CoordinatorProtocol { + let id = UUID() +} diff --git a/UnitTests/Sources/NavigationStackCoordinatorTests.swift b/UnitTests/Sources/NavigationStackCoordinatorTests.swift new file mode 100644 index 000000000..aea651ba9 --- /dev/null +++ b/UnitTests/Sources/NavigationStackCoordinatorTests.swift @@ -0,0 +1,217 @@ +// +// Copyright 2022 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 XCTest + +@testable import ElementX + +@MainActor +class NavigationStackCoordinatorTests: XCTestCase { + private var navigationStackCoordinator: NavigationStackCoordinator! + + override func setUp() { + navigationStackCoordinator = NavigationStackCoordinator() + } + + func testRoot() { + XCTAssertNil(navigationStackCoordinator.rootCoordinator) + + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + } + + func testSingleSheet() { + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + let coordinator = SomeTestCoordinator() + navigationStackCoordinator.setSheetCoordinator(coordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + assertCoordinatorsEqual(coordinator, navigationStackCoordinator.sheetCoordinator) + + navigationStackCoordinator.setSheetCoordinator(nil) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssertNil(navigationStackCoordinator.sheetCoordinator) + } + + func testMultipleSheets() { + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + let sheetCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setSheetCoordinator(sheetCoordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssert(navigationStackCoordinator.stackCoordinators.isEmpty) + assertCoordinatorsEqual(sheetCoordinator, navigationStackCoordinator.sheetCoordinator) + + let someOtherSheetCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setSheetCoordinator(someOtherSheetCoordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssert(navigationStackCoordinator.stackCoordinators.isEmpty) + assertCoordinatorsEqual(someOtherSheetCoordinator, navigationStackCoordinator.sheetCoordinator) + } + + func testSinglePush() { + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + let coordinator = SomeTestCoordinator() + navigationStackCoordinator.push(coordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + assertCoordinatorsEqual(coordinator, navigationStackCoordinator.stackCoordinators.first) + + navigationStackCoordinator.pop() + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssert(navigationStackCoordinator.stackCoordinators.isEmpty) + } + + func testMultiplePushes() { + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + var coordinators = [CoordinatorProtocol]() + for _ in 0...10 { + let coordinator = SomeTestCoordinator() + coordinators.append(coordinator) + navigationStackCoordinator.push(coordinator) + } + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, coordinators.count) + + for index in coordinators.indices { + assertCoordinatorsEqual(coordinators[index], navigationStackCoordinator.stackCoordinators[index]) + } + + navigationStackCoordinator.popToRoot() + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssert(navigationStackCoordinator.stackCoordinators.isEmpty) + } + + func testRootReplacementDimissesTheRest() { + let rootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(rootCoordinator) + + let sheetCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setSheetCoordinator(sheetCoordinator) + + let pushedCoordinator = SomeTestCoordinator() + navigationStackCoordinator.push(pushedCoordinator) + + assertCoordinatorsEqual(rootCoordinator, navigationStackCoordinator.rootCoordinator) + assertCoordinatorsEqual(pushedCoordinator, navigationStackCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(sheetCoordinator, navigationStackCoordinator.sheetCoordinator) + + let newRootCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setRootCoordinator(newRootCoordinator) + + assertCoordinatorsEqual(newRootCoordinator, navigationStackCoordinator.rootCoordinator) + XCTAssert(navigationStackCoordinator.stackCoordinators.isEmpty) + } + + func testPushesDontReplaceSheet() { + let sheetCoordinator = SomeTestCoordinator() + navigationStackCoordinator.setSheetCoordinator(sheetCoordinator) + + let pushedCoordinator = SomeTestCoordinator() + navigationStackCoordinator.push(pushedCoordinator) + + assertCoordinatorsEqual(pushedCoordinator, navigationStackCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(sheetCoordinator, navigationStackCoordinator.sheetCoordinator) + + let newlyPushedCoordinator = SomeTestCoordinator() + navigationStackCoordinator.push(newlyPushedCoordinator) + + assertCoordinatorsEqual(pushedCoordinator, navigationStackCoordinator.stackCoordinators.first) + assertCoordinatorsEqual(newlyPushedCoordinator, navigationStackCoordinator.stackCoordinators.last) + assertCoordinatorsEqual(sheetCoordinator, navigationStackCoordinator.sheetCoordinator) + } + + func testPopDismissalCallbacks() { + let pushedCoordinator = SomeTestCoordinator() + + let expectation = expectation(description: "Wait for callback") + navigationStackCoordinator.push(pushedCoordinator) { + expectation.fulfill() + } + + navigationStackCoordinator.pop() + waitForExpectations(timeout: 1.0) + } + + func testPopToRootDismissalCallbacks() { + navigationStackCoordinator.push(SomeTestCoordinator()) + navigationStackCoordinator.push(SomeTestCoordinator()) + + let coordinator = SomeTestCoordinator() + let expectation = expectation(description: "Wait for callback") + navigationStackCoordinator.push(coordinator) { + expectation.fulfill() + } + + navigationStackCoordinator.popToRoot() + waitForExpectations(timeout: 1.0) + } + + func testSheetDismissalCallback() { + let coordinator = SomeTestCoordinator() + let expectation = expectation(description: "Wait for callback") + navigationStackCoordinator.setSheetCoordinator(coordinator) { + expectation.fulfill() + } + + navigationStackCoordinator.setSheetCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + + func testRootReplacementCallbacks() { + navigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + let popExpectation = expectation(description: "Waiting for callback") + navigationStackCoordinator.push(SomeTestCoordinator()) { + popExpectation.fulfill() + } + + navigationStackCoordinator.setRootCoordinator(SomeTestCoordinator()) + + waitForExpectations(timeout: 1.0) + } + + // MARK: - Private + + private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { + guard let lhs = lhs as? SomeTestCoordinator, + let rhs = rhs as? SomeTestCoordinator else { + XCTFail("Coordinators are not the same") + return + } + + XCTAssertEqual(lhs.id, rhs.id) + } +} + +private struct SomeTestCoordinator: CoordinatorProtocol { + let id = UUID() +} diff --git a/changelog.d/317.feature b/changelog.d/317.feature new file mode 100644 index 000000000..5057b24fb --- /dev/null +++ b/changelog.d/317.feature @@ -0,0 +1 @@ +Implement a split screen layout for when running on iPad and MacOS \ No newline at end of file