mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 03:06:37 +00:00
7f1b6086ae
* Add a developer flag for the search tab Introduces a globalSearchEnabled user preference (off by default) with a toggle in Developer Options, used to gate the new search experience. * Support search role tabs in the tab coordinator Migrates the NavigationTabCoordinator to SwiftUI's value-based Tab API and adds an isSearch option so a tab can adopt the iOS 26 .search role, which detaches it into the trailing search button in the tab bar. * Moves room search to a new screen Adds a new screen that filters the alternate room summary provider as you type, shows an empty state, and lists matching rooms with their matrix identifier. Supports hardware keyboard selection (arrow keys to move through results, return to open) by reaching into the system search field. * Present search in a dedicated tab Adds the search tab to the chats flow on iOS 26 when the flag is enabled, rendered as the detached .search role button. The Cmd+K shortcut now selects this tab, and the home screen's inline search is hidden while the tab is available. * Remove the old global search screen The dedicated search tab replaces the Cmd+K floating window, so the GlobalSearchScreen, its window plumbing in the WindowManager, and its presentation in the chats flow are all removed.
347 lines
14 KiB
Swift
347 lines
14 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Compound
|
|
import SwiftUI
|
|
|
|
/// Class responsible for displaying an arbitrary number of coordinators within the tab bar.
|
|
@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>
|
|
/// When `true` the tab is given the `.search` role, which on iOS 26 detaches it
|
|
/// into the trailing search button in the tab bar.
|
|
let isSearch: Bool
|
|
var badgeCount = 0
|
|
|
|
/// Provide the tab's split coordinator in here to have the tab bar automatically hidden
|
|
/// when pushing a child into the split view's details on iPhone/compact iPad.
|
|
weak var navigationSplitCoordinator: NavigationSplitCoordinator?
|
|
|
|
init(tag: Tag, title: String, icon: KeyPath<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>, isSearch: Bool = false) {
|
|
self.tag = tag
|
|
self.title = title
|
|
self.icon = icon
|
|
self.selectedIcon = selectedIcon
|
|
self.isSearch = isSearch
|
|
}
|
|
|
|
func barVisibility(in horizontalSizeClass: UserInterfaceSizeClass?) -> Visibility {
|
|
if horizontalSizeClass == .compact, navigationSplitCoordinator?.detailCoordinator != nil {
|
|
// Whilst we support pushing screens on the stack in the sidebarCoordinator, in practice
|
|
// we never do that, so simply checking that the detailCoordinator exists is enough.
|
|
.hidden
|
|
} else {
|
|
.automatic
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Tabs
|
|
|
|
fileprivate struct TabModule: Identifiable {
|
|
let module: NavigationModule
|
|
let details: TabDetails
|
|
|
|
var id: ObjectIdentifier {
|
|
module.id
|
|
}
|
|
|
|
var coordinator: CoordinatorProtocol? {
|
|
module.coordinator
|
|
}
|
|
}
|
|
|
|
fileprivate var tabModules = [TabModule]() {
|
|
didSet {
|
|
let diffs = tabModules.map(\.module).difference(from: oldValue.map(\.module))
|
|
diffs.forEach { change in
|
|
switch change {
|
|
case .insert(_, let module, _):
|
|
logPresentationChange("Set tab", module)
|
|
module.coordinator?.start()
|
|
case .remove(_, let module, _):
|
|
logPresentationChange("Remove tab", module)
|
|
module.tearDown()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The current set of coordinators displayed by the tabs.
|
|
var tabCoordinators: [any CoordinatorProtocol] {
|
|
tabModules.compactMap(\.module.coordinator)
|
|
}
|
|
|
|
/// Updates the displayed tabs with the provided array.
|
|
func setTabs(_ tabs: [Tab], animated: Bool = true) {
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = !animated
|
|
|
|
withTransaction(transaction) {
|
|
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? {
|
|
didSet {
|
|
if let oldValue {
|
|
logPresentationChange("Remove sheet", oldValue)
|
|
oldValue.tearDown()
|
|
}
|
|
|
|
if let sheetModule {
|
|
logPresentationChange("Set sheet", sheetModule)
|
|
sheetModule.coordinator?.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
var presentationDetents: Set<PresentationDetent> = []
|
|
|
|
/// The currently presented sheet coordinator.
|
|
var sheetCoordinator: (any CoordinatorProtocol)? {
|
|
sheetModule?.coordinator
|
|
}
|
|
|
|
/// 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
|
|
/// - animated: whether to animate the transition or not. Default is true
|
|
/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
|
|
func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
|
|
guard let coordinator else {
|
|
sheetModule = nil
|
|
return
|
|
}
|
|
|
|
if sheetModule?.coordinator === coordinator {
|
|
fatalError("Cannot use the same coordinator more than once")
|
|
}
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = !animated
|
|
|
|
withTransaction(transaction) {
|
|
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
|
}
|
|
}
|
|
|
|
// MARK: Full Screen Cover
|
|
|
|
fileprivate var fullScreenCoverModule: NavigationModule? {
|
|
didSet {
|
|
if let oldValue {
|
|
logPresentationChange("Remove fullscreen cover", oldValue)
|
|
oldValue.tearDown()
|
|
}
|
|
|
|
if let fullScreenCoverModule {
|
|
logPresentationChange("Set fullscreen cover", fullScreenCoverModule)
|
|
fullScreenCoverModule.coordinator?.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The currently presented fullscreen cover coordinator
|
|
/// Fullscreen covers will be presented through the NavigationSplitCoordinator if provided
|
|
var fullScreenCoverCoordinator: (any CoordinatorProtocol)? {
|
|
fullScreenCoverModule?.coordinator
|
|
}
|
|
|
|
/// Present a fullscreen cover 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
|
|
/// - animated: whether to animate the transition or not. Default is true
|
|
/// - dismissalCallback: called when the fullscreen cover has been dismissed, programatically or otherwise
|
|
func setFullScreenCoverCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
|
|
guard let coordinator else {
|
|
fullScreenCoverModule = nil
|
|
return
|
|
}
|
|
|
|
if fullScreenCoverModule?.coordinator === coordinator {
|
|
fatalError("Cannot use the same coordinator more than once")
|
|
}
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = !animated
|
|
|
|
withTransaction(transaction) {
|
|
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
|
}
|
|
}
|
|
|
|
// MARK: - Overlay
|
|
|
|
fileprivate var overlayModule: NavigationModule? {
|
|
didSet {
|
|
if let oldValue {
|
|
logPresentationChange("Remove overlay", oldValue)
|
|
oldValue.tearDown()
|
|
}
|
|
|
|
if let overlayModule {
|
|
logPresentationChange("Set overlay", overlayModule)
|
|
overlayModule.coordinator?.start()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The currently displayed overlay coordinator
|
|
var overlayCoordinator: (any CoordinatorProtocol)? {
|
|
overlayModule?.coordinator
|
|
}
|
|
|
|
enum OverlayPresentationMode { case fullScreen, minimized }
|
|
fileprivate var overlayPresentationMode: OverlayPresentationMode = .minimized
|
|
|
|
/// Present an overlay on top of the tab view
|
|
/// - Parameters:
|
|
/// - coordinator: the coordinator to display
|
|
/// - presentationMode: how the coordinator should be presented
|
|
/// - animated: whether the transition should be animated
|
|
/// - dismissalCallback: called when the overlay has been dismissed, programatically or otherwise
|
|
func setOverlayCoordinator(_ coordinator: (any CoordinatorProtocol)?,
|
|
presentationMode: OverlayPresentationMode = .fullScreen,
|
|
animated: Bool = true,
|
|
dismissalCallback: (() -> Void)? = nil) {
|
|
guard let coordinator else {
|
|
overlayModule = nil
|
|
return
|
|
}
|
|
|
|
if overlayModule?.coordinator === coordinator {
|
|
fatalError("Cannot use the same coordinator more than once")
|
|
}
|
|
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = !animated
|
|
|
|
withTransaction(transaction) {
|
|
overlayPresentationMode = presentationMode
|
|
overlayModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
|
}
|
|
}
|
|
|
|
/// Updates the presentation of the overlay coordinator.
|
|
/// - Parameters:
|
|
/// - mode: The type of presentation to use.
|
|
/// - animated: whether the transition should be animated
|
|
func setOverlayPresentationMode(_ mode: OverlayPresentationMode, animated: Bool = true) {
|
|
var transaction = Transaction()
|
|
transaction.disablesAnimations = !animated
|
|
|
|
withTransaction(transaction) {
|
|
overlayPresentationMode = mode
|
|
}
|
|
}
|
|
|
|
// MARK: - CoordinatorProtocol
|
|
|
|
/// No idea if this is particuarly needed for the TabView but we do this for the NavigationStackCoordinator and NavigationSplitCoordinator so it
|
|
/// doesn't seem to harm to also do it here.
|
|
func stop() {
|
|
tabModules.forEach { $0.module.tearDown() }
|
|
}
|
|
|
|
func toPresentable() -> AnyView {
|
|
AnyView(NavigationTabCoordinatorView(navigationTabCoordinator: self))
|
|
}
|
|
|
|
// MARK: - CustomStringConvertible
|
|
|
|
var description: String {
|
|
guard !tabModules.isEmpty else { return "NavigationTabCoordinator(Empty)" }
|
|
return "NavigationTabCoordinator(\(tabCoordinators)"
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func logPresentationChange(_ change: String, _ module: NavigationModule) {
|
|
if let coordinator = module.coordinator {
|
|
MXLog.info("\(self) \(change): \(coordinator)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
|
|
|
|
@State private var standardAppearance = UITabBarAppearance()
|
|
|
|
var body: some View {
|
|
TabView(selection: $navigationTabCoordinator.selectedTab) {
|
|
ForEach(navigationTabCoordinator.tabModules) { module in
|
|
Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) {
|
|
module.coordinator?.toPresentable()
|
|
.id(module.id)
|
|
.toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar)
|
|
} label: {
|
|
Label {
|
|
Text(module.details.title)
|
|
} icon: {
|
|
CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon)
|
|
}
|
|
}
|
|
.badge(module.details.badgeCount)
|
|
}
|
|
}
|
|
.backportTabBarMinimizeBehaviorOnScrollDown()
|
|
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
|
|
.sheet(item: $navigationTabCoordinator.sheetModule) { module in
|
|
module.coordinator?.toPresentable()
|
|
.id(module.id)
|
|
}
|
|
.fullScreenCover(item: $navigationTabCoordinator.fullScreenCoverModule) { module in
|
|
module.coordinator?.toPresentable()
|
|
.id(module.id)
|
|
}
|
|
.accessibilityHidden(navigationTabCoordinator.overlayModule?.coordinator != nil && navigationTabCoordinator.overlayPresentationMode == .fullScreen)
|
|
.overlay {
|
|
Group {
|
|
if let coordinator = navigationTabCoordinator.overlayModule?.coordinator {
|
|
coordinator.toPresentable()
|
|
.opacity(navigationTabCoordinator.overlayPresentationMode == .minimized ? 0 : 1)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.elementDefault, value: navigationTabCoordinator.overlayPresentationMode)
|
|
.animation(.elementDefault, value: navigationTabCoordinator.overlayModule)
|
|
}
|
|
}
|
|
|
|
private func configureAppearance(_ tabBarController: UITabBarController) {
|
|
standardAppearance.configureWithDefaultBackground()
|
|
standardAppearance.stackedLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPhone Portrait
|
|
standardAppearance.compactInlineLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPhone Landscape
|
|
standardAppearance.inlineLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPadOS 17 (doesn't work for 18+)
|
|
tabBarController.tabBar.standardAppearance = standardAppearance
|
|
}
|
|
}
|