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:
Doug
2023-12-06 17:43:55 +00:00
committed by GitHub
parent 9db54f779a
commit 722ad5b4e7
8 changed files with 152 additions and 53 deletions

View File

@@ -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 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.
///
/// 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
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:630f7168d3ed6a7e1a9d8084af15ab794ba79ece4c63e7147e127a36fb853ce2
size 78972
oid sha256:da7a4e7370b3955ed4ff24dfda68a2e911dc5c22c6a05891cb40850935b5fac0
size 79353

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9eafc4604f3d3dabdbf85d97ca643be13cb83111ad98def8cc383108067a0269
size 181066
oid sha256:56af9ef188080e0fbbc542599c150c2e923be8fa1895b5262662e86b6ff8e8ad
size 181373

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33495c3c814ac0162bb8759acc1c6225271aa62dcbbd43f3076e77ffdae1d879
size 96134
oid sha256:c7e0c6e4e771ecfe02d50767c3c09740219de4a2aa75d298073e0db1a58e3df7
size 96770