Skip to content

Commit cbb7556

Browse files
committed
enhance: mobile multi navigation stacks
1 parent 4b1d51a commit cbb7556

5 files changed

Lines changed: 331 additions & 66 deletions

File tree

ios/App/App/AppDelegate.swift

Lines changed: 172 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
88

99
var window: UIWindow?
1010
var navController: UINavigationController?
11+
12+
// ---------------------------------------------------------
13+
// MARK: Multi-stack routing state
14+
// ---------------------------------------------------------
15+
16+
/// Currently active logical stack id (must match CLJS :stack, e.g. "home", "capture", "goto").
17+
private var activeStackId: String = "home"
18+
19+
/// Per-stack path stacks, including the active one.
20+
/// Example: ["home": ["/", "/page/A"], "capture": ["/__stack__/capture"]]
21+
private var stackPathStacks: [String: [String]] = [
22+
"home": ["/"]
23+
]
24+
25+
/// Mirror of the active stack's paths.
1126
private var pathStack: [String] = ["/"]
12-
private var ignoreRoutePopCount = 0
27+
28+
/// Used to ignore JS-driven pops when we're popping in response to a native gesture.
29+
private var ignoreRoutePopCount: Int = 0
30+
31+
/// Temporary snapshot image for smooth pop transitions.
1332
private var popSnapshotView: UIView?
1433

34+
// ---------------------------------------------------------
35+
// MARK: Helpers
36+
// ---------------------------------------------------------
37+
1538
private func normalizedPath(_ raw: String?) -> String {
1639
guard let raw = raw, !raw.isEmpty else { return "/" }
1740
return raw
1841
}
1942

43+
private func debugLogStacks(_ label: String) {
44+
#if DEBUG
45+
print("🧭 [\(label)] activeStackId=\(activeStackId)")
46+
print(" pathStack=\(pathStack)")
47+
print(" stackPathStacks=\(stackPathStacks)")
48+
#endif
49+
}
50+
51+
/// Returns the current native path stack for a given logical stack id,
52+
/// or initialises a sensible default if none exists yet.
53+
private func paths(for stackId: String) -> [String] {
54+
if let existing = stackPathStacks[stackId], !existing.isEmpty {
55+
return existing
56+
}
57+
58+
if stackId == "home" {
59+
return ["/"]
60+
} else {
61+
// Virtual stacks (e.g. capture, search, goto) default to a stack-root path.
62+
return ["/__stack__/\(stackId)"]
63+
}
64+
}
65+
66+
/// Updates the stored paths for a given stack id and keeps `pathStack`
67+
/// consistent if this is the active stack.
68+
private func setPaths(_ paths: [String], for stackId: String) {
69+
stackPathStacks[stackId] = paths
70+
if stackId == activeStackId {
71+
pathStack = paths
72+
}
73+
}
74+
2075
// ---------------------------------------------------------
2176
// MARK: UIApplication lifecycle
2277
// ---------------------------------------------------------
@@ -124,7 +179,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
124179
}
125180

126181
// ---------------------------------------------------------
127-
// MARK: Navigation operations
182+
// MARK: Navigation operations (within active stack)
128183
// ---------------------------------------------------------
129184

130185
private func emptyNavStack(path: String) {
@@ -137,10 +192,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
137192

138193
let vc = NativePageViewController(path: path, push: false)
139194
pathStack = [path]
195+
setPaths(pathStack, for: activeStackId)
140196

141197
nav.setViewControllers([vc], animated: false)
142198
SharedWebViewController.instance.clearPlaceholder()
143199
SharedWebViewController.instance.attach(to: vc)
200+
201+
debugLogStacks("emptyNavStack")
144202
}
145203

146204
private func pushIfNeeded(path: String, animated: Bool) {
@@ -154,32 +212,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
154212

155213
let vc = NativePageViewController(path: path, push: true)
156214
pathStack.append(path)
215+
setPaths(pathStack, for: activeStackId)
216+
157217
nav.pushViewController(vc, animated: animated)
218+
219+
debugLogStacks("pushIfNeeded")
158220
}
159221

160222
private func replaceTop(path: String) {
161223
let path = normalizedPath(path)
162224
guard let nav = navController else { return }
163225

164226
_ = pathStack.popLast()
165-
let vc = NativePageViewController(path: path, push: false)
166227
pathStack.append(path)
228+
setPaths(pathStack, for: activeStackId)
167229

230+
let vc = NativePageViewController(path: path, push: false)
168231
var stack = nav.viewControllers
169232
if stack.isEmpty {
170233
stack = [vc]
171234
} else {
172235
stack[stack.count - 1] = vc
173236
}
174237
nav.setViewControllers(stack, animated: false)
238+
239+
debugLogStacks("replaceTop")
175240
}
176241

