mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 15:09:41 +00:00
fix: stabilize mobile navigation stacks
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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})))))
|
||||
|
||||
74
src/test/mobile/navigation_test.cljs
Normal file
74
src/test/mobile/navigation_test.cljs
Normal 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))))))))
|
||||
Reference in New Issue
Block a user