mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Search tab (#5754)
* 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.
This commit is contained in:
@@ -74,7 +74,7 @@ struct Application: App {
|
||||
|
||||
CommandGroup(after: .windowArrangement) {
|
||||
Button("Global Search") {
|
||||
appCoordinator.handleAppRoute(.globalSearch, windowType: nil)
|
||||
appCoordinator.handleAppRoute(.search, windowType: nil)
|
||||
}
|
||||
.keyboardShortcut("k", modifiers: [.command])
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ enum AppRoute: Hashable {
|
||||
case transferOwnership(roomID: String)
|
||||
/// A thread within a room, only to be used to handle tap on notification for threaded events.
|
||||
case thread(roomID: String, threadRootEventID: String, focusEventID: String?)
|
||||
/// The global search screen
|
||||
case globalSearch
|
||||
/// The search screen
|
||||
case search
|
||||
|
||||
/// Whether or not the route should be handled by the authentication flow.
|
||||
var isAuthenticationRoute: Bool {
|
||||
|
||||
@@ -23,17 +23,21 @@ import SwiftUI
|
||||
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>) {
|
||||
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 {
|
||||
@@ -294,18 +298,18 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
||||
var body: some View {
|
||||
TabView(selection: $navigationTabCoordinator.selectedTab) {
|
||||
ForEach(navigationTabCoordinator.tabModules) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
.tabItem {
|
||||
Label {
|
||||
Text(module.details.title)
|
||||
} icon: {
|
||||
CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.tag(module.details.tag)
|
||||
.badge(module.details.badgeCount)
|
||||
.toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar)
|
||||
}
|
||||
.badge(module.details.badgeCount)
|
||||
}
|
||||
}
|
||||
.backportTabBarMinimizeBehaviorOnScrollDown()
|
||||
|
||||
@@ -24,6 +24,7 @@ nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable {
|
||||
|
||||
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
||||
var threadsEnabled: Bool { get }
|
||||
var globalSearchEnabled: Bool { get }
|
||||
var hideQuietNotificationAlerts: Bool { get }
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
||||
case enableOnlySignedDeviceIsolationMode
|
||||
case knockingEnabled
|
||||
case threadsEnabled
|
||||
case globalSearchEnabled
|
||||
case roomThreadListEnabled
|
||||
case linkPreviewsEnabled
|
||||
case jumpToReadMarkerEnabled
|
||||
@@ -439,6 +441,9 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
||||
@UserPreference
|
||||
nonisolated(unsafe) var roomThreadListEnabled: Bool
|
||||
|
||||
@UserPreference
|
||||
nonisolated(unsafe) var globalSearchEnabled: Bool
|
||||
|
||||
@UserPreference
|
||||
nonisolated(unsafe) var focusEventOnNotificationTap: Bool
|
||||
|
||||
@@ -496,6 +501,7 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
||||
_enableOnlySignedDeviceIsolationMode = UserPreference(key: .enableOnlySignedDeviceIsolationMode, defaultValue: false, storage: store)
|
||||
_knockingEnabled = UserPreference(key: .knockingEnabled, defaultValue: false, storage: store)
|
||||
_threadsEnabled = UserPreference(key: .threadsEnabled, defaultValue: false, storage: store)
|
||||
_globalSearchEnabled = UserPreference(key: .globalSearchEnabled, defaultValue: ProcessInfo().isiOSAppOnMac, storage: store)
|
||||
_roomThreadListEnabled = UserPreference(key: .roomThreadListEnabled, defaultValue: false, storage: store)
|
||||
_focusEventOnNotificationTap = UserPreference(key: .focusEventOnNotificationTap, defaultValue: false, storage: store)
|
||||
_linkPreviewsEnabled = UserPreference(key: .linkPreviewsEnabled, defaultValue: false, storage: store)
|
||||
|
||||
@@ -17,7 +17,6 @@ class WindowManager: SecureWindowManagerProtocol {
|
||||
|
||||
private(set) var mainWindow: UIWindow!
|
||||
private(set) var overlayWindow: UIWindow!
|
||||
private(set) var globalSearchWindow: UIWindow!
|
||||
private(set) var alternateWindow: UIWindow!
|
||||
|
||||
private(set) var openWindowAction: OpenWindowAction!
|
||||
@@ -32,7 +31,7 @@ class WindowManager: SecureWindowManagerProtocol {
|
||||
}
|
||||
|
||||
var windows: [UIWindow] {
|
||||
[mainWindow, overlayWindow, globalSearchWindow, alternateWindow]
|
||||
[mainWindow, overlayWindow, alternateWindow]
|
||||
}
|
||||
|
||||
// periphery:ignore - auto cancels when reassigned
|
||||
@@ -80,11 +79,6 @@ class WindowManager: SecureWindowManagerProtocol {
|
||||
overlayWindow.backgroundColor = .clear
|
||||
overlayWindow.isHidden = false
|
||||
|
||||
globalSearchWindow = UIWindow(windowScene: scene)
|
||||
globalSearchWindow.tintColor = .compound.textActionPrimary
|
||||
globalSearchWindow.backgroundColor = .clear
|
||||
globalSearchWindow.isHidden = true
|
||||
|
||||
alternateWindow = UIWindow(windowScene: scene)
|
||||
alternateWindow.tintColor = .compound.textActionPrimary
|
||||
|
||||
@@ -138,8 +132,6 @@ class WindowManager: SecureWindowManagerProtocol {
|
||||
// e.g. the keyboard being displayed on top of a call sheet.
|
||||
mainWindow.endEditing(true)
|
||||
|
||||
hideGlobalSearch()
|
||||
|
||||
// alternateWindow.isHidden = false cannot got inside the Task otherwise the timing
|
||||
// is poor when you lock the phone - you briefly see the main window for a few
|
||||
// frames after you've unlocked the phone and then the placeholder animates in.
|
||||
@@ -149,41 +141,9 @@ class WindowManager: SecureWindowManagerProtocol {
|
||||
|
||||
mainWindow.isHidden = true
|
||||
overlayWindow.isHidden = true
|
||||
globalSearchWindow.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
func showGlobalSearch() {
|
||||
MXLog.info("Received global search presentation request.")
|
||||
|
||||
guard alternateWindow.isHidden else {
|
||||
MXLog.info("The alternate window is visible, ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
if let mainSession {
|
||||
let request = UISceneSessionActivationRequest(session: mainSession)
|
||||
UIApplication.shared.activateSceneSession(for: request) { error in
|
||||
MXLog.error("Failed to focus window with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
globalSearchWindow.isHidden = false
|
||||
globalSearchWindow.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
func hideGlobalSearch() {
|
||||
MXLog.info("Received global search dismissal request.")
|
||||
|
||||
guard alternateWindow.isHidden else {
|
||||
MXLog.info("The alternate window is visible, ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
globalSearchWindow.isHidden = true
|
||||
mainWindow.makeKey()
|
||||
}
|
||||
|
||||
// MARK: - OrientationManager
|
||||
|
||||
func setOrientation(_ orientation: UIInterfaceOrientationMask) {
|
||||
|
||||
@@ -50,19 +50,12 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol {
|
||||
var mainWindow: UIWindow! { get }
|
||||
/// Presented on top of the main window, to display e.g. user indicators.
|
||||
var overlayWindow: UIWindow! { get }
|
||||
/// A window layered on top of the main one. Used by the global search function
|
||||
var globalSearchWindow: UIWindow! { get }
|
||||
/// A secondary window that can be presented instead of the main/overlay window combo.
|
||||
var alternateWindow: UIWindow! { get }
|
||||
|
||||
/// All the windows being managed
|
||||
var windows: [UIWindow] { get }
|
||||
|
||||
/// Makes the global search window key. Used to get automatic text field focus.
|
||||
func showGlobalSearch()
|
||||
|
||||
func hideGlobalSearch()
|
||||
|
||||
// MARK: - Secondary window support
|
||||
|
||||
var secondaryWindowsEnabled: Bool { get set }
|
||||
|
||||
Reference in New Issue
Block a user