enhance: mobile multi navigation stacks

This commit is contained in:
Tienson Qin
2025-12-06 05:44:12 +08:00
parent 4b1d51ae4a
commit cbb7556178
5 changed files with 330 additions and 65 deletions

View File

@@ -98,8 +98,9 @@
(js/console.log "Native search query" q)
(reset! mobile-state/*search-input q)
(reset! mobile-state/*search-last-input-at (common-util/time-ms))
(when (= :page (state/get-current-route))
(mobile-nav/reset-route!))))
(comment
(when (= :page (state/get-current-route))
(mobile-nav/reset-route!)))))
(add-keyboard-hack-listener!)))
(defn configure

View File

@@ -90,6 +90,7 @@
;; so it is available even in :advanced release builds
(prn "[Mobile] init!")
(log/add-handler mobile-state/log-append!)
(mobile-nav/install-native-bridge!)
(set-router!)
(init/init!)
(fhandler/start! render!))

View File

@@ -7,6 +7,7 @@
[promesa.core :as p]
[reitit.frontend.easy :as rfe]))
;; Each tab owns a navigation stack
(defonce navigation-source (atom nil))
(defonce ^:private initialised-stacks (atom {}))
(def ^:private primary-stack "home")
@@ -15,8 +16,30 @@
(defonce ^:private pending-navigation (atom nil))
(defonce ^:private hooks-installed? (atom false))
;; --- DEBUG toggle ---
(def ^:private debug-nav? true)
(defn- dbg [tag & args]
(when debug-nav?
(let [payload (cond
;; one map argument → use it directly
(and (= 1 (count args))
(map? (first args)))
(first args)
;; even number of args → treat as k/v pairs
(even? (count args))
(apply hash-map args)
;; odd / weird → just log the raw args
:else
{:args args})]
(log/info tag payload))))
;; Track whether the latest change came from a native back gesture / popstate.
(.addEventListener js/window "popstate" (fn [_] (reset! navigation-source :pop)))
(.addEventListener js/window "popstate" (fn [_]
(reset! navigation-source :pop)
(dbg :nav/popstate {:source :popstate})))
(defn current-stack
[]
@@ -25,6 +48,7 @@
(defn set-current-stack!
[stack]
(when (some? stack)
(dbg :nav/set-current-stack {:from @active-stack :to stack})
(reset! active-stack stack)))
(defn- strip-fragment [href]
@@ -38,10 +62,6 @@
(let [p (strip-fragment (.-hash js/location))]
(if (string/blank? p) "/" p)))
(defn- virtual-path?
[path]
(and (string? path) (string/starts-with? path "/__stack__/")))
(defn- stack-defaults
[stack]
(let [name (keyword stack)
@@ -57,6 +77,7 @@
(defn- record-navigation-intent!
[{:keys [type stack]}]
(let [stack (or stack @active-stack primary-stack)]
(dbg :nav/record-intent {:type type :stack stack})
(reset! pending-navigation {:type type
:stack stack})))
@@ -70,6 +91,7 @@
([k params query]
(record-navigation-intent! {:type :push
:stack @active-stack})
(dbg :nav/push-state {:name k :params params :query query :stack @active-stack})
(orig-push-state k params query)))
(defonce orig-replace-state rfe/replace-state)
@@ -81,6 +103,7 @@
([k params query]
(record-navigation-intent! {:type :replace
:stack @active-stack})
(dbg :nav/replace-state {:name k :params params :query query :stack @active-stack})
(orig-replace-state k params query)))
(defn install-navigation-hooks!
@@ -88,6 +111,7 @@
Also tags navigation with the active stack so native can keep per-stack history."
[]
(when (compare-and-set! hooks-installed? false true)
(dbg :nav/hooks-installed {})
(set! rfe/push-state push-state)
(set! rfe/replace-state replace-state)))
@@ -108,6 +132,14 @@
[stack]
(-> @stack-history (get stack) :history last))
;; --- DEBUG: watch stack-history changes ---
(add-watch stack-history ::stack-history-debug
(fn [_ _ old new]
(when debug-nav?
(dbg :nav/stack-history
:old (into {} (for [[k v] old] [k (mapv :path (:history v))]))
:new (into {} (for [[k v] new] [k (mapv :path (:history v))]))))))
(defn- remember-route!
[stack nav-type route path route-match]
(when stack
@@ -128,6 +160,12 @@
(conj history entry))
history)))]
(when entry
(dbg :nav/remember-route
:stack stack
:nav-type nav-type
:path path
:route-to (or (get-in route [:to])
(get-in route-match [:data :name])))
(swap! stack-history update stack (fn [{:keys [history] :as st}]
{:history (update-history history)}))
(swap! initialised-stacks assoc stack true)))))
@@ -135,6 +173,7 @@
(defn reset-stack-history!
[stack]
(when stack
(dbg :nav/reset-stack-history {:stack stack})
(swap! stack-history assoc stack {:history [(stack-defaults stack)]})
(swap! initialised-stacks dissoc stack)))
@@ -154,6 +193,12 @@
(true? push) "push"
:else "push"))]
(reset! navigation-source nil)
(dbg :nav/next-navigation
:src src
:intent intent
:stack stack
:first? first?
:nav-type nav-type)
(when first?
(swap! initialised-stacks assoc stack true))
{:navigation-type nav-type
@@ -162,6 +207,7 @@
(defn- notify-route-payload!
[payload]
(dbg :nav/notify-native payload)
(-> (.routeDidChange mobile-util/ui-local (clj->js payload))
(p/catch (fn [err]
(log/warn :mobile-native-navigation/route-report-failed
@@ -169,17 +215,19 @@
:payload payload})))))
(defn notify-route-change!
"Inform native iOS layer of a route change to keep native stack in sync.
{route {to keyword, path-params map, query-params map}
route-match map ;; optional full route match for fast restoration
path string ;; optional, e.g. \"/page/Today\"
push boolean? ;; optional, explicit push vs replace hint}"
"Inform native iOS layer of a route change to keep native stack in sync."
[{:keys [route route-match path push stack]}]
(let [{:keys [navigation-type push? stack]} (next-navigation! {:push push
:nav-type (:navigation-type route-match)
:stack (or stack (current-stack))})
stack (or stack (current-stack))
path (or path (current-path))]
(dbg :nav/notify-route-change
:stack stack
:navigation-type navigation-type
:path path
:route-to (or (:to route)
(get-in route-match [:data :name])))
(set-current-stack! stack)
(remember-route! stack navigation-type route path route-match)
(when (and (mobile-util/native-ios?)
@@ -191,27 +239,86 @@
path (assoc :path (strip-fragment path)))]
(notify-route-payload! payload)))))
(defn reset-route!
[]
(route-handler/redirect-to-home! false)
(let [stack (current-stack)]
(reset-stack-history! stack)
(notify-route-payload!
{:navigationType "reset"
:push false
:stack stack})))
(comment
(defn reset-route!
[]
(route-handler/redirect-to-home! false)
(let [stack (current-stack)]
(reset-stack-history! stack)
(notify-route-payload!
{:navigationType "reset"
:push false
:stack stack}))))
(defn switch-stack!
"Activate a stack and restore its last known route if different from current location."
"Activate a stack and restore its last known route."
[stack]
(when stack
(let [stack (ensure-stack stack)]
(dbg :nav/switch-stack {:to stack
:current @active-stack
:stack-top (select-keys (stack-top stack) [:path])})
(set-current-stack! stack)
(when-let [{:keys [path route route-match]} (stack-top stack)]
(let [route-match (or route-match (:route-match (stack-defaults stack)))
path (or path (current-path))]
;; Update local route state immediately for UI (header, page context) without full router churn.
path (or path (current-path))]
(dbg :nav/switch-stack-apply
:stack stack
:path path
:route-name (or (get-in route [:data :name])
(get-in route-match [:data :name])))
(route-handler/set-route-match! route-match)
;; Avoid triggering native navigation on stack switches; we rely on per-stack
;; history and UI updates handled in JS for snappy tab changes.
)))))
(notify-route-change!
{:route {:to (or (get-in route [:data :name])
(get-in route-match [:data :name]))
:path-params (or (:path-params route)
(get-in route-match [:parameters :path]))
:query-params (or (:query-params route)
(get-in route-match [:parameters :query]))}
:path path
:stack stack
:push false}))))))
(defn pop-stack!
"Pop one route from the current stack, update router via replace-state.
Called when native UINavigationController pops (back gesture / back button)."
[]
(let [stack (current-stack)
{:keys [history]} (get @stack-history stack)
history (vec history)]
(if (<= (count history) 1)
(dbg :nav/pop-stack-root {:stack stack
:history (mapv :path history)})
(let [new-history (subvec history 0 (dec (count history)))
{:keys [route route-match path]} (peek new-history)
route-match (or route-match (:route-match (stack-defaults stack)))
route-name (get-in route-match [:data :name])
path-params (get-in route-match [:parameters :path])
query-params (get-in route-match [:parameters :query])]
(dbg :nav/pop-stack
:stack stack
:old-history (mapv :path history)
:new-history (mapv :path new-history)
:target-path path
:route-name route-name)
(swap! stack-history assoc stack {:history new-history})
;; Pretend this came from a pop for next-navigation!
(reset! navigation-source :pop)
;; Use *original* replace-state to avoid recording a :replace intent.
(orig-replace-state route-name path-params query-params)
(route-handler/set-route-match! route-match)))))
(defn ^:export install-native-bridge!
[]
(dbg :nav/install-native-bridge {})
(set! (.-LogseqNative js/window)
(clj->js
{:onNativePop (fn []
(dbg :nav/on-native-pop {:stack (current-stack)
:path (current-path)})
(pop-stack!))})))