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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ struct InviteUsersScreen: View {
|
||||
.navigationTitle(L10n.screenCreateRoomAddPeopleTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.disableInteractiveDismissOnSearch()
|
||||
.dismissSearchOnDisappear()
|
||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone)
|
||||
.searchableConfiguration(hidesNavigationBar: false)
|
||||
.searchController(query: $context.searchQuery,
|
||||
placeholder: L10n.commonSearchForSomeone,
|
||||
showsCancelButton: false,
|
||||
disablesInteractiveDismiss: true)
|
||||
.compoundSearchField()
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
@@ -57,8 +57,7 @@ struct MessageForwardingScreen: View {
|
||||
.disabled(context.viewState.selectedRoomID == nil)
|
||||
}
|
||||
}
|
||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.searchableConfiguration(hidesNavigationBar: false)
|
||||
.searchController(query: $context.searchQuery, showsCancelButton: false)
|
||||
.compoundSearchField()
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
@@ -260,9 +260,9 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static var previews: some View {
|
||||
NotificationSettingsScreen(context: viewModel.context)
|
||||
.snapshot(delay: 0.1)
|
||||
.snapshot(delay: 2.0)
|
||||
NotificationSettingsScreen(context: viewModelConfigurationMismatch.context)
|
||||
.snapshot(delay: 0.1)
|
||||
.snapshot(delay: 2.0)
|
||||
.previewDisplayName("Configuration mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ struct StartChatScreen: View {
|
||||
.navigationTitle(L10n.actionStartChat)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.disableInteractiveDismissOnSearch()
|
||||
.dismissSearchOnDisappear()
|
||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone)
|
||||
.searchableConfiguration(hidesNavigationBar: false)
|
||||
.searchController(query: $context.searchQuery,
|
||||
placeholder: L10n.commonSearchForSomeone,
|
||||
showsCancelButton: false,
|
||||
disablesInteractiveDismiss: true)
|
||||
.compoundSearchField()
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:630f7168d3ed6a7e1a9d8084af15ab794ba79ece4c63e7147e127a36fb853ce2
|
||||
size 78972
|
||||
oid sha256:da7a4e7370b3955ed4ff24dfda68a2e911dc5c22c6a05891cb40850935b5fac0
|
||||
size 79353
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9eafc4604f3d3dabdbf85d97ca643be13cb83111ad98def8cc383108067a0269
|
||||
size 181066
|
||||
oid sha256:56af9ef188080e0fbbc542599c150c2e923be8fa1895b5262662e86b6ff8e8ad
|
||||
size 181373
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:33495c3c814ac0162bb8759acc1c6225271aa62dcbbd43f3076e77ffdae1d879
|
||||
size 96134
|
||||
oid sha256:c7e0c6e4e771ecfe02d50767c3c09740219de4a2aa75d298073e0db1a58e3df7
|
||||
size 96770
|
||||
|
||||
Reference in New Issue
Block a user