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
This commit is contained in:
Stefan Ceriu
2022-12-12 12:31:27 +02:00
committed by GitHub
parent 910852028b
commit 9abef3de6a
22 changed files with 1407 additions and 541 deletions

View File

@@ -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 = "<group>"; };
054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = "<group>"; };
057B747CF045D3C6C30EAB2C /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fi; path = fi.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
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 = "<group>"; };
086B997409328F091EBA43CE /* RoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenUITests.swift; sourceTree = "<group>"; };
08F64963396A6A23538EFCEC /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = is; path = is.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -533,7 +536,6 @@
1059E2AE7878CF7820592637 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = "<group>"; };
105D16E7DB0CCE9526612BDD /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-IN"; path = "bn-IN.lproj/Localizable.strings"; sourceTree = "<group>"; };
109361C96BFFBE2FD89BF15C /* NavigationControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationControllerTests.swift; sourceTree = "<group>"; };
10CC626F97AD70FF0420C115 /* RoomSummaryProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderProtocol.swift; sourceTree = "<group>"; };
1113CA0A67B4AA227AAFB63B /* UserNotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationController.swift; sourceTree = "<group>"; };
11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModelProtocol.swift; sourceTree = "<group>"; };
@@ -652,7 +654,6 @@
48CE6BF18E542B32FA52CE06 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = fa; path = fa.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
49193CB0C248D621A96FB2AA /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
4959CECEC984B3995616F427 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = "<group>"; };
495D3EC4972639C1A87DDF8E /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = "<group>"; };
4990FDBDA96B88E214F92F48 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = "<group>"; };
49D2C8E66E83EA578A7F318A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationRequest.swift; sourceTree = "<group>"; };
@@ -789,12 +790,14 @@
98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemBubbledStylerView.swift; sourceTree = "<group>"; };
997783054A2E95F9E624217E /* kaa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kaa; path = kaa.lproj/Localizable.strings; sourceTree = "<group>"; };
99DE232F24EAD72A3DF7EF1A /* kab */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = kab; path = kab.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModule.swift; sourceTree = "<group>"; };
9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportServiceProtocol.swift; sourceTree = "<group>"; };
9B1FBF8CA40199B8058B1F08 /* NotificationItemProxy+NSE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationItemProxy+NSE.swift"; sourceTree = "<group>"; };
9B577F829C693B8DFB7014FD /* RedactedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineItem.swift; sourceTree = "<group>"; };
9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMartJSONLoaderTests.swift; sourceTree = "<group>"; };
9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = "<group>"; };
9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = "<group>"; };
9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackCoordinatorTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
9D7D706FFF438CAF16F44D8C /* ServerSelectionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionCoordinator.swift; sourceTree = "<group>"; };
@@ -816,6 +819,7 @@
A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = "<group>"; };
A4B5B19A10D3F7C2BC5315DF /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = "<group>"; };
A5B0B1226DA8DB55918B34CD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = "<group>"; };
A63A32A0627A03F00A2900FE /* NavigationSplitCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitCoordinator.swift; sourceTree = "<group>"; };
A64F0DB78E0AC23C91AD89EF /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = mk.lproj/Localizable.strings; sourceTree = "<group>"; };
A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = "<group>"; };
A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = "<group>"; };
@@ -861,6 +865,7 @@
B8347789959986B374DB25DD /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = sq; path = sq.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
B83CB897B183BF3C33715F55 /* bn-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "bn-IN"; path = "bn-IN.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
B8A56EA2A5AE726F445CB2E3 /* eo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = eo; path = eo.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = "<group>"; };
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
BA7B2E9CC5DC3B76ADC35A43 /* AnalyticsPromptCheckmarkItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptCheckmarkItem.swift; sourceTree = "<group>"; };
BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = "<group>"; };
@@ -891,6 +896,7 @@
C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = "<group>"; };
C95ADE8D9527523572532219 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hu; path = hu.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
C9A86C95340248A8B7BA9A43 /* AnalyticsPromptViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptViewModelProtocol.swift; sourceTree = "<group>"; };
CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = "<group>"; };
CA89A2DD51B6BBE1DA55E263 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
CA9D14D6F914324865C7DB9F /* ActivityCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityCoordinator.swift; sourceTree = "<group>"; };
CAAE4A709C0A2144C103AA0F /* ang */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ang; path = ang.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -915,6 +921,7 @@
D1A9CCCF53495CF3D7B19FCE /* MockSessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionVerificationControllerProxy.swift; sourceTree = "<group>"; };
D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = "<group>"; };
D2D783758EAE6A88C93564EB /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = "<group>"; };
D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
@@ -987,6 +994,7 @@
F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattedBodyText.swift; sourceTree = "<group>"; };
F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermalinkBuilder.swift; sourceTree = "<group>"; };
F77C060C2ACC4CB7336A29E7 /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = "<group>"; };
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = "<group>"; };
FA154570F693D93513E584C1 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = "<group>"; };
FAB10E673916D2B8D21FD197 /* TemplateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateModels.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
780F74C73E826685A9DB289B /* Navigation */ = {
isa = PBXGroup;
children = (
B8F28602AC7AC881AED37EBA /* NavigationCoordinators.swift */,
9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */,
CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */,
);
path = Navigation;
sourceTree = "<group>";
};
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 = "<group>";
@@ -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 */,

View File

@@ -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()

View File

@@ -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<AnyCancellable>()
@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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)))"
}
}

View File

@@ -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"

View File

@@ -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.

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -31,6 +31,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Setup
deinit {
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Constants.backPaginationIndicatorID)
}
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol,

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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<State, Event>
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
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

1
changelog.d/317.feature Normal file
View File

@@ -0,0 +1 @@
Implement a split screen layout for when running on iPad and MacOS