@octane-xplat/motion 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/UPSTREAM.md +284 -16
- package/dist/native/index.js +980 -112
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/index.js +1104 -166
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +8 -3
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -0
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +234 -1
- package/src/controller.ts +122 -10
- package/src/delegation.test.ts +269 -0
- package/src/delegation.ts +29 -0
- package/src/delegation.web.test.ts +270 -0
- package/src/drag.mobile.test.ts +81 -0
- package/src/drag.test.ts +167 -0
- package/src/drag.ts +157 -0
- package/src/driver.ts +249 -0
- package/src/driver.web.ts +180 -0
- package/src/engine.test.ts +86 -0
- package/src/engine.ts +137 -15
- package/src/hooks.tsrx +53 -2
- package/src/host-types.ts +44 -1
- package/src/host.ts +39 -2
- package/src/host.web.ts +51 -10
- package/src/index.ts +18 -4
- package/src/index.web.ts +18 -4
- package/src/pan.ts +92 -0
- package/src/pan.web.ts +116 -0
- package/src/presence.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +99 -10
- package/src/value.ts +15 -5
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +7 -1
- package/types/index.d.ts +22 -1
- package/types/types.d.ts +93 -10
- package/types/value.d.ts +4 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { act, createRoot, useEffect } from 'octane'
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { motion, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
|
|
3
|
+
import { motion, Presence, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
|
|
4
4
|
|
|
5
5
|
function Box(props: any) @{
|
|
6
6
|
<motion.View id="box" initial={{ x: 20 }} animate={props.target} transition={{ duration: 0 }} onAnimationComplete={props.done} />
|
|
@@ -133,3 +133,236 @@ it('observes live system reduced-motion changes', () => {
|
|
|
133
133
|
expect((mounted.container.querySelector('#user-reduced') as HTMLElement).style.transform).toContain('translateX(100px)')
|
|
134
134
|
mounted.close(); expect(callbacks.size).toBe(0); vi.unstubAllGlobals()
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
import { useAnimate } from './index.web'
|
|
138
|
+
import { View } from '@octane-xplat/ui'
|
|
139
|
+
|
|
140
|
+
function TapScale() @{
|
|
141
|
+
<motion.Pressable id="tap" animate={{ scale: 1 }} whileTap={{ scale: 1.5 }} whileTapTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe('upstream-shaped props', () => {
|
|
145
|
+
it('animates whileTap on pointerdown and returns on release', () => {
|
|
146
|
+
const mounted = mount(<TapScale />)
|
|
147
|
+
const node = mounted.container.querySelector('#tap') as HTMLElement
|
|
148
|
+
node.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
149
|
+
expect(node.style.transform).toContain('scale(1.5)')
|
|
150
|
+
node.dispatchEvent(new Event('pointerup', { bubbles: true }))
|
|
151
|
+
expect(node.style.transform).toContain('scale(1)')
|
|
152
|
+
mounted.close()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('animates whileFocus on focus and returns on blur', () => {
|
|
156
|
+
const mounted = mount(<TapFocusCase />)
|
|
157
|
+
const node = mounted.container.querySelector('#focusable') as HTMLElement
|
|
158
|
+
node.dispatchEvent(new Event('focus'))
|
|
159
|
+
expect(node.style.transform).toContain('translateX(20px)')
|
|
160
|
+
node.dispatchEvent(new Event('blur'))
|
|
161
|
+
expect(node.style.transform).toContain('translateX(0px)')
|
|
162
|
+
mounted.close()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('fires onAnimationStart and onUpdate for declarative runs', () => {
|
|
166
|
+
const mounted = mount(<CallbackCase />)
|
|
167
|
+
expect(callbackState.started).toBe(1)
|
|
168
|
+
expect(callbackState.updated).toBeGreaterThan(0)
|
|
169
|
+
mounted.close()
|
|
170
|
+
callbackState.started = 0
|
|
171
|
+
callbackState.updated = 0
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('wraps an arbitrary leaf with motion.create', () => {
|
|
175
|
+
const mounted = mount(<CreatedCase />)
|
|
176
|
+
const node = mounted.container.querySelector('#created') as HTMLElement
|
|
177
|
+
expect(node.style.transform).toContain('translateX(30px)')
|
|
178
|
+
mounted.close()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('animates through useAnimate scope and a MotionValue directly', async () => {
|
|
182
|
+
const mounted = mount(<ScopedCase />)
|
|
183
|
+
const node = mounted.container.querySelector('#scoped') as HTMLElement
|
|
184
|
+
await scopedApi.animate({ x: 40 }, { duration: 0 })
|
|
185
|
+
expect(node.style.transform).toContain('translateX(40px)')
|
|
186
|
+
const direct = scopedApi.animate(scopedApi.value, 77, { duration: 0 })
|
|
187
|
+
await direct.finished
|
|
188
|
+
expect(scopedApi.value.get()).toBe(77)
|
|
189
|
+
mounted.close()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const callbackState = { started: 0, updated: 0 }
|
|
194
|
+
function CallbackCase() @{
|
|
195
|
+
<motion.View
|
|
196
|
+
id="callbacks"
|
|
197
|
+
animate={{ x: 5 }}
|
|
198
|
+
transition={{ duration: 0 }}
|
|
199
|
+
onAnimationStart={() => callbackState.started++}
|
|
200
|
+
onUpdate={() => callbackState.updated++}
|
|
201
|
+
/>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function TapFocusCase() @{
|
|
205
|
+
<motion.View id="focusable" animate={{ x: 0 }} whileFocus={{ x: 20 }} whileFocusTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const MotionCard = motion.create(function Card(props: any) @{
|
|
209
|
+
<View {...props} />
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
function CreatedCase() @{
|
|
213
|
+
<MotionCard id="created" animate={{ x: 30 }} transition={{ duration: 0 }} />
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const scopedApi: { animate: any; value: MotionValue } = { animate: undefined, value: undefined as any }
|
|
217
|
+
function ScopedCase() @{
|
|
218
|
+
const [scope, animate] = useAnimate()
|
|
219
|
+
const value = useMotionValue(0)
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
scopedApi.animate = animate
|
|
222
|
+
scopedApi.value = value
|
|
223
|
+
}, [])
|
|
224
|
+
|
|
225
|
+
<motion.View id="scoped" ref={scope} />
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function DragBox(props: any) @{
|
|
229
|
+
<MotionConfig reducedMotion="never"><motion.View id="drag" {...props} /></MotionConfig>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pointer(node: HTMLElement, type: string, x: number, y = 0, pointerId = 1) {
|
|
233
|
+
const event = new MouseEvent(type, { clientX: x, clientY: y, button: 0, bubbles: true })
|
|
234
|
+
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
|
235
|
+
node.dispatchEvent(event)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('declarative pointer drag', () => {
|
|
239
|
+
it('writes a bound MotionValue live, captures one pointer, clamps, and cleans up', () => {
|
|
240
|
+
const x = new MotionValue(10, clock)
|
|
241
|
+
const started = vi.fn(), moved = vi.fn(), ended = vi.fn()
|
|
242
|
+
const mounted = mount(<DragBox drag="x" style={{ x }} dragConstraints={{ left: -30, right: 30 }} dragElastic={false} dragMomentum={false} onDragStart={started} onDrag={moved} onDragEnd={ended} />)
|
|
243
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
244
|
+
node.setPointerCapture = vi.fn()
|
|
245
|
+
expect(node.style.touchAction).toBe('pan-y')
|
|
246
|
+
pointer(node, 'pointerdown', 0)
|
|
247
|
+
pointer(node, 'pointermove', 4)
|
|
248
|
+
expect(started).not.toHaveBeenCalled()
|
|
249
|
+
pointer(node, 'pointermove', 100, 0, 2)
|
|
250
|
+
expect(x.get()).toBe(10)
|
|
251
|
+
pointer(node, 'pointermove', 100)
|
|
252
|
+
expect(x.get()).toBe(30)
|
|
253
|
+
expect(node.style.transform).toContain('translateX(30px)')
|
|
254
|
+
pointer(node, 'pointerup', 100)
|
|
255
|
+
expect(started).toHaveBeenCalledTimes(1)
|
|
256
|
+
expect(ended).toHaveBeenCalledTimes(1)
|
|
257
|
+
expect(node.setPointerCapture).toHaveBeenCalledWith(1)
|
|
258
|
+
mounted.close()
|
|
259
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', -100)
|
|
260
|
+
expect(x.get()).toBe(30)
|
|
261
|
+
expect(node.style.touchAction).toBe('')
|
|
262
|
+
x.destroy()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('updates drag axis and disables listeners on prop changes', () => {
|
|
266
|
+
const mounted = mount(<DragBox drag="x" dragMomentum={false} />)
|
|
267
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
268
|
+
act(() => mounted.root.render(<DragBox drag="y" dragMomentum={false} />))
|
|
269
|
+
expect(node.style.touchAction).toBe('pan-x')
|
|
270
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30); pointer(node, 'pointerup', 0, 30)
|
|
271
|
+
expect(node.style.transform).toContain('translateY(30px)')
|
|
272
|
+
act(() => mounted.root.render(<DragBox drag={false} />))
|
|
273
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 80)
|
|
274
|
+
expect(node.style.transform).toContain('translateY(30px)')
|
|
275
|
+
mounted.close()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('yields perpendicular movement and reports active cancellation once', () => {
|
|
279
|
+
const end = vi.fn(), start = vi.fn()
|
|
280
|
+
const mounted = mount(<DragBox drag="x" dragMomentum={false} onDragStart={start} onDragEnd={end} />)
|
|
281
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
282
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30)
|
|
283
|
+
expect(start).not.toHaveBeenCalled()
|
|
284
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 30)
|
|
285
|
+
pointer(node, 'pointercancel', 30); pointer(node, 'lostpointercapture', 30)
|
|
286
|
+
expect(end).toHaveBeenCalledTimes(1)
|
|
287
|
+
expect(end.mock.calls[0][1].cancelled).toBe(true)
|
|
288
|
+
mounted.close()
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
function VariantTree(props: any) @{
|
|
293
|
+
<motion.View initial="hidden" animate={props.label} variants={{ hidden: { x: -10 }, show: { x: 10, transition: { duration: 0, when: 'beforeChildren' } }, hide: { x: 0, transition: { duration: 0 } } }}>
|
|
294
|
+
<motion.View id="variant-child" custom={props.custom} variants={{ hidden: { x: -20 }, show: (custom: number) => ({ x: custom, transition: { duration: 0 } }), hide: { x: 0, transition: { duration: 0 } } }} />
|
|
295
|
+
<motion.View id="variant-explicit" animate={{ x: 99 }} transition={{ duration: 0 }} />
|
|
296
|
+
</motion.View>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function VariantGesture() @{
|
|
300
|
+
<motion.View id="variant-gesture" animate={['base', 'position']} whileTap="tap" whileFocus="focus" variants={{ base: { scale: 1, transition: { duration: 0 } }, position: { x: 5 }, tap: { scale: 2, transition: { duration: 0 } }, focus: { x: 30, transition: { duration: 0 } } }} />
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
it('inherits labels and initial targets while explicit animate remains independent', async () => {
|
|
304
|
+
const mounted = mount(<VariantTree label="show" custom={40} />)
|
|
305
|
+
const child = mounted.container.querySelector('#variant-child') as HTMLElement
|
|
306
|
+
await act(async () => {})
|
|
307
|
+
expect(child.style.transform).toContain('translateX(40px)')
|
|
308
|
+
expect((mounted.container.querySelector('#variant-explicit') as HTMLElement).style.transform).toContain('translateX(99px)')
|
|
309
|
+
act(() => mounted.root.render(<VariantTree label="hide" custom={40} />))
|
|
310
|
+
await act(async () => {})
|
|
311
|
+
expect(mounted.container.querySelector('#variant-child')).toBe(child)
|
|
312
|
+
expect(child.style.transform).toContain('translateX(0px)')
|
|
313
|
+
mounted.close()
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('resolves gesture labels and restores the resolved animate target', () => {
|
|
317
|
+
const mounted = mount(<VariantGesture />)
|
|
318
|
+
const host = mounted.container.querySelector('#variant-gesture') as HTMLElement
|
|
319
|
+
host.dispatchEvent(new Event('pointerdown'))
|
|
320
|
+
expect(host.style.transform).toContain('scale(2)')
|
|
321
|
+
host.dispatchEvent(new Event('pointerup'))
|
|
322
|
+
expect(host.style.transform).toContain('scale(1)')
|
|
323
|
+
host.dispatchEvent(new Event('focus'))
|
|
324
|
+
expect(host.style.transform).toContain('translateX(30px)')
|
|
325
|
+
host.dispatchEvent(new Event('blur'))
|
|
326
|
+
expect(host.style.transform).toContain('translateX(5px)')
|
|
327
|
+
mounted.close()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('retargets an inherited resolver when custom changes without a new parent label', async () => {
|
|
331
|
+
const mounted = mount(<VariantTree label="show" custom={40} />)
|
|
332
|
+
await act(async () => {})
|
|
333
|
+
act(() => mounted.root.render(<VariantTree label="show" custom={60} />))
|
|
334
|
+
await act(async () => {})
|
|
335
|
+
expect((mounted.container.querySelector('#variant-child') as HTMLElement).style.transform).toContain('translateX(60px)')
|
|
336
|
+
mounted.close()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
function InitialVariantTree() @{
|
|
340
|
+
<motion.View initial={false} animate="show" variants={{ show: { x: 10 } }}>
|
|
341
|
+
<motion.View id="initial-variant" variants={{ show: { x: 90 } }} />
|
|
342
|
+
</motion.View>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it('inherits initial=false and seeds child destinations without playback', () => {
|
|
346
|
+
const mounted = mount(<InitialVariantTree />)
|
|
347
|
+
expect((mounted.container.querySelector('#initial-variant') as HTMLElement).style.transform).toContain('translateX(90px)')
|
|
348
|
+
mounted.close()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
function VariantExit(props: any) @{
|
|
352
|
+
<Presence present={props.present} onExitComplete={props.done}>
|
|
353
|
+
<motion.View id="variant-exit" initial={false} animate="show" exit={['hide', 'offset']} variants={{ show: { x: 0 }, hide: { x: 70, transition: { duration: 0.1 } }, offset: { x: 100 } }} transition={{ duration: 20 }} />
|
|
354
|
+
</Presence>
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it('uses exit label arrays and their transition overrides under Presence', async () => {
|
|
358
|
+
vi.useFakeTimers()
|
|
359
|
+
const done = vi.fn()
|
|
360
|
+
const mounted = mount(<VariantExit present={true} done={done} />)
|
|
361
|
+
act(() => mounted.root.render(<VariantExit present={false} done={done} />))
|
|
362
|
+
expect(mounted.container.querySelector('#variant-exit')).not.toBeNull()
|
|
363
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(160) })
|
|
364
|
+
expect(mounted.container.querySelector('#variant-exit')).toBeNull()
|
|
365
|
+
expect(done).toHaveBeenCalledOnce()
|
|
366
|
+
mounted.close()
|
|
367
|
+
vi.useRealTimers()
|
|
368
|
+
})
|
package/src/controller.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MotionValue } from './value'
|
|
2
2
|
import type { Clock } from './clock-types'
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
3
|
+
import type { HostAdapter, DelegatedRun } from './host-types'
|
|
4
|
+
import type { Target, MotionKey, TransitionInput } from './types'
|
|
5
|
+
import { isBezierEase, isOrchestrated, resolveTransition, type AnimationResult } from './engine'
|
|
5
6
|
|
|
6
7
|
export const keys: MotionKey[] = ['opacity', 'x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
|
|
7
8
|
export const defaults: Required<Target> = {
|
|
@@ -24,10 +25,13 @@ export function validateTarget(target?: Target) {
|
|
|
24
25
|
|
|
25
26
|
export class Controller {
|
|
26
27
|
readonly values = new Map<MotionKey, MotionValue>()
|
|
27
|
-
|
|
28
|
+
/** Per-write snapshot callback (rAF-rate during delegated and JS runs). */
|
|
29
|
+
onUpdate?: (snapshot: Target) => void
|
|
30
|
+
private adapter?: HostAdapter
|
|
31
|
+
private delegated?: { run: DelegatedRun; frame: number; trackSample: () => void }
|
|
28
32
|
private generation = 0
|
|
29
33
|
constructor(private clock: Clock) {}
|
|
30
|
-
|
|
34
|
+
value(key: MotionKey) {
|
|
31
35
|
let value = this.values.get(key)
|
|
32
36
|
if (!value) {
|
|
33
37
|
value = new MotionValue(defaults[key], this.clock)
|
|
@@ -37,15 +41,27 @@ export class Controller {
|
|
|
37
41
|
|
|
38
42
|
return value
|
|
39
43
|
}
|
|
40
|
-
attach(
|
|
41
|
-
this.
|
|
44
|
+
attach(adapter: HostAdapter) {
|
|
45
|
+
this.adapter = adapter
|
|
42
46
|
this.flush()
|
|
43
47
|
}
|
|
48
|
+
private cancelDelegated() {
|
|
49
|
+
if (!this.delegated) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.clock.cancel(this.delegated.frame)
|
|
54
|
+
this.delegated.trackSample()
|
|
55
|
+
this.delegated.run.cancel()
|
|
56
|
+
this.delegated = undefined
|
|
57
|
+
}
|
|
44
58
|
snapshot(): Target {
|
|
45
59
|
return Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
|
|
46
60
|
}
|
|
47
61
|
flush() {
|
|
48
|
-
|
|
62
|
+
const snapshot = Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
|
|
63
|
+
this.adapter?.write(snapshot)
|
|
64
|
+
this.onUpdate?.(snapshot)
|
|
49
65
|
}
|
|
50
66
|
set(key: MotionKey, next: number) {
|
|
51
67
|
this.value(key).jump(next)
|
|
@@ -58,19 +74,114 @@ export class Controller {
|
|
|
58
74
|
}
|
|
59
75
|
async animate(
|
|
60
76
|
target: Target,
|
|
61
|
-
transition:
|
|
77
|
+
transition: TransitionInput,
|
|
62
78
|
reduced: boolean,
|
|
63
79
|
): Promise<AnimationResult> {
|
|
64
80
|
validateTarget(target)
|
|
65
81
|
const generation = ++this.generation
|
|
82
|
+
this.cancelDelegated()
|
|
66
83
|
// A new destination owns the entire declarative animation, including removed keys.
|
|
67
84
|
for (const value of this.values.values()) {
|
|
68
85
|
value.stop('replaced')
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
if (
|
|
89
|
+
!reduced &&
|
|
90
|
+
!isOrchestrated(transition) &&
|
|
91
|
+
transition.type !== 'spring' &&
|
|
92
|
+
(transition.duration ?? 0.3) > 0 &&
|
|
93
|
+
(transition.repeat ?? 0) === 0 &&
|
|
94
|
+
isBezierEase(transition.ease) &&
|
|
95
|
+
this.adapter?.delegate
|
|
96
|
+
) {
|
|
97
|
+
const dest: Target = { ...this.snapshot(), ...target }
|
|
98
|
+
const run = this.adapter.delegate({
|
|
99
|
+
target,
|
|
100
|
+
dest,
|
|
101
|
+
eff: {
|
|
102
|
+
opacity: dest.opacity,
|
|
103
|
+
x: dest.x,
|
|
104
|
+
y: dest.y,
|
|
105
|
+
scaleX: (dest.scale ?? 1) * (dest.scaleX ?? 1),
|
|
106
|
+
scaleY: (dest.scale ?? 1) * (dest.scaleY ?? 1),
|
|
107
|
+
rotate: dest.rotate,
|
|
108
|
+
},
|
|
109
|
+
transition,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (run) {
|
|
113
|
+
const trackSample = () => {
|
|
114
|
+
const eff = run.sample()
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (target[key] === undefined) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let next: number | undefined
|
|
121
|
+
if (key === 'scale') {
|
|
122
|
+
next = (eff.scaleX ?? 1) / (dest.scaleX || 1)
|
|
123
|
+
} else if (key === 'scaleX') {
|
|
124
|
+
next = (eff.scaleX ?? 1) / (dest.scale || 1)
|
|
125
|
+
} else if (key === 'scaleY') {
|
|
126
|
+
next = (eff.scaleY ?? 1) / (dest.scale || 1)
|
|
127
|
+
} else {
|
|
128
|
+
next = eff[key]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (next !== undefined && Number.isFinite(next)) {
|
|
132
|
+
this.value(key).track(next)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.onUpdate?.(this.snapshot())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = { run, frame: 0, trackSample }
|
|
140
|
+
const tick = () => {
|
|
141
|
+
if (this.delegated !== entry) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
trackSample()
|
|
146
|
+
entry.frame = this.clock.request(tick)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
entry.frame = this.clock.request(tick)
|
|
150
|
+
this.delegated = entry
|
|
151
|
+
const result = await run.finished
|
|
152
|
+
// Only this run cleans up its own sampler — an interrupted run must
|
|
153
|
+
// not clobber the replacement's delegated state.
|
|
154
|
+
if (this.delegated === entry) {
|
|
155
|
+
this.clock.cancel(entry.frame)
|
|
156
|
+
this.delegated = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (generation !== this.generation) {
|
|
160
|
+
return 'replaced'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (result !== 'finished') {
|
|
164
|
+
return 'cancelled'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (dest[key] !== undefined) {
|
|
169
|
+
this.value(key).jump(dest[key]!)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return 'finished'
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
71
177
|
const jobs = Object.entries(target).map(([key, to]) => {
|
|
72
178
|
const value = this.value(key as MotionKey)
|
|
73
|
-
return value.animate(
|
|
179
|
+
return value.animate(
|
|
180
|
+
to,
|
|
181
|
+
reduced && key !== 'opacity'
|
|
182
|
+
? { duration: 0 }
|
|
183
|
+
: resolveTransition(transition, key as MotionKey),
|
|
184
|
+
).finished
|
|
74
185
|
})
|
|
75
186
|
|
|
76
187
|
const results = await Promise.all(jobs)
|
|
@@ -82,6 +193,7 @@ export class Controller {
|
|
|
82
193
|
}
|
|
83
194
|
stop() {
|
|
84
195
|
++this.generation
|
|
196
|
+
this.cancelDelegated()
|
|
85
197
|
for (const value of this.values.values()) {
|
|
86
198
|
value.stop()
|
|
87
199
|
}
|
|
@@ -93,6 +205,6 @@ export class Controller {
|
|
|
93
205
|
}
|
|
94
206
|
|
|
95
207
|
this.values.clear()
|
|
96
|
-
this.
|
|
208
|
+
this.adapter = undefined
|
|
97
209
|
}
|
|
98
210
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { Controller } from './controller'
|
|
3
|
+
import type { Clock } from './clock-types'
|
|
4
|
+
import type { DelegatedRequest, DelegatedRun, HostAdapter } from './host-types'
|
|
5
|
+
import type { Target } from './types'
|
|
6
|
+
|
|
7
|
+
function fakeClock() {
|
|
8
|
+
let time = 0,
|
|
9
|
+
id = 0
|
|
10
|
+
|
|
11
|
+
const callbacks = new Map<number, () => void>()
|
|
12
|
+
const clock: Clock = {
|
|
13
|
+
now: () => time,
|
|
14
|
+
request: (fn) => {
|
|
15
|
+
callbacks.set(++id, fn)
|
|
16
|
+
return id
|
|
17
|
+
},
|
|
18
|
+
cancel: (key) => {
|
|
19
|
+
callbacks.delete(key)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
clock,
|
|
25
|
+
advance(ms: number) {
|
|
26
|
+
time += ms
|
|
27
|
+
const pending = [...callbacks.values()]
|
|
28
|
+
callbacks.clear()
|
|
29
|
+
pending.forEach((fn) => fn())
|
|
30
|
+
},
|
|
31
|
+
count: () => callbacks.size,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FakeRun extends DelegatedRun {
|
|
36
|
+
resolve(result: 'finished' | 'cancelled'): void
|
|
37
|
+
setEff(next: Target): void
|
|
38
|
+
cancelCalls: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fakeRun(eff: Target): FakeRun {
|
|
42
|
+
let resolve!: (result: 'finished' | 'cancelled') => void
|
|
43
|
+
let current = { ...eff }
|
|
44
|
+
const run: FakeRun = {
|
|
45
|
+
finished: new Promise((done) => {
|
|
46
|
+
resolve = done
|
|
47
|
+
}),
|
|
48
|
+
sample: () => ({ ...current }),
|
|
49
|
+
cancelCalls: 0,
|
|
50
|
+
cancel() {
|
|
51
|
+
run.cancelCalls++
|
|
52
|
+
run.resolve('cancelled')
|
|
53
|
+
},
|
|
54
|
+
resolve,
|
|
55
|
+
setEff(next) {
|
|
56
|
+
current = { ...next }
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return run
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stubHost(onDelegate?: (req: DelegatedRequest) => DelegatedRun | null) {
|
|
64
|
+
const written: Target[] = []
|
|
65
|
+
const adapter: HostAdapter = {
|
|
66
|
+
read: () => ({ opacity: 1, x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0 }),
|
|
67
|
+
write: (target) => {
|
|
68
|
+
written.push({ ...target })
|
|
69
|
+
},
|
|
70
|
+
restore: () => {},
|
|
71
|
+
delegate: onDelegate,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { adapter, written }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('delegated runs', () => {
|
|
78
|
+
it('hands a tween to the host driver and resolves finished at the destination', async () => {
|
|
79
|
+
const time = fakeClock()
|
|
80
|
+
const controller = new Controller(time.clock)
|
|
81
|
+
let request: DelegatedRequest | undefined
|
|
82
|
+
const run = fakeRun({ x: 0 })
|
|
83
|
+
const { adapter } = stubHost((req) => {
|
|
84
|
+
request = req
|
|
85
|
+
return run
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
controller.attach(adapter)
|
|
89
|
+
controller.seed({ x: 0 })
|
|
90
|
+
|
|
91
|
+
const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
|
|
92
|
+
expect(request?.eff.x).toBe(100)
|
|
93
|
+
expect(request?.target.x).toBe(100)
|
|
94
|
+
|
|
95
|
+
run.setEff({ x: 40 })
|
|
96
|
+
time.advance(16)
|
|
97
|
+
run.resolve('finished')
|
|
98
|
+
expect(await done).toBe('finished')
|
|
99
|
+
expect(controller.values.get('x')?.get()).toBe(100)
|
|
100
|
+
expect(time.count()).toBe(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('folds scale into the effective axis and tracks samples back', async () => {
|
|
104
|
+
const time = fakeClock()
|
|
105
|
+
const controller = new Controller(time.clock)
|
|
106
|
+
let request: DelegatedRequest | undefined
|
|
107
|
+
const run = fakeRun({ scaleX: 0.5, scaleY: 0.5 })
|
|
108
|
+
const { adapter } = stubHost((req) => {
|
|
109
|
+
request = req
|
|
110
|
+
return run
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
controller.attach(adapter)
|
|
114
|
+
controller.seed({ scaleX: 0.5 })
|
|
115
|
+
|
|
116
|
+
const done = controller.animate({ scale: 2 }, { duration: 0.4 }, false)
|
|
117
|
+
// Retained scaleX=0.5 folds into the effective destination.
|
|
118
|
+
expect(request?.eff.scaleX).toBe(1)
|
|
119
|
+
expect(request?.eff.scaleY).toBe(2)
|
|
120
|
+
run.setEff({ scaleX: 0.9, scaleY: 0.9 })
|
|
121
|
+
time.advance(16)
|
|
122
|
+
// scale channel tracks effective / retained scaleX.
|
|
123
|
+
expect(controller.values.get('scale')?.get()).toBeCloseTo(1.8)
|
|
124
|
+
run.resolve('finished')
|
|
125
|
+
expect(await done).toBe('finished')
|
|
126
|
+
expect(controller.values.get('scale')?.get()).toBe(2)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('interrupts a delegated run with a tracked sample and resolves replaced', async () => {
|
|
130
|
+
const time = fakeClock()
|
|
131
|
+
const controller = new Controller(time.clock)
|
|
132
|
+
const first = fakeRun({ x: 0 })
|
|
133
|
+
const second = fakeRun({ x: 30 })
|
|
134
|
+
const runs = [first, second]
|
|
135
|
+
let calls = 0
|
|
136
|
+
const { adapter } = stubHost(() => runs[calls++ % runs.length])
|
|
137
|
+
controller.attach(adapter)
|
|
138
|
+
controller.seed({ x: 0 })
|
|
139
|
+
|
|
140
|
+
const doneFirst = controller.animate({ x: 100 }, { duration: 0.5 }, false)
|
|
141
|
+
first.setEff({ x: 30 })
|
|
142
|
+
time.advance(16)
|
|
143
|
+
const doneSecond = controller.animate({ x: -50 }, { duration: 0.5 }, false)
|
|
144
|
+
expect(first.cancelCalls).toBe(1)
|
|
145
|
+
// The interruption sample is written into the channel value.
|
|
146
|
+
expect(controller.values.get('x')?.get()).toBe(30)
|
|
147
|
+
expect(await doneFirst).toBe('replaced')
|
|
148
|
+
second.setEff({ x: -20 })
|
|
149
|
+
time.advance(16)
|
|
150
|
+
expect(controller.values.get('x')?.get()).toBe(-20)
|
|
151
|
+
second.resolve('finished')
|
|
152
|
+
expect(await doneSecond).toBe('finished')
|
|
153
|
+
expect(controller.values.get('x')?.get()).toBe(-50)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps springs on the JS engine', async () => {
|
|
157
|
+
const time = fakeClock()
|
|
158
|
+
const controller = new Controller(time.clock)
|
|
159
|
+
let delegated = false
|
|
160
|
+
const { adapter } = stubHost(() => {
|
|
161
|
+
delegated = true
|
|
162
|
+
return null
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
controller.attach(adapter)
|
|
166
|
+
controller.seed({ x: 0 })
|
|
167
|
+
|
|
168
|
+
const done = controller.animate(
|
|
169
|
+
{ x: 100 },
|
|
170
|
+
{ type: 'spring', stiffness: 100, damping: 10 },
|
|
171
|
+
false,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(delegated).toBe(false)
|
|
175
|
+
for (let i = 0; i < 400; i++) {
|
|
176
|
+
time.advance(16)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
expect(await done).toBe('finished')
|
|
180
|
+
expect(controller.values.get('x')?.get()).toBe(100)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('keeps reduced-motion runs on the JS engine for instant settle', async () => {
|
|
184
|
+
const time = fakeClock()
|
|
185
|
+
const controller = new Controller(time.clock)
|
|
186
|
+
let delegated = false
|
|
187
|
+
const { adapter, written } = stubHost(() => {
|
|
188
|
+
delegated = true
|
|
189
|
+
return null
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
controller.attach(adapter)
|
|
193
|
+
controller.seed({ x: 0 })
|
|
194
|
+
|
|
195
|
+
const done = controller.animate({ x: 50 }, { duration: 0.5 }, true)
|
|
196
|
+
expect(delegated).toBe(false)
|
|
197
|
+
expect(await done).toBe('finished')
|
|
198
|
+
expect(controller.values.get('x')?.get()).toBe(50)
|
|
199
|
+
expect(written[written.length - 1]?.x).toBe(50)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('resolves replaced when the delegated run is stopped', async () => {
|
|
203
|
+
const time = fakeClock()
|
|
204
|
+
const controller = new Controller(time.clock)
|
|
205
|
+
const run = fakeRun({ x: 0 })
|
|
206
|
+
const { adapter } = stubHost(() => run)
|
|
207
|
+
controller.attach(adapter)
|
|
208
|
+
controller.seed({ x: 0 })
|
|
209
|
+
|
|
210
|
+
const done = controller.animate({ x: 100 }, { duration: 0.5 }, false)
|
|
211
|
+
controller.stop()
|
|
212
|
+
expect(run.cancelCalls).toBe(1)
|
|
213
|
+
expect(await done).toBe('replaced')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it.each([
|
|
217
|
+
['per-key transitions', { default: { duration: 0.5 }, x: { duration: 1 } }],
|
|
218
|
+
['a non-bezier ease', { duration: 0.5, ease: 'backOut' as const }],
|
|
219
|
+
['a repeating run', { duration: 0.5, repeat: 2 }],
|
|
220
|
+
])('stays on the JS engine for %s', async (_label, transition) => {
|
|
221
|
+
const time = fakeClock()
|
|
222
|
+
const controller = new Controller(time.clock)
|
|
223
|
+
let delegated = false
|
|
224
|
+
const { adapter } = stubHost(() => {
|
|
225
|
+
delegated = true
|
|
226
|
+
return null
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
controller.attach(adapter)
|
|
230
|
+
controller.seed({ x: 0 })
|
|
231
|
+
|
|
232
|
+
const done = controller.animate({ x: 100 }, transition as any, false)
|
|
233
|
+
expect(delegated).toBe(false)
|
|
234
|
+
for (let i = 0; i < 500; i++) {
|
|
235
|
+
time.advance(16)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
expect(await done).toBe('finished')
|
|
239
|
+
expect(controller.values.get('x')?.get()).toBe(100)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('applies per-key overrides to each channel independently', async () => {
|
|
243
|
+
const time = fakeClock()
|
|
244
|
+
const controller = new Controller(time.clock)
|
|
245
|
+
const { adapter } = stubHost()
|
|
246
|
+
controller.attach(adapter)
|
|
247
|
+
controller.seed({ x: 0, y: 0 })
|
|
248
|
+
|
|
249
|
+
const done = controller.animate(
|
|
250
|
+
{ x: 100, y: 100 },
|
|
251
|
+
{ default: { duration: 0.1, ease: 'linear' }, x: { duration: 0.3 } },
|
|
252
|
+
false,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
for (let i = 0; i < 8; i++) {
|
|
256
|
+
time.advance(16)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// y finished at 100ms; x still travelling on its 300ms override.
|
|
260
|
+
expect(controller.values.get('y')?.get()).toBe(100)
|
|
261
|
+
expect(controller.values.get('x')?.get()).toBeLessThan(100)
|
|
262
|
+
for (let i = 0; i < 30; i++) {
|
|
263
|
+
time.advance(16)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
expect(await done).toBe('finished')
|
|
267
|
+
expect(controller.values.get('x')?.get()).toBe(100)
|
|
268
|
+
})
|
|
269
|
+
})
|