Fix animation stale reference

This commit is contained in:
2026-07-21 16:03:28 +10:00
parent d2d82ae6ae
commit 9af84760fa
12 changed files with 225 additions and 38 deletions

View File

@@ -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, {

View File

@@ -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)
})
})

View File

@@ -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,