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 e182940844
commit bf0adfcb25
6 changed files with 95 additions and 36 deletions
@@ -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
}
}
@@ -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