Implement tab bar badges, visibility and selection. (#4373)

This commit is contained in:
Doug
2025-08-05 09:23:45 +01:00
committed by GitHub
parent 974a3ad74f
commit 3adf8b0610
6 changed files with 95 additions and 36 deletions

View File

@@ -9188,7 +9188,7 @@
repositoryURL = "https://github.com/element-hq/compound-ios";
requirement = {
kind = revision;
revision = afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889;
revision = 8d7ca2e413026735b724044847ff1ae3b40563f0;
};
};
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = {

View File

@@ -15,7 +15,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/compound-ios",
"state" : {
"revision" : "afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889"
"revision" : "8d7ca2e413026735b724044847ff1ae3b40563f0"
}
},
{

View File

@@ -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<Tag: Hashable>: 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<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
var dismissalCallback: (() -> Void)?
var badgeCount = 0
var barVisibility: Visibility = .automatic
init(tag: Tag, title: String, icon: KeyPath<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>) {
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<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
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<Tag: Hashable>: View {
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
@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
}
}

View File

@@ -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<HomeTab>
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let onboardingFlowCoordinator: OnboardingFlowCoordinator
private let onboardingStackCoordinator: NavigationStackCoordinator
private let chatsFlowCoordinator: ChatsFlowCoordinator
private let chatsTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails
private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserSessionFlowCoordinatorAction, Never> {
@@ -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

View File

@@ -11,7 +11,8 @@ import XCTest
@MainActor
class NavigationTabCoordinatorTests: XCTestCase {
private var navigationTabCoordinator: NavigationTabCoordinator!
enum TestTab { case tab, chats, spaces }
private var navigationTabCoordinator: NavigationTabCoordinator<TestTab>!
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?) {

View File

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