fix: stabilize mobile navigation stacks

This commit is contained in:
Tienson Qin
2026-05-19 00:31:15 +08:00
parent efd9380f22
commit 6dd00b0d6c
6 changed files with 124 additions and 20 deletions

View File

@@ -28,6 +28,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
/// Used to ignore JS-driven pops when we're popping in response to a native gesture.
private var ignoreRoutePopCount: Int = 0
/// Suppresses JS callbacks for native pops that already originated from JS history.
private var suppressNextNativePopCallback: Bool = false
/// Temporary snapshot image for smooth pop transitions.
private var popSnapshotView: UIView?
@@ -245,8 +248,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
guard let nav = navController else { return }
if nav.viewControllers.count > 1 {
_ = pathStack.popLast()
setPaths(pathStack, for: activeStackId)
suppressNextNativePopCallback = true
nav.popViewController(animated: animated)
}
}
@@ -310,6 +312,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
guard !ctx.isCancelled else {
self.pathStack = previousStack
self.setPaths(previousStack, for: self.activeStackId)
self.suppressNextNativePopCallback = false
if let fromVC {
SharedWebViewController.instance.attach(to: fromVC)
@@ -320,11 +323,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
// 🔑 DO NOT call webView.goBack().
// Tell JS explicitly that native popped.
self.ignoreRoutePopCount += 1
let shouldNotifyNativePop = !self.suppressNextNativePopCallback
self.suppressNextNativePopCallback = false
if let bridge = SharedWebViewController.instance.bridgeController.bridge {
let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();"
bridge.webView?.evaluateJavaScript(js, completionHandler: nil)
if shouldNotifyNativePop {
self.ignoreRoutePopCount += 1
if let bridge = SharedWebViewController.instance.bridgeController.bridge {
let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();"
bridge.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
SharedWebViewController.instance.attach(
@@ -415,12 +423,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
}
// Fast-path: cancel search home root.
// We do NOT rebuild nav stack and we do NOT reattach the WebView.
// JS will just navigate the existing shared WKWebView to "/".
if previousStackId == "search",
stackId == "home"{
// Just update bookkeeping so future home pushes/pop work correctly.
self.setPaths(["/__stack__/search"], for: "search")
self.activeStackId = "home"
self.setPaths(["/"], for: "home")

View File

@@ -60,9 +60,13 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin {
}
DispatchQueue.main.async {
let selectedId = self.store.selectedId
self.store.tabs = tabs
if let firstId = tabs.first?.id {
self.store.selectedId = firstId
if let selectedId = selectedId,
selectedId == "search" || tabs.contains(where: { $0.id == selectedId }) {
self.store.selectedId = selectedId
} else {
self.store.selectedId = tabs.first?.id
}
}

View File

@@ -117,6 +117,7 @@ private struct LiquidTabs26View: View {
@State private var hackShowKeyboard: Bool = false
@State private var selectedTab: LiquidTabsTabSelection = .content(0)
@State private var searchPath = NavigationPath()
private let maxMainTabs = 6
@@ -143,6 +144,9 @@ private struct LiquidTabs26View: View {
private func handleRetap(on selection: LiquidTabsTabSelection) {
print("User re-tapped tab: \(selection)")
if case .search = selection {
searchPath = NavigationPath()
}
navController.popToRootViewController(animated: true)
if let id = store.tabId(for: selection) {
@@ -220,6 +224,7 @@ private struct LiquidTabs26View: View {
navController: navController,
selectedTab: $selectedTab,
firstTabId: store.tabs.first?.id,
searchPath: $searchPath,
store: store
)
.ignoresSafeArea()
@@ -281,6 +286,7 @@ private struct LiquidTabs26View: View {
focusSearchField()
case .content:
searchPath = NavigationPath()
isSearchFocused = false
hackShowKeyboard = false
}
@@ -314,13 +320,14 @@ private struct SearchTabHost26: View {
let navController: UINavigationController
var selectedTab: Binding<LiquidTabsTabSelection>
let firstTabId: String?
@Binding var searchPath: NavigationPath
@ObservedObject var store: LiquidTabsStore
@Environment(\.isSearching) private var isSearching
@State private var wasSearching: Bool = false
var body: some View {
NavigationStack {
NavigationStack(path: $searchPath) {
ZStack {
Color.logseqBackground
.ignoresSafeArea()
@@ -339,6 +346,7 @@ private struct SearchTabHost26: View {
let firstId = firstTabId {
wasSearching = false
searchPath = NavigationPath()
selectedTab.wrappedValue = .content(0)
store.selectedId = firstId
}
@@ -355,6 +363,7 @@ private struct LiquidTabs16View: View {
let navController: UINavigationController
@State private var hackShowKeyboard: Bool = false
@State private var searchPath = NavigationPath()
private var searchTextBinding: Binding<String> {
Binding(
@@ -385,9 +394,15 @@ private struct LiquidTabs16View: View {
// Re-tap: pop to root
if id == store.selectedId {
if id == "search" {
searchPath = NavigationPath()
}
navController.popToRootViewController(animated: true)
LiquidTabsPlugin.shared?.notifyTabSelected(id: id, reselected: true)
} else {
if store.selectedId == "search" {
searchPath = NavigationPath()
}
store.selectedId = id
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
@@ -408,6 +423,7 @@ private struct LiquidTabs16View: View {
SearchTab16Host(
navController: navController,
searchText: searchTextBinding,
searchPath: $searchPath,
store: store
)
.ignoresSafeArea()
@@ -416,6 +432,11 @@ private struct LiquidTabs16View: View {
}
.tag("search" as String?)
}
.onChange(of: store.selectedId) { newId in
if newId != "search" {
searchPath = NavigationPath()
}
}
// Hidden UITextField that pre-invokes keyboard
KeyboardHackField(shouldShow: $hackShowKeyboard)
@@ -453,10 +474,11 @@ private struct LiquidTabs16View: View {
private struct SearchTab16Host: View {
let navController: UINavigationController
@Binding var searchText: String
@Binding var searchPath: NavigationPath
@ObservedObject var store: LiquidTabsStore
var body: some View {
NavigationStack {
NavigationStack(path: $searchPath) {
ZStack {
Color.logseqBackground
.ignoresSafeArea()

View File

@@ -134,8 +134,6 @@
(add-search-listener!
(fn [q]
;; wire up search handler
(js/console.log "Native search query" q)
(reset! mobile-state/*search-input q)
(p/let [result (mobile-search/search q)]
(update-native-search-results! result))))

View File

@@ -20,8 +20,9 @@
(defonce ^:private hooks-installed? (atom false))
;; Track whether the latest change came from a native back gesture / popstate.
(.addEventListener js/window "popstate" (fn [_]
(reset! navigation-source :pop)))
(when (fn? (.-addEventListener js/window))
(.addEventListener js/window "popstate" (fn [_]
(reset! navigation-source :pop))))
(defn current-stack
[]
@@ -220,8 +221,8 @@
(let [route-match (or route-match (:route-match (stack-defaults stack)))
path (or path (current-path))]
(route-handler/set-route-match! route-match)
(when (= current "search")
;; reset to :home
(when (and (= current "search")
(= stack primary-stack))
(orig-replace-state :home nil nil))
(notify-route-change!
{:route {:to (or (get-in route [:data :name])
@@ -308,7 +309,7 @@
(route-handler/set-route-match! route-match)
(notify-route-change!
{:route route
:route-match route-match
:route-match (assoc route-match :navigation-type "reset")
:path path
:stack stack
:push false})))))

View File

@@ -0,0 +1,74 @@
(ns mobile.navigation-test
(:require [cljs.test :refer [deftest is testing use-fixtures]]
[frontend.handler.route :as route-handler]
[frontend.mobile.util :as mobile-util]
[mobile.navigation :as mobile-nav]))
(defn- route-match
[name]
{:data {:name name}
:parameters {:path {} :query {}}})
(defn- stack-entry
[name path]
{:path path
:route {:to name :path-params {} :query-params {}}
:route-match (route-match name)})
(defn- reset-navigation-state! []
(reset! @#'mobile-nav/navigation-source nil)
(reset! @#'mobile-nav/initialised-stacks {})
(reset! @#'mobile-nav/active-stack "home")
(reset! @#'mobile-nav/stack-history {})
(reset! @#'mobile-nav/pending-navigation nil))
(use-fixtures :each
{:before reset-navigation-state!
:after reset-navigation-state!})
(deftest switch-stack-from-search-to-non-home-does-not-replace-browser-route-with-home
(testing "closing native search while selecting another tab should keep the selected stack route"
(let [replace-calls (atom [])
route-matches (atom [])]
(reset! @#'mobile-nav/active-stack "search")
(reset! @#'mobile-nav/stack-history
{"search" {:history [(stack-entry :search "/__stack__/search")]}
"graphs" {:history [(stack-entry :graphs "/__stack__/graphs")]}})
(with-redefs [mobile-nav/orig-replace-state (fn [& args] (swap! replace-calls conj args))
route-handler/set-route-match! (fn [match] (swap! route-matches conj match))
mobile-util/native-platform? (constantly false)]
(mobile-nav/switch-stack! "graphs")
(is (empty? @replace-calls))
(is (= :graphs (get-in (last @route-matches) [:data :name])))))))
(deftest switch-stack-from-search-to-home-closes-search-with-home-route
(testing "closing native search to Home still updates the browser route to Home"
(let [replace-calls (atom [])]
(reset! @#'mobile-nav/active-stack "search")
(reset! @#'mobile-nav/stack-history
{"search" {:history [(stack-entry :search "/__stack__/search")]}
"home" {:history [(stack-entry :home "/")]}})
(with-redefs [mobile-nav/orig-replace-state (fn [& args] (swap! replace-calls conj args))
route-handler/set-route-match! (constantly nil)
mobile-util/native-platform? (constantly false)]
(mobile-nav/switch-stack! "home")
(is (= [[:home nil nil]] @replace-calls))))))
(deftest pop-to-root-notifies-native-reset
(testing "collapsing a logical stack tells native to rebuild it as a single root view controller"
(let [payloads (atom [])]
(reset! @#'mobile-nav/active-stack "home")
(reset! @#'mobile-nav/stack-history
{"home" {:history [(stack-entry :home "/")
(stack-entry :page "/page/alpha")]}})
(with-redefs [mobile-nav/orig-replace-state (constantly nil)
route-handler/set-route-match! (constantly nil)
mobile-util/native-platform? (constantly true)
mobile-util/ui-local #js {:routeDidChange
(fn [payload]
(swap! payloads conj
(js->clj payload :keywordize-keys true))
(js/Promise.resolve nil))}]
(mobile-nav/pop-to-root! "home")
(is (= "reset" (:navigationType (last @payloads))))
(is (= "/" (:path (last @payloads))))))))