Files
letro-ios/ElementX/Sources/Other/SwiftUI/Search.swift
2024-03-06 11:02:30 +01:00

192 lines
7.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import SwiftUIIntrospect
// MARK: - Search Controller Extensions
extension View {
/// 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.
/// - 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 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 {
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.preferredSearchBarPlacement = .stacked
parent?.navigationItem.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling
}
}
}
// MARK: - Searchable Extensions
struct IsSearchingModifier: ViewModifier {
@Environment(\.isSearching) private var isSearchingEnv
@Binding var isSearching: Bool
func body(content: Content) -> some View {
content
.onChange(of: isSearchingEnv) { isSearching = $0 }
}
}
extension View {
func isSearching(_ isSearching: Binding<Bool>) -> some View {
modifier(IsSearchingModifier(isSearching: isSearching))
}
}