177242
private func popIfNeeded(animated: Bool) {
178243
guard let nav = navController else { return }
179244

180245
if nav.viewControllers.count > 1 {
181246
_ = pathStack.popLast()
247+
setPaths(pathStack, for: activeStackId)
182248
nav.popViewController(animated: animated)
249+
250+
debugLogStacks("popIfNeeded")
183251
}
184252
}
185253

@@ -206,15 +274,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
206274
vcs.count < pathStack.count
207275
}
208276

277+
#if DEBUG
278+
print("🧭 willShow — isPop=\(isPop)")
279+
print(" toVC=\(toVC.targetPath) fromVC=\(String(describing: fromVC?.targetPath))")
280+
debugLogStacks("willShow")
281+
#endif
282+
209283
if isPop {
210284
// -----------------------------
211-
// POP — keep your existing logic
285+
// POP — update per-stack pathStack, then notify JS.
212286
// -----------------------------
213287
let previousStack = pathStack
214-
if pathStack.count > 1 { _ = pathStack.popLast() }
288+
289+
if pathStack.count > 1 {
290+
_ = pathStack.popLast()
291+
}
215292
if let last = pathStack.last, last != toVC.targetPath {
216293
pathStack[pathStack.count - 1] = toVC.targetPath
217294
}
295+
setPaths(pathStack, for: activeStackId)
218296

219297
popSnapshotView?.removeFromSuperview()
220298
popSnapshotView = nil
@@ -227,22 +305,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
227305
popSnapshotView = iv
228306
}
229307

230-
coordinator.animate(alongsideTransition: nil) { ctx in
308+
coordinator.animate(alongsideTransition: nil) { [weak self] ctx in
309+
guard let self else { return }
310+
231311
guard !ctx.isCancelled else {
232312
self.pathStack = previousStack
313+
self.setPaths(previousStack, for: self.activeStackId)
314+
233315
if let fromVC {
234316
SharedWebViewController.instance.attach(to: fromVC)
235317
}
236318
SharedWebViewController.instance.clearPlaceholder()
237319
return
238320
}
239321

240-
if let webView = SharedWebViewController.instance.bridgeController.bridge?.webView,
241-
webView.canGoBack {
242-
self.ignoreRoutePopCount += 1
243-
webView.goBack()
244-
} else {
245-
self.ignoreRoutePopCount += 1
322+
// 🔑 DO NOT call webView.goBack().
323+
// Tell JS explicitly that native popped.
324+
self.ignoreRoutePopCount += 1
325+
#if DEBUG
326+
print("⬅️ Native POP completed, notifying JS via onNativePop(), ignoreRoutePopCount=\(self.ignoreRoutePopCount)")
327+
debugLogStacks("after native-pop pathStack update")
328+
#endif
329+
330+
if let bridge = SharedWebViewController.instance.bridgeController.bridge {
331+
let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();"
332+
bridge.webView?.evaluateJavaScript(js, completionHandler: nil)
246333
}
247334

248335
SharedWebViewController.instance.attach(
@@ -264,19 +351,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
264351
// -----------------------------
265352
// PUSH / RESET
266353
// -----------------------------
267-
// Attach the shared webview to the *destination* page
268-
// before/during the animation so it can start rendering immediately.
269354
SharedWebViewController.instance.attach(
270355
to: toVC,
271356
leavePlaceholderInPreviousParent: fromVC != nil
272357
)
273358

274359
coordinator.animate(alongsideTransition: nil) { ctx in
275360
if ctx.isCancelled, let fromVC {
276-
// If the push is cancelled (interactive back), put the webview back.
277361
SharedWebViewController.instance.attach(to: fromVC)
278362
} else {
279-
// Transition completed → clear any placeholders.
280363
SharedWebViewController.instance.clearPlaceholder()
281364
}
282365
}
@@ -294,7 +377,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
294377
SharedWebViewController.instance.clearPlaceholder()
295378
SharedWebViewController.instance.attach(to: current)
296379
}
297-
298380
}
299381

300382
func navigationController(
@@ -308,7 +390,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
308390
}
309391

310392
// ---------------------------------------------------------
311-
// MARK: Route Observation
393+
// MARK: Route Observation (JS -> Native)
312394
// ---------------------------------------------------------
313395

314396
private func observeRouteChanges() {
@@ -318,10 +400,69 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
318400
queue: .main
319401
) { [weak self] notification in
320402
guard let self else { return }
403+
guard let nav = self.navController else { return }
321404

322-
let path = self.normalizedPath(notification.userInfo?["path"] as? String)
405+
let rawPath = notification.userInfo?["path"] as? String
406+
let path = self.normalizedPath(rawPath)
323407
let navigationType = (notification.userInfo?["navigationType"] as? String) ?? "push"
408+
let stackId = (notification.userInfo?["stack"] as? String) ?? "home"
409+
410+
#if DEBUG
411+
print("📡 routeDidChange from JS → native")
412+
print(" stackId=\(stackId) navigationType=\(navigationType) path=\(path)")
413+
debugLogStacks("before observeRouteChanges")
414+
#endif
415+
416+
// ============================================
417+
// 1️⃣ Stack switch: home ↔ capture ↔ goto ...
418+
// ============================================
419+
if stackId != self.activeStackId {
420+
// Save current native stack paths
421+
self.setPaths(self.pathStack, for: self.activeStackId)
422+
423+
// Load (or create) new stack's paths
424+
var newPaths = self.paths(for: stackId)
425+
426+
// Ensure the top of the stack matches the path sent by JS
427+
if let last = newPaths.last, last != path {
428+
if newPaths.isEmpty {
429+
newPaths = [path]
430+
} else {
431+
newPaths[newPaths.count - 1] = path
432+
}
433+
}
434+
435+
self.activeStackId = stackId
436+
self.pathStack = newPaths
437+
self.setPaths(newPaths, for: stackId)
324438

439+
// Rebuild the UINavigationController's stack from these paths
440+
var vcs: [UIViewController] = []
441+
for (idx, p) in newPaths.enumerated() {
442+
let vc = NativePageViewController(path: p, push: idx > 0)
443+
vcs.append(vc)
444+
}
445+
446+
nav.setViewControllers(vcs, animated: false)
447+
448+
if let lastVC = vcs.last as? NativePageViewController {
449+
SharedWebViewController.instance.attach(to: lastVC)
450+
SharedWebViewController.instance.clearPlaceholder()
451+
}
452+
453+
#if DEBUG
454+
print("🔀 STACK SWITCH to \(stackId)")
455+
debugLogStacks("after stack switch")
456+
#endif
457+
458+
// For stacks like "capture", default paths are ["__/stack__/capture"],
459+
// so they get a single VC and no back button.
460+
return
461+
}
462+
463+
// ============================================
464+
// 2️⃣ Navigation *within* the active stack
465+
// ============================================
325466
switch navigationType {
326467
case "reset":
327468
self.emptyNavStack(path: path)
@@ -332,16 +473,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
332473
case "pop":
333474
if self.ignoreRoutePopCount > 0 {
334475
self.ignoreRoutePopCount -= 1
476+
#if DEBUG
477+
print("🙈 ignoring JS pop (ignoreRoutePopCount→\(self.ignoreRoutePopCount))")
478+
debugLogStacks("after ignore JS pop")
479+
#endif
335480
return
336481
}
337-
338482
if self.pathStack.count > 1 {
339483
self.popIfNeeded(animated: true)
340484
}
341485

342486
default:
343487
self.pushIfNeeded(path: path, animated: true)
344488
}
489+
490+
#if DEBUG
491+
debugLogStacks("after observeRouteChanges switch")
492+
#endif
345493
}
346494
}
347495
}
@@ -372,6 +520,10 @@ extension NSUserActivity {
372520
}
373521
}
374522

