247 lines
10 KiB
Swift
247 lines
10 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 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<String>,
|
||
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<String>
|
||
private let isSearching: Binding<Bool>
|
||
|
||
init(searchQuery: Binding<String>, isSearching: Binding<Bool>) {
|
||
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<Bool>) -> 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
|
||
}
|
||
}
|