Skip to content

Commit

Permalink
Replace searchable modifiers with a custom implementation using UISea…
Browse files Browse the repository at this point in the history
…rchController. (#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.
  • Loading branch information
pixlwave authored Dec 6, 2023
1 parent 6cd25fa commit e8b446c
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 52 deletions.
168 changes: 134 additions & 34 deletions ElementX/Sources/Other/SwiftUI/Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
private struct SearchControllerModifier: ViewModifier {
@Binding var searchQuery: String

func body(content: Content) -> some View {
content
.interactiveDismissDisabled(isSearching)
}
}

private struct DismissSearchOnDisappear: ViewModifier {
@Environment(\.isSearching) private var isSearching
@Environment(\.dismissSearch) private var dismissSearch
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit e8b446c

Please sign in to comment.