Skip to content

Android predictive back “peek” for Threshold #12

@ScottMorris

Description

@ScottMorris

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:

  1. get back-gesture progress from Android (native callback), and
  2. keep something meaningful “behind” the current page (either the real previous page still mounted, or a snapshot), then
  3. 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:

  1. Register an Android back animation callback on API 34+.

  2. Emit events into the webview:

    • backStarted
    • backProgressed (includes progress: 0..1)
    • backCancelled
    • backInvoked
  3. 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)

  • Add Rust plugin skeleton mirroring alarm-manager.

  • Add Kotlin plugin:

    • On API 34+, register a back animation callback.
    • Emit events (started, progress, cancelled, invoked).
    • Add setCanGoBack command.

Phase 2: Web controller + UI hook

  • Implement PredictiveBackController.ts:

    • subscribe/unsubscribe
    • store state
    • expose setCanGoBack
  • Add RouteStage.tsx:

    • wraps current <Outlet /> in a scrub-able layer
    • Tier 1 underlay

Phase 3: Integrate with existing router layout

  • Update apps/window-alarm/src/router.tsx RootLayout:

    • replace the current wrapper div with <RouteStage><Outlet/></RouteStage>

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

  1. Do we want this only on Android 14+ devices, or also approximate it on older Android via in-app swipe handling?

  2. For /ringing/:id: should back gesture be disabled entirely to avoid accidental dismiss?

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions