// // 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 GameController import SwiftUI @_spi(Advanced) import SwiftUIIntrospect // MARK: - Search Controller Extensions extension View { /// A custom replacement for searchable that allows more precise configuration of the underlying search controller. /// /// Whilst we originally used introspect to configure parameters such as preventing the navigation bar from hiding /// during a search, this proved unreliable from iOS 17.1 onwards. This implementation avoids all of those shenanigans. /// **Note:** For some reason the font size is incorrect in the PreviewTests, buts its fine in UI tests and within the app. /// /// - Parameters: /// - query: The current or starting search text. /// - placeholder: The string to display when there’s no other text in the text field. /// - hidesNavigationBar: A Boolean indicating whether to hide the navigation bar when searching. /// - showsCancelButton: A Boolean indicating whether the search controller manages the visibility of the search bar’s cancel button. /// - disablesInteractiveDismiss: Whether or not interactive dismiss is disabled whilst the user is searching. func searchController(query: Binding, placeholder: String? = nil, hidesNavigationBar: Bool = false, showsCancelButton: Bool = true, disablesInteractiveDismiss: Bool = false, accessibilityFocusOnStart: Bool = false) -> some View { modifier(SearchControllerModifier(searchQuery: query, placeholder: placeholder, hidesNavigationBar: hidesNavigationBar, showsCancelButton: showsCancelButton, disablesInteractiveDismiss: disablesInteractiveDismiss, accessibilityFocusOnStart: accessibilityFocusOnStart)) } } private struct SearchControllerModifier: ViewModifier { @Binding var searchQuery: String let placeholder: String? let hidesNavigationBar: Bool let showsCancelButton: Bool let disablesInteractiveDismiss: Bool let accessibilityFocusOnStart: Bool /// Whether or not the user is currently searching. When ``automaticallyShowsCancelButton`` /// is `false`, checking if this value is `false` is pretty much meaningless. @State private var isSearching = false func body(content: Content) -> some View { let text: Text? = if let placeholder { Text(placeholder) } else { nil } if #available(iOS 26, *) { content .searchable(text: $searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: text) .interactiveDismissDisabled(!searchQuery.isEmpty && disablesInteractiveDismiss) .introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in // Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow. guard let searchController = navigationController.navigationBar.topItem?.searchController else { return } searchController.automaticallyShowsCancelButton = showsCancelButton searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar } .onDisappear { // Dismiss search when the view disappears to tidy up appearance when popping back to the view. if isSearching { isSearching = false } } } else { content .interactiveDismissDisabled(!searchQuery.isEmpty && disablesInteractiveDismiss) .background { SearchController(searchQuery: $searchQuery, placeholder: placeholder, hidesNavigationBar: hidesNavigationBar, showsCancelButton: showsCancelButton, hidesSearchBarWhenScrolling: false, accessibilityFocusOnStart: accessibilityFocusOnStart, isSearching: $isSearching) } .onDisappear { // Dismiss search when the view disappears to tidy up appearance when popping back to the view. if isSearching { isSearching = false } } } } } private struct SearchController: UIViewControllerRepresentable { @Binding var searchQuery: String let placeholder: String? let hidesNavigationBar: Bool let showsCancelButton: Bool let hidesSearchBarWhenScrolling: Bool let accessibilityFocusOnStart: Bool @Binding var isSearching: Bool func makeUIViewController(context: Context) -> SearchInjectionViewController { let controller = SearchInjectionViewController(searchController: context.coordinator.searchController, hidesSearchBarWhenScrolling: hidesSearchBarWhenScrolling) if accessibilityFocusOnStart { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UIAccessibility.post(notification: .screenChanged, argument: controller.searchController.searchBar) } } return controller } func updateUIViewController(_ viewController: SearchInjectionViewController, context: Context) { let searchController = viewController.searchController searchController.searchBar.text = searchQuery searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar searchController.automaticallyShowsCancelButton = showsCancelButton if searchController.isActive, !isSearching { DispatchQueue.main.async { searchController.isActive = false } } else if !searchController.isActive, isSearching { DispatchQueue.main.async { searchController.isActive = true } } if let placeholder { // Blindly setting nil clears the default placeholder. searchController.searchBar.placeholder = placeholder } viewController.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling } func makeCoordinator() -> Coordinator { Coordinator(searchQuery: $searchQuery, isSearching: $isSearching) } class Coordinator: NSObject, UISearchBarDelegate, UISearchControllerDelegate { let searchController = UISearchController() private let searchQuery: Binding private let isSearching: Binding init(searchQuery: Binding, isSearching: Binding) { self.searchQuery = searchQuery self.isSearching = isSearching super.init() searchController.delegate = self searchController.searchBar.delegate = self } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { searchQuery.wrappedValue = searchText } func didPresentSearchController(_ searchController: UISearchController) { isSearching.wrappedValue = true } func willDismissSearchController(_ searchController: UISearchController) { // Clear any search results when the user taps cancel. searchQuery.wrappedValue = "" } func didDismissSearchController(_ searchController: UISearchController) { isSearching.wrappedValue = false } } class SearchInjectionViewController: UIViewController { let searchController: UISearchController var hidesSearchBarWhenScrolling: Bool init(searchController: UISearchController, hidesSearchBarWhenScrolling: Bool) { self.searchController = searchController self.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling super.init(nibName: nil, bundle: nil) view.alpha = 0 } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } override func willMove(toParent parent: UIViewController?) { parent?.navigationItem.searchController = searchController parent?.navigationItem.preferredSearchBarPlacement = .stacked parent?.navigationItem.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling } } } // MARK: - Searchable Extensions extension View { func isSearching(_ isSearching: Binding) -> some View { modifier(IsSearchingModifier(isSearching: isSearching)) } /// Automatically focusses the view's search field if a hardware keyboard is connected. func focusSearchIfHardwareKeyboardAvailable() -> some View { modifier(FocusSearchIfHardwareKeyboardAvailableModifier()) } } private struct IsSearchingModifier: ViewModifier { @Environment(\.isSearching) private var isSearchingEnv @Binding var isSearching: Bool func body(content: Content) -> some View { content .onChange(of: isSearchingEnv) { isSearching = $1 } } } private struct FocusSearchIfHardwareKeyboardAvailableModifier: ViewModifier { @FocusState private var isFocused func body(content: Content) -> some View { content .searchFocused($isFocused) .onAppear(perform: focusIfHardwareKeyboardAvailable) } func focusIfHardwareKeyboardAvailable() { // The simulator always detects the hardware keyboard as connected #if !targetEnvironment(simulator) if GCKeyboard.coalesced != nil { MXLog.info("Hardware keyboard is connected") isFocused = true } #endif } }