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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions