Implement tab bar badges, visibility and selection. (#4373)
This commit is contained in:
@@ -9188,7 +9188,7 @@
|
||||
repositoryURL = "https://github.com/element-hq/compound-ios";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889;
|
||||
revision = 8d7ca2e413026735b724044847ff1ae3b40563f0;
|
||||
};
|
||||
};
|
||||
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/element-hq/compound-ios",
|
||||
"state" : {
|
||||
"revision" : "afc59afb1b1e4f4960e2f2a15e52d4e2e33fc889"
|
||||
"revision" : "8d7ca2e413026735b724044847ff1ae3b40563f0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user