mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +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
|
import SwiftUIIntrospect
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// Disable the interactive dismiss while the search is on.
|
/// A custom replacement for searchable that allows more precise configuration of the underlying search controller.
|
||||||
/// - Note: the modifier needs to be called before the `searchable` modifier to work properly
|
///
|
||||||
func disableInteractiveDismissOnSearch() -> some View {
|
/// Whilst we originally used introspect to configure parameters such as preventing the navigation bar from hiding
|
||||||
modifier(InteractiveDismissSearchModifier())
|
/// 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.
|
||||||
|
///
|
||||||
/// 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.
|
|
||||||
/// - Parameters:
|
/// - 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.
|
/// - 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.
|
/// - showsCancelButton: A Boolean indicating whether the search controller manages the visibility of the search bar’s cancel button.
|
||||||
///
|
/// - disablesInteractiveDismiss: Whether or not interactive dismiss is disabled whilst the user is searching.
|
||||||
/// This modifier may be moved into Compound once styles for the various configuration options have been defined.
|
func searchController(query: Binding<String>,
|
||||||
func searchableConfiguration(hidesNavigationBar: Bool = true,
|
placeholder: String? = nil,
|
||||||
showsCancelButton: Bool = true) -> some View {
|
hidesNavigationBar: Bool = false,
|
||||||
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
showsCancelButton: Bool = true,
|
||||||
guard let searchController = navigationController.navigationBar.topItem?.searchController else { return }
|
disablesInteractiveDismiss: Bool = false) -> some View {
|
||||||
searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar
|
modifier(SearchControllerModifier(searchQuery: query,
|
||||||
searchController.automaticallyShowsCancelButton = showsCancelButton
|
placeholder: placeholder,
|
||||||
}
|
hidesNavigationBar: hidesNavigationBar,
|
||||||
|
showsCancelButton: showsCancelButton,
|
||||||
|
disablesInteractiveDismiss: disablesInteractiveDismiss))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct InteractiveDismissSearchModifier: ViewModifier {
|
private struct SearchControllerModifier: ViewModifier {
|
||||||
@Environment(\.isSearching) private var isSearching
|
@Binding var searchQuery: String
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
let placeholder: String?
|
||||||
content
|
let hidesNavigationBar: Bool
|
||||||
.interactiveDismissDisabled(isSearching)
|
let showsCancelButton: Bool
|
||||||
}
|
let disablesInteractiveDismiss: Bool
|
||||||
}
|
|
||||||
|
/// Whether or not the user is currently searching. When ``automaticallyShowsCancelButton``
|
||||||
private struct DismissSearchOnDisappear: ViewModifier {
|
/// is `false`, checking if this value is `false` is pretty much meaningless.
|
||||||
@Environment(\.isSearching) private var isSearching
|
@State private var isSearching = false
|
||||||
@Environment(\.dismissSearch) private var dismissSearch
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
|
.interactiveDismissDisabled(!searchQuery.isEmpty && disablesInteractiveDismiss)
|
||||||
|
.background {
|
||||||
|
SearchController(searchQuery: $searchQuery,
|
||||||
|
placeholder: placeholder,
|
||||||
|
hidesNavigationBar: hidesNavigationBar,
|
||||||
|
showsCancelButton: showsCancelButton,
|
||||||
|
hidesSearchBarWhenScrolling: false,
|
||||||
|
isSearching: $isSearching)
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
// Dismiss search when the view disappears to tidy up appearance when popping back to the view.
|
||||||
if isSearching {
|
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)
|
.navigationTitle(L10n.screenCreateRoomAddPeopleTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.disableInteractiveDismissOnSearch()
|
.searchController(query: $context.searchQuery,
|
||||||
.dismissSearchOnDisappear()
|
placeholder: L10n.commonSearchForSomeone,
|
||||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone)
|
showsCancelButton: false,
|
||||||
.searchableConfiguration(hidesNavigationBar: false)
|
disablesInteractiveDismiss: true)
|
||||||
.compoundSearchField()
|
.compoundSearchField()
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ struct MessageForwardingScreen: View {
|
|||||||
.disabled(context.viewState.selectedRoomID == nil)
|
.disabled(context.viewState.selectedRoomID == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
|
.searchController(query: $context.searchQuery, showsCancelButton: false)
|
||||||
.searchableConfiguration(hidesNavigationBar: false)
|
|
||||||
.compoundSearchField()
|
.compoundSearchField()
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -260,9 +260,9 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NotificationSettingsScreen(context: viewModel.context)
|
NotificationSettingsScreen(context: viewModel.context)
|
||||||
.snapshot(delay: 0.1)
|
.snapshot(delay: 2.0)
|
||||||
NotificationSettingsScreen(context: viewModelConfigurationMismatch.context)
|
NotificationSettingsScreen(context: viewModelConfigurationMismatch.context)
|
||||||
.snapshot(delay: 0.1)
|
.snapshot(delay: 2.0)
|
||||||
.previewDisplayName("Configuration mismatch")
|
.previewDisplayName("Configuration mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ struct StartChatScreen: View {
|
|||||||
.navigationTitle(L10n.actionStartChat)
|
.navigationTitle(L10n.actionStartChat)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.disableInteractiveDismissOnSearch()
|
.searchController(query: $context.searchQuery,
|
||||||
.dismissSearchOnDisappear()
|
placeholder: L10n.commonSearchForSomeone,
|
||||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone)
|
showsCancelButton: false,
|
||||||
.searchableConfiguration(hidesNavigationBar: false)
|
disablesInteractiveDismiss: true)
|
||||||
.compoundSearchField()
|
.compoundSearchField()
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:630f7168d3ed6a7e1a9d8084af15ab794ba79ece4c63e7147e127a36fb853ce2
|
oid sha256:da7a4e7370b3955ed4ff24dfda68a2e911dc5c22c6a05891cb40850935b5fac0
|
||||||
size 78972
|
size 79353
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9eafc4604f3d3dabdbf85d97ca643be13cb83111ad98def8cc383108067a0269
|
oid sha256:56af9ef188080e0fbbc542599c150c2e923be8fa1895b5262662e86b6ff8e8ad
|
||||||
size 181066
|
size 181373
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:33495c3c814ac0162bb8759acc1c6225271aa62dcbbd43f3076e77ffdae1d879
|
oid sha256:c7e0c6e4e771ecfe02d50767c3c09740219de4a2aa75d298073e0db1a58e3df7
|
||||||
size 96134
|
size 96770
|
||||||
|
|||||||
Reference in New Issue
Block a user