mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Replace searchable modifiers with a custom implementation using UISearchController. (#2209)
Unfortunately the introspection became unreliable from iOS 17.1 onwards. * Replace disableInteractiveDismissOnSearch and dismissSearchOnDisappear. These can be handled within our searchController implementation now. * Fix preview snapshots. Weirdly, the search field is bigger in these, although it hasn't changed in the UI tests or the app itself.
This commit is contained in:
@@ -18,53 +18,153 @@ import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
extension View {
|
||||
/// Disable the interactive dismiss while the search is on.
|
||||
/// - Note: the modifier needs to be called before the `searchable` modifier to work properly
|
||||
func disableInteractiveDismissOnSearch() -> some View {
|
||||
modifier(InteractiveDismissSearchModifier())
|
||||
}
|
||||
|
||||
/// Dismiss search when the view is disappearing. It helps to restore correct state on pop into a NavigationStack
|
||||
/// - Note: the modifier needs to be called before the `searchable` modifier to work properly
|
||||
func dismissSearchOnDisappear() -> some View {
|
||||
modifier(DismissSearchOnDisappear())
|
||||
}
|
||||
|
||||
/// Configures a searchable's underlying search controller.
|
||||
/// 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.
|
||||
///
|
||||
/// This modifier may be moved into Compound once styles for the various configuration options have been defined.
|
||||
func searchableConfiguration(hidesNavigationBar: Bool = true,
|
||||
showsCancelButton: Bool = true) -> some View {
|
||||
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
||||
guard let searchController = navigationController.navigationBar.topItem?.searchController else { return }
|
||||
searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar
|
||||
searchController.automaticallyShowsCancelButton = showsCancelButton
|
||||
}
|
||||
/// - 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) -> some View {
|
||||
modifier(SearchControllerModifier(searchQuery: query,
|
||||
placeholder: placeholder,
|
||||
hidesNavigationBar: hidesNavigationBar,
|
||||
showsCancelButton: showsCancelButton,
|
||||
disablesInteractiveDismiss: disablesInteractiveDismiss))
|
||||
}
|
||||
}
|
||||
|
||||
private struct InteractiveDismissSearchModifier: ViewModifier {
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.interactiveDismissDisabled(isSearching)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DismissSearchOnDisappear: ViewModifier {
|
||||
@Environment(\.isSearching) private var isSearching
|
||||
@Environment(\.dismissSearch) private var dismissSearch
|
||||
private struct SearchControllerModifier: ViewModifier {
|
||||
@Binding var searchQuery: String
|
||||
|
||||
let placeholder: String?
|
||||
let hidesNavigationBar: Bool
|
||||
let showsCancelButton: Bool
|
||||
let disablesInteractiveDismiss: 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,
|
||||
isSearching: $isSearching)
|
||||
}
|
||||
.onDisappear {
|
||||
// Dismiss search when the view disappears to tidy up appearance when popping back to the view.
|
||||
if isSearching {
|
||||
dismissSearch()
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchController: UIViewControllerRepresentable {
|
||||
@Binding var searchQuery: String
|
||||
|
||||
let placeholder: String?
|
||||
let hidesNavigationBar: Bool
|
||||
let showsCancelButton: Bool
|
||||
let hidesSearchBarWhenScrolling: Bool
|
||||
|
||||
@Binding var isSearching: Bool
|
||||
|
||||
func makeUIViewController(context: Context) -> SearchInjectionViewController {
|
||||
SearchInjectionViewController(searchController: context.coordinator.searchController,
|
||||
hidesSearchBarWhenScrolling: hidesSearchBarWhenScrolling)
|
||||
}
|
||||
|
||||
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.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user