diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9cb2be1ed..44a3d412f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -9188,7 +9188,7 @@ repositoryURL = "https://github.com/element-hq/compound-ios"; requirement = { kind = revision; - revision = afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889; + revision = 8d7ca2e413026735b724044847ff1ae3b40563f0; }; }; F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f6743d062..7cf91bd9a 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-ios", "state" : { - "revision" : "afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889" + "revision" : "8d7ca2e413026735b724044847ff1ae3b40563f0" } }, { diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index dcc9e09f7..a2cd51d56 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -9,22 +9,35 @@ import Compound import SwiftUI /// Class responsible for displaying an arbitrary number of coordinators within the tab bar. -@Observable class NavigationTabCoordinator: CoordinatorProtocol, CustomStringConvertible { +@Observable class NavigationTabCoordinator: CoordinatorProtocol, CustomStringConvertible { struct Tab { let coordinator: CoordinatorProtocol + let details: TabDetails + var dismissalCallback: (() -> Void)? + } + + @Observable class TabDetails { + /// A unique tab that identifies the tab for selection. + let tag: Tag let title: String let icon: KeyPath let selectedIcon: KeyPath - var dismissalCallback: (() -> Void)? + var badgeCount = 0 + var barVisibility: Visibility = .automatic + + init(tag: Tag, title: String, icon: KeyPath, selectedIcon: KeyPath) { + self.tag = tag + self.title = title + self.icon = icon + self.selectedIcon = selectedIcon + } } // MARK: Tabs fileprivate struct TabModule: Identifiable { let module: NavigationModule - let title: String - let icon: KeyPath - let selectedIcon: KeyPath + let details: TabDetails var id: ObjectIdentifier { module.id } @MainActor var coordinator: CoordinatorProtocol? { module.coordinator } @@ -57,13 +70,15 @@ import SwiftUI transaction.disablesAnimations = !animated withTransaction(transaction) { - tabModules = tabs.map { TabModule(module: .init($0.coordinator, dismissalCallback: $0.dismissalCallback), - title: $0.title, - icon: $0.icon, - selectedIcon: $0.selectedIcon) } + tabModules = tabs.map { TabModule(module: .init($0.coordinator, dismissalCallback: $0.dismissalCallback), details: $0.details) } } + + selectedTab = tabModules.first?.details.tag } + /// The currently selected tab's tag. + var selectedTab: Tag? + // MARK: Sheets fileprivate var sheetModule: NavigationModule? { @@ -186,26 +201,29 @@ import SwiftUI } } -private struct NavigationTabCoordinatorView: View { - @Bindable var navigationTabCoordinator: NavigationTabCoordinator - @State private var selectedTab: ObjectIdentifier? +private struct NavigationTabCoordinatorView: View { + @Bindable var navigationTabCoordinator: NavigationTabCoordinator + + @State private var standardAppearance = UITabBarAppearance() var body: some View { - TabView(selection: $selectedTab) { + TabView(selection: $navigationTabCoordinator.selectedTab) { ForEach(navigationTabCoordinator.tabModules) { module in module.coordinator?.toPresentable() + .id(module.id) .tabItem { Label { - Text(module.title) + Text(module.details.title) } icon: { - CompoundIcon(module.id == selectedTab ? module.selectedIcon : module.icon) + CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon) } } - .tag(module.id) - .id(module.id) - .toolbar(.hidden, for: .tabBar) + .tag(module.details.tag) + .badge(module.details.badgeCount) + .toolbar(module.details.barVisibility, for: .tabBar) } } + .introspect(.tabView, on: .supportedVersions, customize: configureAppearance) .sheet(item: $navigationTabCoordinator.sheetModule) { module in module.coordinator?.toPresentable() .id(module.id) @@ -215,4 +233,10 @@ private struct NavigationTabCoordinatorView: View { .id(module.id) } } + + private func configureAppearance(_ tabBarController: UITabBarController) { + standardAppearance.configureWithDefaultBackground() + standardAppearance.stackedLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary + tabBarController.tabBar.standardAppearance = standardAppearance + } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index a4e9100f7..abee49a97 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -18,15 +18,19 @@ enum UserSessionFlowCoordinatorAction { } class UserSessionFlowCoordinator: FlowCoordinatorProtocol { + enum HomeTab: Hashable { case chats, spaces } + private let userSession: UserSessionProtocol private let navigationRootCoordinator: NavigationRootCoordinator - private let navigationTabCoordinator: NavigationTabCoordinator + private let navigationTabCoordinator: NavigationTabCoordinator private let appMediator: AppMediatorProtocol private let appSettings: AppSettings private let onboardingFlowCoordinator: OnboardingFlowCoordinator private let onboardingStackCoordinator: NavigationStackCoordinator private let chatsFlowCoordinator: ChatsFlowCoordinator + private let chatsTabDetails: NavigationTabCoordinator.TabDetails + private let spacesTabDetails: NavigationTabCoordinator.TabDetails private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { @@ -68,6 +72,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { analytics: analytics, notificationManager: notificationManager, isNewLogin: isNewLogin) + chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid) + chatsTabDetails.barVisibility = .hidden + spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid) onboardingStackCoordinator = NavigationStackCoordinator() onboardingFlowCoordinator = OnboardingFlowCoordinator(userSession: userSession, @@ -81,8 +88,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { isNewLogin: isNewLogin) navigationTabCoordinator.setTabs([ - .init(coordinator: chatsSplitCoordinator, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid) - // .init(coordinator: BlankFormCoordinator(), title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid) + .init(coordinator: chatsSplitCoordinator, details: chatsTabDetails), + .init(coordinator: BlankFormCoordinator(), details: spacesTabDetails) ]) setupObservers() @@ -103,17 +110,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { // There aren't any routes that directly target this flow yet, so pass them directly to the // chats flow coordinator. - #warning("This should switch tabs to make sure the route is visible.") chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) + navigationTabCoordinator.selectedTab = .chats } func clearRoute(animated: Bool) { chatsFlowCoordinator.clearRoute(animated: animated) } - #warning("This should use a publisher, combining it with the active tab.") func isDisplayingRoomScreen(withRoomID roomID: String) -> Bool { - chatsFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) + guard navigationTabCoordinator.selectedTab == .chats else { return false } + return chatsFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) } // MARK: - Private diff --git a/UnitTests/Sources/NavigationTabCoordinatorTests.swift b/UnitTests/Sources/NavigationTabCoordinatorTests.swift index 099fca35d..6ebb78fbe 100644 --- a/UnitTests/Sources/NavigationTabCoordinatorTests.swift +++ b/UnitTests/Sources/NavigationTabCoordinatorTests.swift @@ -11,7 +11,8 @@ import XCTest @MainActor class NavigationTabCoordinatorTests: XCTestCase { - private var navigationTabCoordinator: NavigationTabCoordinator! + enum TestTab { case tab, chats, spaces } + private var navigationTabCoordinator: NavigationTabCoordinator! override func setUp() { navigationTabCoordinator = NavigationTabCoordinator() @@ -21,21 +22,21 @@ class NavigationTabCoordinatorTests: XCTestCase { XCTAssertTrue(navigationTabCoordinator.tabCoordinators.isEmpty) let someCoordinator = SomeTestCoordinator() - navigationTabCoordinator.setTabs([.init(coordinator: someCoordinator, title: "Whatever", icon: \.help, selectedIcon: \.helpSolid)]) + navigationTabCoordinator.setTabs([.init(coordinator: someCoordinator, details: .init(tag: .tab, title: "Tab", icon: \.help, selectedIcon: \.helpSolid))]) assertCoordinatorsEqual(navigationTabCoordinator.tabCoordinators, [someCoordinator]) let chatsCoordinator = SomeTestCoordinator() let spacesCoordinator = SomeTestCoordinator() navigationTabCoordinator.setTabs([ - .init(coordinator: chatsCoordinator, title: "Chats", icon: \.chat, selectedIcon: \.chatSolid), - .init(coordinator: spacesCoordinator, title: "Spaces", icon: \.space, selectedIcon: \.spaceSolid) + .init(coordinator: chatsCoordinator, details: .init(tag: .chats, title: "Chats", icon: \.chat, selectedIcon: \.chatSolid)), + .init(coordinator: spacesCoordinator, details: .init(tag: .spaces, title: "Spaces", icon: \.space, selectedIcon: \.spaceSolid)) ]) assertCoordinatorsEqual(navigationTabCoordinator.tabCoordinators, [chatsCoordinator, spacesCoordinator]) } func testSingleSheet() { let tabCoordinator = SomeTestCoordinator() - navigationTabCoordinator.setTabs([.init(coordinator: tabCoordinator, title: "Tab", icon: \.help, selectedIcon: \.helpSolid)]) + navigationTabCoordinator.setTabs([.init(coordinator: tabCoordinator, details: .init(tag: .tab, title: "Tab", icon: \.help, selectedIcon: \.helpSolid))]) let coordinator = SomeTestCoordinator() navigationTabCoordinator.setSheetCoordinator(coordinator) @@ -51,7 +52,7 @@ class NavigationTabCoordinatorTests: XCTestCase { func testMultipleSheets() { let tabCoordinator = SomeTestCoordinator() - navigationTabCoordinator.setTabs([.init(coordinator: tabCoordinator, title: "Tab", icon: \.help, selectedIcon: \.helpSolid)]) + navigationTabCoordinator.setTabs([.init(coordinator: tabCoordinator, details: .init(tag: .tab, title: "Tab", icon: \.help, selectedIcon: \.helpSolid))]) let sheetCoordinator = SomeTestCoordinator() navigationTabCoordinator.setSheetCoordinator(sheetCoordinator) @@ -66,6 +67,22 @@ class NavigationTabCoordinatorTests: XCTestCase { assertCoordinatorsEqual(someOtherSheetCoordinator, navigationTabCoordinator.sheetCoordinator) } + func testFullScreenCover() { + let tabCoordinator = SomeTestCoordinator() + navigationTabCoordinator.setTabs([.init(coordinator: tabCoordinator, details: .init(tag: .tab, title: "Tab", icon: \.help, selectedIcon: \.helpSolid))]) + + let coordinator = SomeTestCoordinator() + navigationTabCoordinator.setFullScreenCoverCoordinator(coordinator) + + assertCoordinatorsEqual(navigationTabCoordinator.tabCoordinators, [tabCoordinator]) + assertCoordinatorsEqual(coordinator, navigationTabCoordinator.fullScreenCoverCoordinator) + + navigationTabCoordinator.setFullScreenCoverCoordinator(nil) + + assertCoordinatorsEqual(navigationTabCoordinator.tabCoordinators, [tabCoordinator]) + XCTAssertNil(navigationTabCoordinator.fullScreenCoverCoordinator) + } + func testTabDismissalCallbacks() { let chatsCoordinator = SomeTestCoordinator() let spacesCoordinator = SomeTestCoordinator() @@ -74,12 +91,12 @@ class NavigationTabCoordinatorTests: XCTestCase { expectation.expectedFulfillmentCount = 2 navigationTabCoordinator.setTabs([ - .init(coordinator: chatsCoordinator, title: "Chats", icon: \.chat, selectedIcon: \.chatSolid) { expectation.fulfill() }, - .init(coordinator: spacesCoordinator, title: "Spaces", icon: \.space, selectedIcon: \.spaceSolid) { expectation.fulfill() } + .init(coordinator: chatsCoordinator, details: .init(tag: .chats, title: "Chats", icon: \.chat, selectedIcon: \.chatSolid)) { expectation.fulfill() }, + .init(coordinator: spacesCoordinator, details: .init(tag: .spaces, title: "Spaces", icon: \.space, selectedIcon: \.spaceSolid)) { expectation.fulfill() } ]) assertCoordinatorsEqual(navigationTabCoordinator.tabCoordinators, [chatsCoordinator, spacesCoordinator]) - navigationTabCoordinator.setTabs([.init(coordinator: SomeTestCoordinator(), title: "Whatever", icon: \.help, selectedIcon: \.helpSolid)]) + navigationTabCoordinator.setTabs([.init(coordinator: SomeTestCoordinator(), details: .init(tag: .tab, title: "Tab", icon: \.help, selectedIcon: \.helpSolid))]) waitForExpectations(timeout: 1.0) } @@ -94,6 +111,17 @@ class NavigationTabCoordinatorTests: XCTestCase { waitForExpectations(timeout: 1.0) } + func testFullScreenCoverDismissalCallback() { + let coordinator = SomeTestCoordinator() + let expectation = expectation(description: "Wait for callback") + navigationTabCoordinator.setFullScreenCoverCoordinator(coordinator) { + expectation.fulfill() + } + + navigationTabCoordinator.setFullScreenCoverCoordinator(nil) + waitForExpectations(timeout: 1.0) + } + // MARK: - Private private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) { diff --git a/project.yml b/project.yml index a0342e05e..4a723d85c 100644 --- a/project.yml +++ b/project.yml @@ -72,7 +72,7 @@ packages: # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios - revision: afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889 + revision: 8d7ca2e413026735b724044847ff1ae3b40563f0 # path: ../compound-ios AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events