Design: Android predictive back “peek” for Threshold
This document outlines what it would take to approximate Android’s predictive back gesture (interactive “peek” while swiping back) in Threshold’s Tauri v2 + React app.
This is intentionally scoped as a design + implementation plan for an agent.
Why this is hard in a webview
Android’s predictive back is interactive: the system sends a continuous progress value (0→1) while the user drags, and your UI is expected to scrub an animation in real time. Standard web navigation animations (including the View Transition API) are not scrub-able.
So the only way to do a true “peek” in the web layer is:
- get back-gesture progress from Android (native callback), and
- keep something meaningful “behind” the current page (either the real previous page still mounted, or a snapshot), then
- translate/transform the top page in real time.
Target UX
On Android 14+ (API 34+):
- Edge-swipe back should “pull” the current page to the right as you drag.
- The previous page should be visible underneath (or a snapshot if we can’t keep it mounted).
- If the user cancels (swipes back but releases below threshold), the page snaps back smoothly.
- If the user commits, we finish the animation and then perform the actual navigation back.
- Motion should feel calm: subtle shadow, minimal parallax, no bouncy overshoot.
On Android < 14:
- Keep current discrete back handling (existing
onBackButtonPress behaviour).
On desktop / iOS:
- No changes required for this feature.
Current code touchpoints (from repo)
-
apps/window-alarm/src/App.tsx
- already registers Android back with
@tauri-apps/api/app onBackButtonPress() and calls router.history.back().
-
apps/window-alarm/src/router.tsx
- root layout renders
<Outlet /> inside a wrapper div.
-
plugins/alarm-manager/android/...
- demonstrates that custom Android plugins are already in place (Tauri v2 plugin pattern).
Proposed architecture
A) New Tauri Android plugin: predictive-back
Create a new plugin (parallel to plugins/alarm-manager) that exposes Android predictive back progress to the webview.
Location:
plugins/predictive-back/ (Rust)
plugins/predictive-back/android/ (Kotlin)
Android requirements:
- Android 14+ (API 34) provides progress callbacks.
- The plugin should gracefully no-op on older Android versions.
Plugin responsibilities:
-
Register an Android back animation callback on API 34+.
-
Emit events into the webview:
backStarted
backProgressed (includes progress: 0..1)
backCancelled
backInvoked
-
Allow the web layer to tell native whether there is back history, so Android can decide whether to show an “exit” predictive animation vs an “in-app” one.
Suggested plugin API surface (JS/Rust commands):
setEnabled({ enabled: boolean })
setCanGoBack({ canGoBack: boolean })
setConfig({ edge: 'left' | 'system', minFlingVelocity?: number }) (optional)
Event payload shape (into JS):
type PredictiveBackEvent =
| { type: 'started'; progress: 0; edge: 'left' | 'right' }
| { type: 'progress'; progress: number; edge: 'left' | 'right' }
| { type: 'cancelled' }
| { type: 'invoked' }
Emitting events into JS
Because we don’t currently have an in-repo example of Kotlin → JS event emission, we propose two implementation options:
Option 1 (simple, reliable): webview.evaluateJavascript()
- In the plugin, dispatch a
CustomEvent on window.
- JS listens with
window.addEventListener('threshold:predictive-back', ...).
Kotlin sketch:
private fun emit(eventJson: String) {
activity.runOnUiThread {
val js = "window.dispatchEvent(new CustomEvent('threshold:predictive-back', { detail: $eventJson }));"
webview.evaluateJavascript(js, null)
}
}
Option 2 (preferred if available): use Tauri’s plugin event emitter
- If the Android plugin base exposes an event emitter (for example a
trigger/emit helper), use it.
- The agent should check Tauri’s Android plugin APIs before choosing.
B) Web-side “predictive back controller”
Create a small controller module that:
-
subscribes to predictive back events
-
manages a shared state store: { active, progress, edge, phase }
-
exposes imperative helpers:
setCanGoBack(bool)
setNextBackOverride('gesture' | 'button') (optional)
Proposed file:
apps/window-alarm/src/utils/PredictiveBackController.ts
C) UI: a dedicated “route slot” that can be scrubbed
To scrub the animation, we need an element that represents “the current page” and can be translated in X in real time.
We already have a natural place:
- the wrapper div in
RootLayout that currently contains <Outlet />.
We’ll convert it into:
- an outer container (
routeStage) that holds layers
- an inner top layer (
routeTop) that moves with gesture progress
New component:
apps/window-alarm/src/components/RouteStage.tsx
It will look like:
<div className="wa-route-stage">
<div className="wa-route-underlay">
{/* previous page layer or snapshot */}
</div>
<div className="wa-route-top" style={{ transform: `translateX(${progressPx}px)` }}>
<Outlet />
</div>
</div>
Where:
progressPx = clamp(progress, 0, 1) * stageWidthPx
D) The big decision: what’s in the underlay?
This determines how “real” the peek feels.
Tier 1 (recommended first): underlay is a “calm surface” + subtle shadow
Underlay contains:
- app background colour / texture (Material You-ish)
- optional blurred screenshot of previous page if we can capture one cheaply
Pros:
- Fast to implement.
- Doesn’t require keeping multiple routes mounted.
- Still gives an interactive “pull” feel.
Cons:
- Not a true preview of the previous page content.
Tier 2 (true peek): underlay renders the actual previous route
This requires a navigation stack that keeps the previous screen mounted behind the current one.
Because TanStack Router’s <Outlet /> only renders the current match, we’d need a small “stack outlet” implementation.
Approach:
- Maintain an in-memory stack of locations (path + params).
- Render the top two stack entries as separate layers.
- The underlay is the previous entry’s component tree.
There are two implementation strategies:
Strategy 2A (pragmatic, app-specific): manual screen registry
- Since Threshold has a small set of screens (
Home, EditAlarm, Settings, Ringing), build a registry that can render a screen from a location.
- Parse the pathname to determine which screen and params to render.
Pros:
- Straightforward.
- Similar to what Ionic’s outlet effectively does.
Cons:
- More custom code.
- Needs upkeep if routes grow.
Strategy 2B (router-driven): attempt to render a second match tree
- Investigate whether TanStack Router can render matches for an arbitrary location in parallel.
- If feasible, use route tree metadata and
router.matchRoutes() or similar APIs.
Pros:
- Less custom route parsing.
Cons:
- May not be supported cleanly.
- Higher investigation risk.
For an agent: implement Tier 1 first, then optionally Tier 2A.
Interaction model
State machine
idle
dragging (started + progress updates)
cancelling (animate back to 0)
committing (animate to 1, then navigate back)
Event handling
On Android API 34+:
started → enter dragging, freeze “underlay” source (snapshot or previous stack entry)
progress → update progress state, apply translateX
cancelled → animate progress → 0, exit to idle
invoked → animate progress → 1, then call router.history.back() and pop stack
Important: during dragging, do NOT call router navigation repeatedly. We only navigate once, on commit.
Avoiding conflicts with existing back handler
We currently handle discrete back via onBackButtonPress().
Rules:
- If a predictive gesture is active (
dragging), ignore the discrete back handler.
- If predictive API is available and enabled, discrete back should still work (treat as instant commit with
progress=1).
Styling / motion
Create a CSS module:
apps/window-alarm/src/theme/predictiveBack.css
Recommended visuals:
- A soft shadow on the top layer while dragging.
- A subtle edge scrim on the underlay.
- Optional: underlay scales from 0.98 → 1.0 as progress increases.
Avoid:
- overshoot / bounce
- strong blur
- long durations
Durations:
- cancel snap-back: ~160–200ms
- commit finish: ~140–180ms
Respect reduced motion:
- If
prefers-reduced-motion: reduce, disable scrubbing and fall back to discrete back navigation.
Implementation plan (agent checklist)
Phase 0: Investigation
- Confirm Android API level target and minSdk in
apps/window-alarm/src-tauri/gen/android.
- Verify how to emit events from Kotlin plugin to the webview (choose evaluateJavascript vs built-in emitter).
Phase 1: Create predictive-back plugin (Android only)
Phase 2: Web controller + UI hook
Phase 3: Integrate with existing router layout
Phase 4: Connect router history state
- Maintain
canGoBack based on a simple stack (or window.history.length > 1).
- Feed
setCanGoBack() to native plugin.
Phase 5: Optional Tier 2 (true peek)
- Implement manual route registry renderer for the previous route underlay.
- Keep only top 2 layers mounted.
Open questions / decisions
-
Do we want this only on Android 14+ devices, or also approximate it on older Android via in-app swipe handling?
-
For /ringing/:id: should back gesture be disabled entirely to avoid accidental dismiss?
-
Underlay:
- Tier 1 (calm surface) is simplest.
- Tier 2 (true previous route) is the “real” peek.
Deliverables
- New plugin:
plugins/predictive-back/*
- Web controller:
apps/window-alarm/src/utils/PredictiveBackController.ts
- UI wrapper:
apps/window-alarm/src/components/RouteStage.tsx
- Styles:
apps/window-alarm/src/theme/predictiveBack.css
- Root integration:
apps/window-alarm/src/router.tsx updated
Design: Android predictive back “peek” for Threshold
This document outlines what it would take to approximate Android’s predictive back gesture (interactive “peek” while swiping back) in Threshold’s Tauri v2 + React app.
This is intentionally scoped as a design + implementation plan for an agent.
Why this is hard in a webview
Android’s predictive back is interactive: the system sends a continuous progress value (0→1) while the user drags, and your UI is expected to scrub an animation in real time. Standard web navigation animations (including the View Transition API) are not scrub-able.
So the only way to do a true “peek” in the web layer is:
Target UX
On Android 14+ (API 34+):
On Android < 14:
onBackButtonPressbehaviour).On desktop / iOS:
Current code touchpoints (from repo)
apps/window-alarm/src/App.tsx@tauri-apps/api/apponBackButtonPress()and callsrouter.history.back().apps/window-alarm/src/router.tsx<Outlet />inside a wrapper div.plugins/alarm-manager/android/...Proposed architecture
A) New Tauri Android plugin:
predictive-backCreate a new plugin (parallel to
plugins/alarm-manager) that exposes Android predictive back progress to the webview.Location:
plugins/predictive-back/(Rust)plugins/predictive-back/android/(Kotlin)Android requirements:
Plugin responsibilities:
Register an Android back animation callback on API 34+.
Emit events into the webview:
backStartedbackProgressed(includesprogress: 0..1)backCancelledbackInvokedAllow the web layer to tell native whether there is back history, so Android can decide whether to show an “exit” predictive animation vs an “in-app” one.
Suggested plugin API surface (JS/Rust commands):
setEnabled({ enabled: boolean })setCanGoBack({ canGoBack: boolean })setConfig({ edge: 'left' | 'system', minFlingVelocity?: number })(optional)Event payload shape (into JS):
Emitting events into JS
Because we don’t currently have an in-repo example of Kotlin → JS event emission, we propose two implementation options:
Option 1 (simple, reliable):
webview.evaluateJavascript()CustomEventonwindow.window.addEventListener('threshold:predictive-back', ...).Kotlin sketch:
Option 2 (preferred if available): use Tauri’s plugin event emitter
trigger/emithelper), use it.B) Web-side “predictive back controller”
Create a small controller module that:
subscribes to predictive back events
manages a shared state store:
{ active, progress, edge, phase }exposes imperative helpers:
setCanGoBack(bool)setNextBackOverride('gesture' | 'button')(optional)Proposed file:
apps/window-alarm/src/utils/PredictiveBackController.tsC) UI: a dedicated “route slot” that can be scrubbed
To scrub the animation, we need an element that represents “the current page” and can be translated in X in real time.
We already have a natural place:
RootLayoutthat currently contains<Outlet />.We’ll convert it into:
routeStage) that holds layersrouteTop) that moves with gesture progressNew component:
apps/window-alarm/src/components/RouteStage.tsxIt will look like:
Where:
progressPx = clamp(progress, 0, 1) * stageWidthPxD) The big decision: what’s in the underlay?
This determines how “real” the peek feels.
Tier 1 (recommended first): underlay is a “calm surface” + subtle shadow
Underlay contains:
Pros:
Cons:
Tier 2 (true peek): underlay renders the actual previous route
This requires a navigation stack that keeps the previous screen mounted behind the current one.
Because TanStack Router’s
<Outlet />only renders the current match, we’d need a small “stack outlet” implementation.Approach:
There are two implementation strategies:
Strategy 2A (pragmatic, app-specific): manual screen registry
Home,EditAlarm,Settings,Ringing), build a registry that can render a screen from a location.Pros:
Cons:
Strategy 2B (router-driven): attempt to render a second match tree
router.matchRoutes()or similar APIs.Pros:
Cons:
For an agent: implement Tier 1 first, then optionally Tier 2A.
Interaction model
State machine
idledragging(started + progress updates)cancelling(animate back to 0)committing(animate to 1, then navigate back)Event handling
On Android API 34+:
started→ enterdragging, freeze “underlay” source (snapshot or previous stack entry)progress→ updateprogressstate, applytranslateXcancelled→ animate progress → 0, exit toidleinvoked→ animate progress → 1, then callrouter.history.back()and pop stackImportant: during
dragging, do NOT call router navigation repeatedly. We only navigate once, on commit.Avoiding conflicts with existing back handler
We currently handle discrete back via
onBackButtonPress().Rules:
dragging), ignore the discrete back handler.progress=1).Styling / motion
Create a CSS module:
apps/window-alarm/src/theme/predictiveBack.cssRecommended visuals:
Avoid:
Durations:
Respect reduced motion:
prefers-reduced-motion: reduce, disable scrubbing and fall back to discrete back navigation.Implementation plan (agent checklist)
Phase 0: Investigation
apps/window-alarm/src-tauri/gen/android.Phase 1: Create
predictive-backplugin (Android only)Add Rust plugin skeleton mirroring
alarm-manager.Add Kotlin plugin:
started,progress,cancelled,invoked).setCanGoBackcommand.Phase 2: Web controller + UI hook
Implement
PredictiveBackController.ts:setCanGoBackAdd
RouteStage.tsx:<Outlet />in a scrub-able layerPhase 3: Integrate with existing router layout
Update
apps/window-alarm/src/router.tsxRootLayout:<RouteStage><Outlet/></RouteStage>Phase 4: Connect router history state
canGoBackbased on a simple stack (orwindow.history.length > 1).setCanGoBack()to native plugin.Phase 5: Optional Tier 2 (true peek)
Open questions / decisions
Do we want this only on Android 14+ devices, or also approximate it on older Android via in-app swipe handling?
For
/ringing/:id: should back gesture be disabled entirely to avoid accidental dismiss?Underlay:
Deliverables
plugins/predictive-back/*apps/window-alarm/src/utils/PredictiveBackController.tsapps/window-alarm/src/components/RouteStage.tsxapps/window-alarm/src/theme/predictiveBack.cssapps/window-alarm/src/router.tsxupdated