mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +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.
260 lines
9.4 KiB
Swift
260 lines
9.4 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-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 Combine
|
|
import SwiftUI
|
|
|
|
class WindowManager: SecureWindowManagerProtocol {
|
|
private let appDelegate: AppDelegate
|
|
weak var mainScene: UIWindowScene?
|
|
weak var mainSession: UISceneSession?
|
|
weak var delegate: SecureWindowManagerDelegate?
|
|
|
|
private(set) var mainWindow: UIWindow!
|
|
private(set) var overlayWindow: UIWindow!
|
|
private(set) var alternateWindow: UIWindow!
|
|
|
|
private(set) var openWindowAction: OpenWindowAction!
|
|
private(set) var dismissWindowAction: DismissWindowAction!
|
|
|
|
var secondaryWindowsEnabled = true {
|
|
didSet {
|
|
if secondaryWindowsEnabled == false {
|
|
closeAllSecondaryWindows()
|
|
}
|
|
}
|
|
}
|
|
|
|
var windows: [UIWindow] {
|
|
[mainWindow, overlayWindow, alternateWindow]
|
|
}
|
|
|
|
// periphery:ignore - auto cancels when reassigned
|
|
/// The task used to switch windows, so that we don't get stuck in the wrong state with a quick switch.
|
|
@CancellableTask private var switchTask: Task<Void, Error>?
|
|
/// A duration that allows window switching to wait a couple of frames to avoid a transition through black.
|
|
private let windowHideDelay = Duration.milliseconds(33)
|
|
|
|
private var coordinators: [SecondaryWindowType: (coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?)] = [:]
|
|
|
|
init(appDelegate: AppDelegate) {
|
|
self.appDelegate = appDelegate
|
|
}
|
|
|
|
func configure(withScene scene: UIWindowScene, session: UISceneSession) {
|
|
// This gets called for all opened windows, we're only interested in the main window.
|
|
guard let userInfo = session.userInfo, userInfo[SceneDelegate.sceneIDKey] as? String == SceneDelegate.mainSceneID else {
|
|
scene.windows.forEach { $0.tintColor = .compound.textActionPrimary } // SecondaryWindow tinting.
|
|
return
|
|
}
|
|
|
|
// Don't allow more than 1 main window to be presented.
|
|
if mainScene != nil {
|
|
// The window will be presented momentarily, so lets leave it blank.
|
|
scene.keyWindow?.rootViewController = UIHostingController(rootView: Color.clear)
|
|
UIApplication.shared.requestSceneSessionDestruction(session, options: nil)
|
|
return
|
|
}
|
|
|
|
mainScene = scene
|
|
mainSession = session
|
|
|
|
// Restore the previous window size on macOS as this isn't automatic.
|
|
if let previousSize = mainWindow?.frame.size {
|
|
scene.resizeWindowOnMac(to: previousSize)
|
|
}
|
|
|
|
// `keyWindow` can be nil on iOS 26 until the scene becomes active, but the
|
|
// SwiftUI WindowGroup's window is already attached to the scene by then.
|
|
mainWindow = scene.keyWindow ?? scene.windows.first
|
|
mainWindow.tintColor = .compound.textActionPrimary
|
|
|
|
overlayWindow = PassthroughWindow(windowScene: scene)
|
|
overlayWindow.tintColor = .compound.textActionPrimary
|
|
overlayWindow.backgroundColor = .clear
|
|
overlayWindow.isHidden = false
|
|
|
|
alternateWindow = UIWindow(windowScene: scene)
|
|
alternateWindow.tintColor = .compound.textActionPrimary
|
|
|
|
delegate?.windowManagerDidConfigureWindows(self)
|
|
}
|
|
|
|
func configure(withOpenWindowAction openWindowAction: OpenWindowAction,
|
|
dismissWindowAction: DismissWindowAction) {
|
|
self.openWindowAction = openWindowAction
|
|
self.dismissWindowAction = dismissWindowAction
|
|
}
|
|
|
|
func handleSceneDisconnection(_ scene: UIWindowScene) {
|
|
if scene == mainScene {
|
|
mainScene = nil
|
|
mainSession = nil
|
|
// Leave the mainWindow so we can reapply it's size on macOS.
|
|
}
|
|
}
|
|
|
|
func handleRoute(_ appRoute: AppRoute, windowType: SecondaryWindowType) {
|
|
MXLog.info("Handling app route: \(appRoute) for window type: \(windowType)")
|
|
|
|
guard let flowCoordinator = coordinators[windowType]?.flowCoordinator else {
|
|
MXLog.error("Invalid flow coordinator")
|
|
return
|
|
}
|
|
|
|
flowCoordinator.handleAppRoute(appRoute, animated: true)
|
|
}
|
|
|
|
func switchToMain() {
|
|
mainWindow.isHidden = false
|
|
overlayWindow.isHidden = false
|
|
|
|
mainWindow.makeKey()
|
|
|
|
switchTask = Task {
|
|
// Delay hiding to make sure the main windows are visible.
|
|
try await Task.sleep(for: windowHideDelay)
|
|
|
|
alternateWindow.isHidden = true
|
|
}
|
|
}
|
|
|
|
func switchToAlternate() {
|
|
alternateWindow.isHidden = false
|
|
|
|
// We don't know what route the app will use when returning back
|
|
// to the main window, so end any editing operation now to avoid
|
|
// e.g. the keyboard being displayed on top of a call sheet.
|
|
mainWindow.endEditing(true)
|
|
|
|
// 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.
|
|
switchTask = Task {
|
|
// Delay hiding to make sure the alternate window is visible.
|
|
try await Task.sleep(for: windowHideDelay)
|
|
|
|
mainWindow.isHidden = true
|
|
overlayWindow.isHidden = true
|
|
}
|
|
}
|
|
|
|
// MARK: - OrientationManager
|
|
|
|
func setOrientation(_ orientation: UIInterfaceOrientationMask) {
|
|
mainScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
|
|
}
|
|
|
|
func lockOrientation(_ orientation: UIInterfaceOrientationMask) {
|
|
appDelegate.orientationLock = orientation
|
|
}
|
|
|
|
// MARK: - Secondary window support
|
|
|
|
func windowForType(_ type: SecondaryWindowType) -> AnyView {
|
|
MXLog.info("Requesting window for type: \(type)")
|
|
|
|
guard let coordinator = coordinators[type]?.coordinator else {
|
|
MXLog.error("Invalid coordinator for window type: \(type)")
|
|
return AnyView(InstantlyDismissingWindow())
|
|
}
|
|
|
|
// This behaves strangely and gets called late but cleans up enough
|
|
// and is self contained enough to be just good .. enough
|
|
return AnyView(coordinator.toPresentable().onDisappear { [weak self] in
|
|
self?.coordinators[type] = nil
|
|
})
|
|
}
|
|
|
|
func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: SecondaryWindowType) {
|
|
if secondaryWindowsEnabled == false {
|
|
MXLog.error("Cannot register coordinator, secondary windows are disabled.")
|
|
return
|
|
}
|
|
|
|
coordinators[type] = (coordinator, flowCoordinator)
|
|
openWindowAction(value: type)
|
|
}
|
|
|
|
func closeSecondaryWindow(forType type: SecondaryWindowType) {
|
|
dismissWindowAction(value: type)
|
|
}
|
|
|
|
func closeAllSecondaryWindows() {
|
|
for key in coordinators.keys {
|
|
dismissWindowAction(value: key)
|
|
}
|
|
|
|
coordinators.removeAll()
|
|
}
|
|
}
|
|
|
|
private class PassthroughWindow: UIWindow {
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
if #available(iOS 26, *) {
|
|
// Passthrough UIWindow using SwiftUI in iOS 26
|
|
// https://stackoverflow.com/a/79835964/730924
|
|
guard let rootView = rootViewController?.view else {
|
|
return nil
|
|
}
|
|
|
|
// Special handling for glass buttons
|
|
// ".glass has a layer name of "@1" and and .glassProminent has a layer name of "@2""
|
|
guard let name = rootView.layer.hitTest(point)?.name, !name.starts(with: "@") else {
|
|
return rootView
|
|
}
|
|
|
|
return nil
|
|
} else {
|
|
guard let hitView = super.hitTest(point, with: event) else {
|
|
return nil
|
|
}
|
|
|
|
guard let rootViewController else {
|
|
return nil
|
|
}
|
|
|
|
guard hitView != self else {
|
|
return nil
|
|
}
|
|
|
|
// If the returned view is the `UIHostingController`'s view, ignore.
|
|
return rootViewController.view == hitView ? nil : hitView
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whenever restoring an app SwiftUI tries to restore its windows as well
|
|
/// which we're generally not prepared for so use this to just close them instead
|
|
private struct InstantlyDismissingWindow: View {
|
|
@Environment(\.dismissWindow) var dismissWindow
|
|
|
|
var body: some View {
|
|
Rectangle()
|
|
.task {
|
|
dismissWindow()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIWindowScene {
|
|
func resizeWindowOnMac(to size: CGSize) {
|
|
// Hackity hack 🔨
|
|
guard ProcessInfo.processInfo.isiOSAppOnMac, let sizeRestrictions else { return }
|
|
|
|
self.sizeRestrictions?.minimumSize = size
|
|
self.sizeRestrictions?.maximumSize = size
|
|
|
|
Task {
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
self.sizeRestrictions?.minimumSize = sizeRestrictions.minimumSize
|
|
self.sizeRestrictions?.maximumSize = sizeRestrictions.maximumSize
|
|
}
|
|
}
|
|
}
|