Files
Termind/TFI/UI/Components/MobileToolbarSearch.swift

208 lines
6.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import TermindPresentation
import UIKit
extension View {
func mobileToolbarSearch(
text: Binding<String>,
isPresented: Binding<Bool>? = nil,
prompt: LocalizedStringResource,
onSubmit: @escaping () -> Void = {}
) -> some View {
background {
MobileToolbarSearchAnchor(
text: text.singleLineText, isPresented: isPresented, prompt: prompt,
onSubmit: onSubmit)
}
}
}
private struct MobileToolbarSearchAnchor: UIViewControllerRepresentable {
let text: Binding<String>
let isPresented: Binding<Bool>?
let prompt: LocalizedStringResource
let onSubmit: () -> Void
@Environment(\.locale) private var locale
func makeUIViewController(context: Context) -> MobileToolbarSearchController {
MobileToolbarSearchController()
}
func updateUIViewController(_ controller: MobileToolbarSearchController, context: Context) {
var prompt = prompt
prompt.locale = locale
controller.configure(
text: text, isPresented: isPresented, placeholder: String(localized: prompt),
onSubmit: onSubmit)
}
static func dismantleUIViewController(_ controller: MobileToolbarSearchController, coordinator: ()) {
controller.disconnect()
}
}
/// UIKit owns the entire search presentation. SwiftUI's searchable focus and
/// presentation bindings can otherwise reopen an integrated toolbar mid-dismissal.
@MainActor
private final class MobileToolbarSearchController: UIViewController, UISearchControllerDelegate, UISearchBarDelegate {
private let search = UISearchController(searchResultsController: nil)
private var text: Binding<String>?
private var presentation: Binding<Bool>?
private var onSubmit: () -> Void = {}
private var isEnabled = false
private var isVisible = false
private var isDismissing = false
private weak var owner: UIViewController?
private var previousDefinesPresentationContext = false
override func loadView() {
view = UIView()
view.isUserInteractionEnabled = false
search.delegate = self
search.searchBar.delegate = self
search.obscuresBackgroundDuringPresentation = false
search.hidesNavigationBarDuringPresentation = false
search.searchBar.autocapitalizationType = .none
search.searchBar.autocorrectionType = .no
}
func configure(
text: Binding<String>, isPresented: Binding<Bool>?, placeholder: String,
onSubmit: @escaping () -> Void
) {
self.text = text
presentation = isPresented
isEnabled = true
self.onSubmit = onSubmit
search.searchBar.placeholder = placeholder
// Configuration comes from a SwiftUI update. Defer UIKit's delegate
// callbacks, which can publish presentation changes back to the model.
DispatchQueue.main.async { [weak self] in self?.synchronize() }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
isVisible = true
synchronize()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Tab and navigation stacks retain their children. Release the native
// presentation before another page takes over the shared toolbar.
isVisible = false
removeSearch()
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if parent != nil { synchronize() }
}
private var navigationOwner: UIViewController? {
var child: UIViewController = self
while let parent = child.parent {
if parent is UINavigationController { return child }
child = parent
}
return nil
}
private func synchronize() {
guard isEnabled, isVisible else {
removeSearch()
return
}
guard let destination = navigationOwner else { return }
if owner !== destination {
removeSearch()
owner = destination
previousDefinesPresentationContext = destination.definesPresentationContext
destination.definesPresentationContext = true
}
let item = destination.navigationItem
if item.searchController !== search {
if #available(iOS 26.0, *) {
item.preferredSearchBarPlacement = .integratedButton
} else {
item.preferredSearchBarPlacement = .inline
}
item.searchController = search
}
guard !isDismissing else { return }
if search.searchBar.text != text?.wrappedValue {
search.searchBar.text = text?.wrappedValue
}
if let requested = presentation?.wrappedValue, search.isActive != requested {
if requested {
search.isActive = true
search.searchBar.becomeFirstResponder()
} else {
dismissSearch()
}
}
}
func disconnect() {
isEnabled = false
removeSearch()
}
private func removeSearch() {
if search.isActive { dismissSearch() }
if let owner, owner.navigationItem.searchController === search {
owner.navigationItem.searchController = nil
owner.definesPresentationContext = previousDefinesPresentationContext
}
owner = nil
}
private func publishPresentation(_ presented: Bool) {
guard let presentation, presentation.wrappedValue != presented else { return }
presentation.wrappedValue = presented
}
private func dismissSearch() {
// Do not accept a new presentation until UIKit completes this dismissal.
isDismissing = true
search.searchBar.resignFirstResponder()
publishPresentation(false)
search.isActive = false
}
func willPresentSearchController(_ searchController: UISearchController) {
guard isEnabled, isVisible, !isDismissing else {
searchController.isActive = false
return
}
searchController.searchBar.text = text?.wrappedValue
publishPresentation(true)
}
func willDismissSearchController(_ searchController: UISearchController) {
isDismissing = true
publishPresentation(false)
}
func didDismissSearchController(_ searchController: UISearchController) {
isDismissing = false
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
dismissSearch()
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
guard !isDismissing else { return }
text?.wrappedValue = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard !isDismissing else { return }
onSubmit()
}
}