Fix animation stale reference
This commit is contained in:
@@ -39,7 +39,7 @@ Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Op
|
||||
|
||||
Run the normal `npm run dev` command, expose its printed network address through an HTTPS URL, and open that URL on the iPhone. The development server includes the PWA service worker and already listens on the local network. Safari still requires a secure context for the service worker; a plain LAN `http://` address is not sufficient. Choose **Share → Add to Home Screen**, then launch **NVR Messenger** from its Home Screen icon.
|
||||
|
||||
The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view.
|
||||
The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active, and shows the exact build ID plus update-check count. Production builds check for updates whenever the app starts, returns to the foreground, regains connectivity, or has been open for a minute; activation reload waits for any live gesture to finish. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view.
|
||||
|
||||
An installed web app cannot access `WKWebView.allowsBackForwardNavigationGestures`. The demo therefore reserves leading-edge touch sequences at the web-content boundary as an iOS standalone-only safeguard. Capacitor remains the deterministic option when native-level gesture suppression is required.
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ async function waitForTransition(page: Page) {
|
||||
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
|
||||
}
|
||||
|
||||
async function flickToNextTab(page: Page) {
|
||||
// Start on the route header, outside conversation-owned drag targets.
|
||||
const frame = await page.locator('[data-native-role="active"] .app-header, [data-native-role="to"] .app-header').last().boundingBox()
|
||||
if (!frame) throw new Error('Active route header did not render')
|
||||
const y = frame.y + frame.height * 0.5
|
||||
await page.mouse.move(frame.x + frame.width * 0.68, y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(frame.x + frame.width * 0.48, y)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test('navigates a conversation and returns through the native runtime', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
||||
@@ -131,6 +142,26 @@ test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
test('accepts a second fast tab flick while the first spring is still settling', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
const routerView = page.locator('.nvr-router-view')
|
||||
|
||||
await flickToNextTab(page)
|
||||
await expect(page).toHaveURL(/\/stories$/)
|
||||
await expect(routerView).toHaveClass(/nvr-router-view--interactive/)
|
||||
|
||||
await flickToNextTab(page)
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
await waitForTransition(page)
|
||||
|
||||
// A stale pointer-up cleanup used to leave an orphaned transaction here,
|
||||
// permanently blocking both subsequent swipes and imperative tab links.
|
||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
||||
await expect(page).toHaveURL(/\/inbox$/)
|
||||
await waitForTransition(page)
|
||||
await expect(routerView).not.toHaveAttribute('data-native-transaction')
|
||||
})
|
||||
|
||||
test('opens and dismisses the compose sheet', async ({ page }) => {
|
||||
await page.goto('/inbox')
|
||||
await page.getByRole('button', { name: 'Compose' }).click()
|
||||
|
||||
@@ -30,7 +30,12 @@ test('ships an installable standalone manifest and iOS metadata', async ({ page,
|
||||
expect.objectContaining({ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' }),
|
||||
]))
|
||||
expect((await request.get('/apple-touch-icon.png')).headers()['content-type']).toContain('image/png')
|
||||
expect((await request.get('/sw.js')).ok()).toBe(true)
|
||||
const workerResponse = await request.get('/sw.js')
|
||||
expect(workerResponse.ok()).toBe(true)
|
||||
expect(workerResponse.headers()['cache-control']).toContain('no-cache')
|
||||
const worker = await workerResponse.text()
|
||||
expect(worker).toContain('self.skipWaiting()')
|
||||
expect(worker).toContain('clientsClaim()')
|
||||
})
|
||||
|
||||
test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => {
|
||||
@@ -67,6 +72,7 @@ test('registers and activates the offline service worker', async ({ page }) => {
|
||||
return registration.active?.scriptURL ?? ''
|
||||
})
|
||||
expect(workerUrl).toMatch(/\/sw\.js$/)
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-pwa-update-checks', '0')
|
||||
})
|
||||
|
||||
test('precaches lazily split routes for offline navigation', async ({ page }) => {
|
||||
|
||||
@@ -1,18 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
import { useNativeRouter } from '@native-vue-router/core'
|
||||
import { recordPwaUpdateState } from '../pwa'
|
||||
|
||||
const native = useNativeRouter()
|
||||
const { needRefresh, updateServiceWorker } = useRegisterSW()
|
||||
const reloadPending = ref(false)
|
||||
let registration: ServiceWorkerRegistration | undefined
|
||||
let checkTimer: number | undefined
|
||||
let reloading = false
|
||||
let checking = false
|
||||
|
||||
function reloadWhenIdle() {
|
||||
if (!reloadPending.value || native.transaction.value || reloading) return
|
||||
reloading = true
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
if (!registration || checking || document.visibilityState === 'hidden' || !navigator.onLine) return
|
||||
checking = true
|
||||
recordPwaUpdateState('checking', true)
|
||||
try {
|
||||
await registration.update()
|
||||
if (!reloadPending.value) recordPwaUpdateState('current')
|
||||
} catch {
|
||||
recordPwaUpdateState('error')
|
||||
} finally {
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
|
||||
const { needRefresh, updateServiceWorker } = useRegisterSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_workerUrl, workerRegistration) {
|
||||
registration = workerRegistration
|
||||
void checkForUpdate()
|
||||
if (checkTimer !== undefined) window.clearInterval(checkTimer)
|
||||
checkTimer = window.setInterval(() => void checkForUpdate(), 60_000)
|
||||
},
|
||||
onNeedRefresh() {
|
||||
recordPwaUpdateState('ready')
|
||||
},
|
||||
onNeedReload() {
|
||||
recordPwaUpdateState('ready')
|
||||
reloadPending.value = true
|
||||
reloadWhenIdle()
|
||||
},
|
||||
onRegisterError() {
|
||||
recordPwaUpdateState('error')
|
||||
},
|
||||
})
|
||||
|
||||
function checkWhenActive() {
|
||||
if (document.visibilityState === 'visible') void checkForUpdate()
|
||||
}
|
||||
|
||||
window.addEventListener('focus', checkWhenActive)
|
||||
window.addEventListener('online', checkWhenActive)
|
||||
document.addEventListener('visibilitychange', checkWhenActive)
|
||||
watch(() => native.transaction.value, reloadWhenIdle, { flush: 'post' })
|
||||
|
||||
function update() {
|
||||
reloadPending.value = true
|
||||
if (!native.transaction.value) void updateServiceWorker(true)
|
||||
reloadWhenIdle()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (checkTimer !== undefined) window.clearInterval(checkTimer)
|
||||
window.removeEventListener('focus', checkWhenActive)
|
||||
window.removeEventListener('online', checkWhenActive)
|
||||
document.removeEventListener('visibilitychange', checkWhenActive)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-if="needRefresh" class="update-toast" role="status">
|
||||
<span>A fresh build is ready.</span>
|
||||
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
|
||||
<aside v-if="needRefresh || reloadPending" class="update-toast" role="status">
|
||||
<span>{{ native.transaction.value ? 'A fresh build will open after this gesture.' : 'A fresh build is ready.' }}</span>
|
||||
<button v-if="needRefresh" type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,11 @@ interface StandaloneNavigator extends Navigator {
|
||||
}
|
||||
|
||||
type ServiceWorkerState = 'unsupported' | 'installing' | 'ready'
|
||||
export type PwaUpdateState = 'idle' | 'checking' | 'current' | 'ready' | 'error'
|
||||
|
||||
declare const __NVR_BUILD_ID__: string
|
||||
|
||||
export const pwaBuildId = __NVR_BUILD_ID__
|
||||
|
||||
const state = reactive({
|
||||
ios: false,
|
||||
@@ -13,10 +18,19 @@ const state = reactive({
|
||||
edgeGuard: false,
|
||||
edgeClaims: 0,
|
||||
serviceWorker: 'installing' as ServiceWorkerState,
|
||||
updateState: 'idle' as PwaUpdateState,
|
||||
updateChecks: 0,
|
||||
})
|
||||
|
||||
export const pwaEnvironment = readonly(state)
|
||||
|
||||
export function recordPwaUpdateState(updateState: PwaUpdateState, checked = false) {
|
||||
state.updateState = updateState
|
||||
if (checked) state.updateChecks += 1
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
|
||||
}
|
||||
|
||||
export function isIOSWebKit() {
|
||||
const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number }
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
@@ -37,6 +51,8 @@ function updateEnvironment() {
|
||||
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
|
||||
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
|
||||
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
|
||||
document.documentElement.dataset.pwaUpdateState = state.updateState
|
||||
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks)
|
||||
}
|
||||
|
||||
export interface PwaAdapterOptions {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
import { pwaEnvironment } from '../pwa'
|
||||
import { pwaBuildId, pwaEnvironment } from '../pwa'
|
||||
|
||||
const store = useDemoStore()
|
||||
</script>
|
||||
@@ -17,6 +17,7 @@ const store = useDemoStore()
|
||||
<h2>PWA environment</h2>
|
||||
<div><span><strong>Display mode</strong><small>Home Screen installation state</small></span><b :class="{ offline: !pwaEnvironment.standalone }">{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}</b></div>
|
||||
<div><span><strong>Offline worker</strong><small>Cached application shell</small></span><b :class="{ offline: pwaEnvironment.serviceWorker !== 'ready' }">{{ pwaEnvironment.serviceWorker }}</b></div>
|
||||
<div><span><strong>App build</strong><small>{{ pwaBuildId }}</small></span><b :class="{ offline: pwaEnvironment.updateState === 'error' }">{{ pwaEnvironment.updateState }} · {{ pwaEnvironment.updateChecks }} checks</b></div>
|
||||
<div><span><strong>iOS edge ownership</strong><small>Leading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}</small></span><b :class="{ offline: !pwaEnvironment.edgeGuard }">{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}</b></div>
|
||||
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share → Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
|
||||
<p v-else-if="pwaEnvironment.ios">The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.</p>
|
||||
|
||||
@@ -14,7 +14,7 @@ Transactions move through `interactive`, `committing`, `settling`, and cancellat
|
||||
|
||||
The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.
|
||||
|
||||
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Release uses distance/velocity intent and a damped spring. Reduced-motion mode settles immediately.
|
||||
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. Reduced-motion mode settles immediately.
|
||||
|
||||
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
## PWA and browser
|
||||
|
||||
The demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. The Navigation Lab exposes the live display mode, service-worker state, edge-guard state, and intercepted-touch count.
|
||||
The demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Production builds check for updates on startup, focus, foreground resume, network reconnection, and once per minute. A new worker activates automatically; its page reload is deferred until no gesture transaction is active. The Navigation Lab exposes the build ID, update checks, live display mode, service-worker state, edge-guard state, and intercepted-touch count.
|
||||
|
||||
The normal `npm run dev` server enables the development service worker and listens on local network interfaces; no PWA-specific command is required. iOS still requires the resulting address to be delivered through HTTPS before service-worker and Home Screen behavior is available.
|
||||
|
||||
For a production-style update test, deploy successive `npm run build` outputs at the same HTTPS origin. The service-worker entry file must be served without long-lived HTTP caching; fingerprinted files under `assets/` can remain immutable. An already-installed build that predates the automatic updater may require one final manual refresh or reinstall before it can receive the new update policy.
|
||||
|
||||
In an installed iOS Home Screen app, the PWA adapter installs non-passive leading-edge touch listeners before the navigator gesture and applies `overscroll-behavior-x: none`. This gives the application the earliest web-content opportunity to claim the sequence. The guard is disabled in normal Safari tabs so the demo does not unexpectedly override browser navigation.
|
||||
|
||||
Mobile operating systems can reserve gestures before web content receives them. A PWA cannot set `WKWebView.allowsBackForwardNavigationGestures`, so absolute native-level suppression cannot be guaranteed from JavaScript. The full interaction system targets installed PWAs; use the Capacitor host when that native switch must be deterministic. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback.
|
||||
|
||||
@@ -108,6 +108,7 @@ export const NativeRouterView = defineComponent({
|
||||
'data-native-presentation': transaction?.presentation,
|
||||
'data-native-direction': transaction?.direction,
|
||||
'data-native-transaction': transaction?.id,
|
||||
'data-native-velocity': transaction ? String(transaction.velocity) : undefined,
|
||||
}, children)
|
||||
}
|
||||
},
|
||||
@@ -166,6 +167,7 @@ function createPointerGesture(
|
||||
let ending = false
|
||||
let bufferedProgress = 0
|
||||
let bufferedVelocity = 0
|
||||
let gestureSign = 0
|
||||
|
||||
const down = (event: PointerEvent) => {
|
||||
if (!event.isPrimary || event.button !== 0 || shouldIgnoreGesture(event.target)) return
|
||||
@@ -176,6 +178,9 @@ function createPointerGesture(
|
||||
captured = false
|
||||
beginPromise = null
|
||||
ending = false
|
||||
bufferedProgress = 0
|
||||
bufferedVelocity = 0
|
||||
gestureSign = 0
|
||||
}
|
||||
const move = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return
|
||||
@@ -187,20 +192,22 @@ function createPointerGesture(
|
||||
if (!direction) return reset()
|
||||
captured = true
|
||||
ending = false
|
||||
gestureSign = Math.sign(dx)
|
||||
element()?.setPointerCapture(pointerId)
|
||||
beginPromise = begin(direction)
|
||||
}
|
||||
event.preventDefault()
|
||||
const elapsed = Math.max(8, event.timeStamp - lastTime)
|
||||
bufferedVelocity = Math.abs(event.clientX - lastX) / elapsed
|
||||
bufferedProgress = Math.min(1, Math.abs(dx) / Math.max(1, element()?.clientWidth ?? window.innerWidth))
|
||||
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth)
|
||||
bufferedVelocity = ((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width)
|
||||
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width))
|
||||
lastX = event.clientX
|
||||
lastTime = event.timeStamp
|
||||
const pending = beginPromise
|
||||
if (pending) {
|
||||
const id = await pending
|
||||
if (id === null) return reset()
|
||||
if (pending !== beginPromise || ending) return
|
||||
if (id === null) return reset()
|
||||
const runtime = injectRuntimeFromElement(element())
|
||||
runtime?.updateInteractive(bufferedProgress, bufferedVelocity)
|
||||
}
|
||||
@@ -209,19 +216,27 @@ function createPointerGesture(
|
||||
if (event.pointerId !== pointerId) return
|
||||
ending = true
|
||||
const runtime = injectRuntimeFromElement(element())
|
||||
const id = beginPromise ? await beginPromise : null
|
||||
if (captured && id !== null && runtime) {
|
||||
runtime.updateInteractive(bufferedProgress, bufferedVelocity)
|
||||
const pending = beginPromise
|
||||
const shouldFinish = captured
|
||||
const progress = bufferedProgress
|
||||
const velocity = bufferedVelocity
|
||||
// Detach this pointer before awaiting preload/navigation/animation work. A
|
||||
// new gesture may now start without this release callback erasing it.
|
||||
reset()
|
||||
const id = pending ? await pending : null
|
||||
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
|
||||
runtime.updateInteractive(progress, velocity)
|
||||
await runtime.finishInteractive()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
const cancel = async () => {
|
||||
ending = true
|
||||
const runtime = injectRuntimeFromElement(element())
|
||||
const id = beginPromise ? await beginPromise : null
|
||||
if (captured && id !== null && runtime) await runtime.cancelInteractive()
|
||||
const pending = beginPromise
|
||||
const shouldCancel = captured
|
||||
reset()
|
||||
const id = pending ? await pending : null
|
||||
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id) await runtime.cancelInteractive()
|
||||
}
|
||||
const reset = () => {
|
||||
pointerId = -1
|
||||
@@ -230,6 +245,7 @@ function createPointerGesture(
|
||||
ending = false
|
||||
bufferedProgress = 0
|
||||
bufferedVelocity = 0
|
||||
gestureSign = 0
|
||||
}
|
||||
return { down, move, up, cancel }
|
||||
}
|
||||
@@ -429,32 +445,39 @@ export const NativeDismissGesture = defineComponent({
|
||||
}
|
||||
event.preventDefault()
|
||||
progress = Math.max(0, Math.min(1, dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight)))
|
||||
velocity = Math.max(0, event.clientY - lastY) / Math.max(8, event.timeStamp - lastTime)
|
||||
const height = Math.max(1, root.value?.clientHeight ?? window.innerHeight)
|
||||
velocity = ((event.clientY - lastY) * 1000) / (Math.max(8, event.timeStamp - lastTime) * height)
|
||||
lastY = event.clientY
|
||||
lastTime = event.timeStamp
|
||||
const pending = beginPromise
|
||||
if (pending) {
|
||||
const id = await pending
|
||||
if (id === null) return reset()
|
||||
if (pending !== beginPromise || ending) return
|
||||
if (id === null) return reset()
|
||||
runtime.updateInteractive(progress, velocity)
|
||||
}
|
||||
}
|
||||
const up = async (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return
|
||||
ending = true
|
||||
const id = beginPromise ? await beginPromise : null
|
||||
if (captured && id !== null) {
|
||||
runtime.updateInteractive(progress, velocity)
|
||||
const pending = beginPromise
|
||||
const shouldFinish = captured
|
||||
const finalProgress = progress
|
||||
const finalVelocity = velocity
|
||||
reset()
|
||||
const id = pending ? await pending : null
|
||||
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
|
||||
runtime.updateInteractive(finalProgress, finalVelocity)
|
||||
await runtime.finishInteractive()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
const cancel = async () => {
|
||||
ending = true
|
||||
const id = beginPromise ? await beginPromise : null
|
||||
if (captured && id !== null) await runtime.cancelInteractive()
|
||||
const pending = beginPromise
|
||||
const shouldCancel = captured
|
||||
reset()
|
||||
const id = pending ? await pending : null
|
||||
if (shouldCancel && id !== null && runtime.transaction.value?.id === id) await runtime.cancelInteractive()
|
||||
}
|
||||
onBeforeUnmount(() => void cancel())
|
||||
return () => h(props.as, {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNativeRouter, definePresentation, shouldCommitGesture } from './runtime'
|
||||
import {
|
||||
createNativeRouter,
|
||||
definePresentation,
|
||||
shouldCommitGesture,
|
||||
springTimeScaleForVelocity,
|
||||
} from './runtime'
|
||||
|
||||
const Page = defineComponent({ template: '<div>page</div>' })
|
||||
|
||||
@@ -36,9 +41,16 @@ beforeEach(() => {
|
||||
describe('gesture decisions', () => {
|
||||
it('uses progress or a deliberate velocity to commit', () => {
|
||||
expect(shouldCommitGesture(0.4, 0)).toBe(true)
|
||||
expect(shouldCommitGesture(0.12, 0.7)).toBe(true)
|
||||
expect(shouldCommitGesture(0.04, 1.4)).toBe(false)
|
||||
expect(shouldCommitGesture(0.2, 0.1)).toBe(false)
|
||||
expect(shouldCommitGesture(0.12, 1.4)).toBe(true)
|
||||
expect(shouldCommitGesture(0.04, 4)).toBe(false)
|
||||
expect(shouldCommitGesture(0.2, 0.4)).toBe(false)
|
||||
})
|
||||
|
||||
it('settles a fast flick more quickly without unbounded spring steps', () => {
|
||||
expect(springTimeScaleForVelocity(0)).toBe(1)
|
||||
expect(springTimeScaleForVelocity(2)).toBeCloseTo(1.6)
|
||||
expect(springTimeScaleForVelocity(8)).toBe(3)
|
||||
expect(springTimeScaleForVelocity(-20)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,16 @@ function entryFor(route: RouteLocationNormalizedLoaded, status: NativeViewEntry[
|
||||
}
|
||||
|
||||
export function shouldCommitGesture(progress: number, velocity: number, threshold = 0.36) {
|
||||
return progress >= threshold || (progress >= 0.08 && velocity >= 0.52)
|
||||
return progress >= threshold || (progress >= 0.08 && velocity >= 1.1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts release velocity (normalized route progress per second) into the
|
||||
* rate at which the spring is simulated. A deliberate flick can settle up to
|
||||
* three times faster while a stationary release keeps the baseline spring.
|
||||
*/
|
||||
export function springTimeScaleForVelocity(velocity: number) {
|
||||
return 1 + Math.min(2, Math.abs(velocity) * 0.3)
|
||||
}
|
||||
|
||||
export function definePresentation(definition: NativePresentationDefinition) {
|
||||
@@ -555,17 +564,24 @@ class NativeRouterRuntimeImpl implements NativeRouterRuntime {
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let position = transaction.progress
|
||||
let velocity = Math.max(-2, Math.min(2, initialVelocity))
|
||||
let velocity = Math.max(-12, Math.min(12, initialVelocity))
|
||||
const timeScale = springTimeScaleForVelocity(initialVelocity)
|
||||
let previous = now()
|
||||
const step = (time: number) => {
|
||||
const live = this.mutableTransaction.value
|
||||
if (!live || live.id !== transaction.id) return resolve()
|
||||
const dt = Math.min(0.032, Math.max(0.001, (time - previous) / 1000))
|
||||
const elapsed = Math.min(0.032, Math.max(0.001, (time - previous) / 1000)) * timeScale
|
||||
previous = time
|
||||
const displacement = target - position
|
||||
const acceleration = displacement * 280 - velocity * 30
|
||||
velocity += acceleration * dt
|
||||
position += velocity * dt
|
||||
// Substeps keep the spring stable when a high-velocity flick advances
|
||||
// several frames of simulated time in one display frame.
|
||||
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)))
|
||||
const dt = elapsed / iterations
|
||||
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
||||
const displacement = target - position
|
||||
const acceleration = displacement * 280 - velocity * 30
|
||||
velocity += acceleration * dt
|
||||
position += velocity * dt
|
||||
}
|
||||
const done = Math.abs(target - position) < 0.002 && Math.abs(velocity) < 0.02
|
||||
this.mutableTransaction.value = {
|
||||
...live,
|
||||
|
||||
@@ -8,11 +8,24 @@ export default defineConfig({
|
||||
base: './',
|
||||
root: path.resolve(__dirname, 'apps/demo'),
|
||||
publicDir: path.resolve(__dirname, 'public'),
|
||||
define: {
|
||||
__NVR_BUILD_ID__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
server: {
|
||||
// iOS Safari can otherwise retain a tunnelled development response after
|
||||
// the dev server has restarted with a new build.
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
},
|
||||
preview: {
|
||||
// Production hosts should apply the same policy at least to index.html and
|
||||
// sw.js. Fingerprinted assets can use immutable caching when deployed.
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
navigateFallback: 'index.html',
|
||||
@@ -39,6 +52,8 @@ export default defineConfig({
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
navigateFallback: '/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user