Files
letro-ios/ElementX/Sources/Other/SwiftUI/Search.swift
Mauro Romito 30c2ca510a added argument to voice over on the search bar automatically
- also removed the geomtry reader in favour of a `readWidth` function
2025-05-09 10:18:34 +02:00

224 lines
9.0 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 2023, 2024 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
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 {
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 {
if #available(iOS 18.0, *) {
content
.searchFocused($isFocused)
.onAppear(perform: focusIfHardwareKeyboardAvailable)
} else {
content
}
}
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
}
}