523+
// ---------------------------------------------------------
524+
// MARK: Convenience
525+
// ---------------------------------------------------------
526+
375527
extension AppDelegate {
376528
func donateQuickAddShortcut() {
377529
let a = NSUserActivity.quickAdd

ios/App/App/UILocalPlugin.swift

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -644,26 +644,30 @@ private func scoreTranscript(_ text: String, locale: Locale) -> Int {
644644
}
645645

646646
@objc func routeDidChange(_ call: CAPPluginCall) {
647-
let route = call.getObject("route") as? [String: Any]
648-
let path = call.getString("path")
649-
let push = call.getBool("push") ?? true
650-
let navigationType = call.getString("navigationType") ?? (push ? "push" : "replace")
651-
652-
var entry: [String: Any] = [:]
653-
if let path = path {
654-
entry["path"] = path
655-
}
656-
if let route = route {
657-
entry["route"] = route
658-
}
659-
entry["push"] = push
660-
entry["navigationType"] = navigationType
647+
let navigationType = call.getString("navigationType") ?? "push"
648+
let push = call.getBool("push") ?? (navigationType == "push")
649+
let path = call.getString("path") ?? "/"
661650

662-
NotificationCenter.default.post(
663-
name: UILocalPlugin.routeChangeNotification,
664-
object: nil,
665-
userInfo: entry
666-
)
651+
// ✅ read stack from JS, default to "home" only if missing
652+
let stack = call.getString("stack") ?? "home"
653+
654+
#if DEBUG
655+
print("📬 UILocal.routeDidChange call from JS")
656+
print(" navigationType=\(navigationType) push=\(push) stack=\(stack) path=\(path)")
657+
#endif
658+
659+
DispatchQueue.main.async {
660+
NotificationCenter.default.post(
661+
name: UILocalPlugin.routeChangeNotification,
662+
object: nil,
663+
userInfo: [
664+
"navigationType": navigationType,
665+
"push": push,
666+
"stack": stack, // 👈 forward it
667+
"path": path
668+
]
669+
)
670+
}
667671

668672
call.resolve()
669673
}

0 commit comments

Comments
 (0)