Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 111 additions & 17 deletions apple/Sources/Logseq/LGNavigationExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,20 +304,25 @@ private struct LGSearchPresentation: View {
@State private var nativePresented = false

var body: some View {
baseContent
.fullScreenCover(isPresented: $nativePresented, onDismiss: didDismiss) {
ZStack {
baseContent
.ignoresSafeArea(.keyboard, edges: nativePresented ? .bottom : [])
.allowsHitTesting(!nativePresented)
.accessibilityHidden(nativePresented)
if nativePresented {
LGSearchSession(context: context, close: closeSearch)
.presentationBackground(.background)
.zIndex(1)
}
.onChange(of: presented, initial: true) {
if presented {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { nativePresented = true }
} else {
closeSearch()
}
}
.onChange(of: presented, initial: true) {
if presented {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { nativePresented = true }
} else {
closeSearch()
}
}
}

private var baseContent: AnyView {
Expand All @@ -331,9 +336,13 @@ private struct LGSearchPresentation: View {
}

private func closeSearch() {
var transaction = Transaction(animation: .default)
transaction.disablesAnimations = false
guard nativePresented else { return }
// The system search control already animates cancellation and the keyboard.
// Retire this layer without adding another presentation transition.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { nativePresented = false }
didDismiss()
}

private func didDismiss() {
Expand All @@ -356,6 +365,8 @@ private struct LGSearchSession: View {
if pendingQuery == query { pendingQuery = nil }
}
#if os(iOS)
.opacity(closing ? 0 : 1)
.animation(.easeOut(duration: 0.2), value: closing)
.onChange(of: depth) {
if depth == 0 && !closing { nativeSearchPresented = true }
}
Expand All @@ -378,10 +389,14 @@ private struct LGSearchSession: View {
private func searchRootTransform(_ content: AnyView) -> AnyView {
let content = AnyView(content.environment(\.luiTextHighlightQuery, query))
#if os(iOS)
let observedContent = AnyView(content.background(LGSearchDismissalObserver(
willDismiss: { if depth == 0 { exitSearch() } },
didDismiss: { if depth == 0 && closing { close() } }
)))
if #available(iOS 26.0, *) {
return AnyView(nativeBottomSearch(content))
return AnyView(nativeBottomSearch(observedContent))
}
return AnyView(nativeLegacySearch(content))
return AnyView(nativeLegacySearch(observedContent))
#else
return AnyView(nativeAutomaticSearch(content))
#endif
Expand Down Expand Up @@ -417,7 +432,7 @@ private struct LGSearchSession: View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.scrollDismissesKeyboard(.interactively)
.navigationTitle(searchTitle)
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.searchable(
text: queryBinding,
Expand All @@ -428,13 +443,13 @@ private struct LGSearchSession: View {
.toolbar {
DefaultToolbarItem(kind: .search, placement: .bottomBar)
}
.toolbar(depth == 0 ? .hidden : .visible, for: .navigationBar)
}

private func exitSearch() {
guard !closing else { return }
closing = true
pendingQuery = query
close()
}

private var nativeSearchPresentedBinding: Binding<Bool> {
Expand Down Expand Up @@ -851,6 +866,85 @@ private struct LGNavigationContent: View {
}

#if os(iOS)
@MainActor
private struct LGSearchDismissalObserver: UIViewControllerRepresentable {
let willDismiss: () -> Void
let didDismiss: () -> Void

func makeUIViewController(context: Context) -> Observer {
Observer(willDismiss: willDismiss, didDismiss: didDismiss)
}

func updateUIViewController(_ controller: Observer, context: Context) {
controller.proxy.willDismiss = willDismiss
controller.proxy.didDismiss = didDismiss
controller.resolve()
}

static func dismantleUIViewController(_ controller: Observer, coordinator: ()) {
controller.uninstall()
}

final class Observer: UIViewController {
let proxy: Delegate
weak var search: UISearchController?

init(willDismiss: @escaping () -> Void, didDismiss: @escaping () -> Void) {
proxy = Delegate(willDismiss: willDismiss, didDismiss: didDismiss)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
override func loadView() { view = UIView() }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); resolve() }
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); resolve() }

func resolve() {
guard let search = navigationController?.viewControllers.compactMap({ $0.navigationItem.searchController }).first,
search.delegate !== proxy else { return }
uninstall()
self.search = search
proxy.forwarded = search.delegate
search.delegate = proxy
}

func uninstall() {
if let search, search.delegate === proxy { search.delegate = proxy.forwarded }
search = nil
}
}

final class Delegate: NSObject, UISearchControllerDelegate {
// UIKit's optional-method lookup is nonisolated; installation stays on the main actor.
nonisolated(unsafe) weak var forwarded: UISearchControllerDelegate?
var willDismiss: () -> Void
var didDismiss: () -> Void

init(willDismiss: @escaping () -> Void, didDismiss: @escaping () -> Void) {
self.willDismiss = willDismiss
self.didDismiss = didDismiss
}

func willDismissSearchController(_ searchController: UISearchController) {
willDismiss()
forwarded?.willDismissSearchController?(searchController)
}

func didDismissSearchController(_ searchController: UISearchController) {
forwarded?.didDismissSearchController?(searchController)
didDismiss()
}

nonisolated override func responds(to selector: Selector!) -> Bool {
super.responds(to: selector) || forwarded?.responds(to: selector) == true
}

nonisolated override func forwardingTarget(for selector: Selector!) -> Any? {
if let forwarded, forwarded.responds(to: selector) { return forwarded }
return super.forwardingTarget(for: selector)
}
}
}

@MainActor
private final class LGNavigationTransitions {
weak var controller: UINavigationController?
Expand Down
2 changes: 2 additions & 0 deletions apple/Tests/UI/check-search-presentation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ mkdir -p "$check_dir/SearchCheck.app"
xcrun --sdk iphonesimulator swiftc -parse-as-library \
-target arm64-apple-ios17.0-simulator -I "$build_dir/Modules" \
Sources/Logseq/LGNavigationExtension.swift \
Sources/Logseq/LGAdaptiveContentInsets.swift \
Sources/Logseq/LGInteractionDiagnostics.swift \
Sources/Logseq/LGLiquidGlassTweak.swift \
Sources/Logseq/LogseqThemeSettings.swift \
"$build_dir/Logseq.build/DerivedSources/resource_bundle_accessor.swift" \
Expand Down
76 changes: 52 additions & 24 deletions apple/Tests/UI/search-dismiss-transition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,27 @@ import SwiftUI
@testable import LUIAppleBackend
@testable import Logseq

@MainActor private final class SearchKeyboardDismissal: NSObject {
private(set) var completed = false
override init() {
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(didHide),
name: UIResponder.keyboardDidHideNotification, object: nil)
}
@objc private func didHide(_ notification: Notification) {
completed = true
}
deinit { NotificationCenter.default.removeObserver(self) }
}

@main struct SearchDismissTransitionCheck {
@MainActor static func main() async throws {
try await check("")
try await check("Needle")
print("PASS: empty and populated search close with the native keyboard, preserve outgoing content, and keep the toolbar at the bottom")
}

@MainActor static func check(_ initialQuery: String) async throws {
var query = ""
func snapshot() -> String {
let result: [String: Any] = ["graphName": "Work", "selectedGraphId": "work",
Expand Down Expand Up @@ -49,52 +68,61 @@ import SwiftUI
views(window).contains { ($0 as? UISearchTextField)?.isFirstResponder == true }
}
let field = views(window).compactMap { $0 as? UISearchTextField }.first { $0.isFirstResponder }!
field.insertText("Needle")
try await wait("Search results must be visible before closing") { find("search.result.found") != nil }
if !initialQuery.isEmpty {
field.insertText(initialQuery)
try await wait("Search results must be visible before closing") { find("search.result.found") != nil }
}
try await Task.sleep(for: .milliseconds(250))
guard let presented = window.rootViewController?.presentedViewController else { throw failure("Missing search presentation") }
guard let root = window.rootViewController else { throw failure("Missing app controller") }
if let modal = root.presentedViewController, !(modal is UISearchController) {
throw failure("Search must close in place instead of adding a full-screen modal transition")
}
func searchController(_ controller: UIViewController) -> UISearchController? {
if let search = controller.navigationItem.searchController, search.isActive { return search }
for child in controller.children {
if let search = searchController(child) { return search }
}
return controller.presentedViewController.flatMap(searchController)
}
guard let search = searchController(presented), let delegate = search.searchBar.delegate else {
guard let search = searchController(root), let delegate = search.searchBar.delegate else {
throw failure("Missing active native search controller")
}
let fieldTop = field.convert(field.bounds, to: window).minY
let keyboard = SearchKeyboardDismissal()
delegate.searchBarCancelButtonClicked?(search.searchBar)
func retainsQuery() -> Bool {
let node = backend.rootIDs.flatMap(descendants).first {
backend.extensionModel(id: $0)?.identifier == "native-search-presentation"
}
guard let node, case .string("Needle")? = backend.extensionModel(id: node)?.properties["query"] else { return false }
return find("search.result.found") != nil
guard let node, case .string(let value)? = backend.extensionModel(id: node)?.properties["query"],
value == initialQuery else { return false }
return initialQuery.isEmpty || find("search.result.found") != nil
}
var animated = false
for _ in 0..<100 {
guard retainsQuery() else {
throw failure("Closing search must preserve the outgoing query and results until dismissal starts")
}
if presented.isBeingDismissed || presented.transitionCoordinator != nil {
animated = true
break
try await Task.sleep(for: .milliseconds(30))
guard field.window == nil || retainsQuery() else {
throw failure("The outgoing results must remain mounted during dismissal")
}
for _ in 0..<6 {
guard field.window == nil || field.convert(field.bounds, to: window).minY >= fieldTop - 2 else {
throw failure("The outgoing search toolbar must not jump to the top during dismissal")
}
guard window.rootViewController?.presentedViewController != nil else {
throw failure("Search disappeared without a dismissal animation")
try await Task.sleep(for: .milliseconds(20))
}
try await wait("The search session must be cleared after dismissal") {
let node = backend.rootIDs.flatMap(descendants).first {
backend.extensionModel(id: $0)?.identifier == "native-search-presentation"
}
try await Task.sleep(for: .milliseconds(10))
guard let node, case .bool(false)? = backend.extensionModel(id: node)?.properties["presented"] else { return false }
return find("search.result.found") == nil
}
guard animated else { throw failure("Closing search must start its dismissal animation") }
try await Task.sleep(for: .milliseconds(30))
guard retainsQuery() else {
throw failure("The outgoing results must remain mounted during dismissal")
guard keyboard.completed else {
throw failure("Search must preserve its native hierarchy until the keyboard finishes dismissing")
}
try await wait("Search must finish dismissing") { window.rootViewController?.presentedViewController == nil }
try await wait("The search session must be cleared after dismissal") { find("search.result.found") == nil }
try await wait("Search must release its input") { !field.isFirstResponder }
guard window.rootViewController === root else { throw failure("Search must retain the app controller") }
guard runtime.lastError == nil else { throw failure(runtime.lastError!) }
print("PASS: native search dismisses with its query and results intact, then clears the closed session")
}

static func failure(_ message: String) -> NSError {
NSError(domain: "SearchDismissTransition", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
Expand Down
6 changes: 1 addition & 5 deletions apple/Tests/UI/search-editor-toolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,6 @@ import SwiftUI
defer { window.isHidden = true; runtime.stop() }
try await Task.sleep(for: .milliseconds(300))
try press("button.search")
try await wait("Search must present its own navigation controller") {
window.rootViewController?.presentedViewController != nil
}
func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) }
try await wait("Native search must become ready for typing") {
views(window).compactMap { $0 as? UISearchTextField }.contains { $0.isFirstResponder }
Expand Down Expand Up @@ -101,8 +98,7 @@ import SwiftUI
guard find("toolbar.outliner.editor", in: chrome) != nil else { throw failure("Missing editor toolbar") }
var measuredFrame: CGRect?
try await wait("The search editor toolbar must be visible above the keyboard") {
guard let presented = window.rootViewController?.presentedViewController else { return false }
measuredFrame = views(presented.view).compactMap { $0 as? UIScrollView }.compactMap { scroll in
measuredFrame = views(window).compactMap { $0 as? UIScrollView }.compactMap { scroll in
let rect = scroll.convert(scroll.bounds, to: window)
return scroll.window != nil && rect.height >= 42 && rect.height <= 60
&& rect.minY > window.bounds.height / 2
Expand Down
9 changes: 3 additions & 6 deletions apple/Tests/UI/search-presentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,7 @@ let logger = Logger(subsystem: "com.logseq.logseq.tests", category: "Search")
if delayedQueries != nil { delayedQueries?.append(query) }
else { applyQuery(query) }
}
let host = SearchCheckHost(rootView: NavigationStack {
LUISwiftUIRoot(backend: backend, rootID: 1)
.toolbar { ToolbarItem(placement: .topBarLeading) { Text("Journals") } }
})
let host = SearchCheckHost(rootView: LUISwiftUIRoot(backend: backend, rootID: 1))
let window = UIWindow(frame: CGRect(x:0,y:0,width:402,height:874))
self.window = window
window.rootViewController = host; window.makeKeyAndVisible()
Expand Down Expand Up @@ -147,8 +144,8 @@ let logger = Logger(subsystem: "com.logseq.logseq.tests", category: "Search")
record("FAIL: search should accept typing while its presentation animates")
failures += 1
}
if window.rootViewController?.presentedViewController == nil {
record("FAIL: search navigation must be isolated from the app navigation")
if let modal = window.rootViewController?.presentedViewController, !(modal is UISearchController) {
record("FAIL: search must not add a full-screen modal over the app navigation")
failures += 1
}
input()?.insertText("Fixture")
Expand Down
15 changes: 10 additions & 5 deletions apple/Tests/UI/search-result-navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,16 @@ import UIKit
query == "Search" && find("search.result.result-0") != nil && find("search.result.result-40") != nil
}
try await Task.sleep(for: .milliseconds(200))
let presented = window.rootViewController!.presentedViewController!
let root = window.rootViewController!
func searchNavigation() -> UINavigationController? {
controllers(root).compactMap { $0 as? UINavigationController }.first {
$0.viewControllers.contains { $0.navigationItem.searchController != nil }
}
}
try await wait("Search must own a navigation controller") {
controllers(presented).contains { $0 is UINavigationController }
searchNavigation() != nil
}
let navigation = controllers(presented).compactMap { $0 as? UINavigationController }.first!
let navigation = searchNavigation()!
func screenshot(_ name: String) throws {
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
Expand All @@ -126,7 +131,7 @@ import UIKit
try await wait("Result content must mount at its page or revealed block") {
find("outliner.block.\(target)") != nil
}
guard window.rootViewController?.presentedViewController === presented,
guard window.rootViewController === root, searchNavigation() === navigation,
find("outliner.block.\(target)") != nil else {
throw failure("Opening a result must retain the search session and its loaded content")
}
Expand All @@ -135,7 +140,7 @@ import UIKit
guard let item = navigation.topViewController?.navigationItem else { return false }
let hasNativeItems = !(item.rightBarButtonItems ?? []).isEmpty
|| item.trailingItemGroups.contains { !$0.barButtonItems.isEmpty }
return hasNativeItems && find("button.connection") != nil
return !navigation.isNavigationBarHidden && hasNativeItems && find("button.connection") != nil
}
window.layoutIfNeeded()
if cycle == 11 { try screenshot("search-result-destination.png") }
Expand Down
Loading
Loading