Files
letro-ios/ElementX/Sources/Other/SwiftUI/Search.swift
2026-01-27 12:50:57 +02:00

247 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 theres 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 bars 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
}
}