@octane-xplat/motion 0.7.3 → 0.10.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 +295 -27
- 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 +25 -16
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -1
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +236 -3
- 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 +99 -0
- package/src/engine.ts +140 -18
- 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-input.ts +11 -4
- package/src/presence-input.web.ts +13 -4
- package/src/presence-state.ts +27 -7
- package/src/presence.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +100 -11
- package/src/value.ts +18 -8
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +8 -2
- package/types/index.d.ts +34 -6
- package/types/types.d.ts +94 -11
- package/types/value.d.ts +7 -4
package/src/drag.test.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { attachDrag, validateDrag } from './drag'
|
|
3
|
+
import { Controller } from './controller'
|
|
4
|
+
import type { Clock } from './clock-types'
|
|
5
|
+
import type { PanCallbacks, HostAdapter } from './host-types'
|
|
6
|
+
import type { DragInfo, MotionProps } from './types'
|
|
7
|
+
|
|
8
|
+
function fixture(props: MotionProps, reduced = false) {
|
|
9
|
+
let time = 0
|
|
10
|
+
let id = 0
|
|
11
|
+
const frames = new Map<number, () => void>()
|
|
12
|
+
const clock: Clock = {
|
|
13
|
+
now: () => time,
|
|
14
|
+
request: (fn) => {
|
|
15
|
+
frames.set(++id, fn)
|
|
16
|
+
return id
|
|
17
|
+
},
|
|
18
|
+
cancel: (key) => {
|
|
19
|
+
frames.delete(key)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const controller = new Controller(clock)
|
|
24
|
+
controller.seed({ x: 10, y: 20 })
|
|
25
|
+
let callbacks: PanCallbacks
|
|
26
|
+
const detach = vi.fn()
|
|
27
|
+
const delegate = vi.fn()
|
|
28
|
+
const writes: number[] = []
|
|
29
|
+
const adapter: HostAdapter = {
|
|
30
|
+
read: () => ({}),
|
|
31
|
+
write: (next) => {
|
|
32
|
+
writes.push(next.x ?? 0)
|
|
33
|
+
},
|
|
34
|
+
restore() {},
|
|
35
|
+
delegate,
|
|
36
|
+
pan(_axis, cb) {
|
|
37
|
+
callbacks = cb
|
|
38
|
+
return detach
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
controller.attach(adapter)
|
|
43
|
+
const off = attachDrag(
|
|
44
|
+
adapter,
|
|
45
|
+
controller,
|
|
46
|
+
props,
|
|
47
|
+
(axis) => controller.value(axis),
|
|
48
|
+
() => reduced,
|
|
49
|
+
() => props,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
controller,
|
|
54
|
+
off,
|
|
55
|
+
detach,
|
|
56
|
+
delegate,
|
|
57
|
+
writes,
|
|
58
|
+
emit(phase: keyof PanCallbacks, x: number, velocity = 0, cancelled = false) {
|
|
59
|
+
const info: DragInfo = {
|
|
60
|
+
point: { x, y: 0 },
|
|
61
|
+
offset: { x, y: x },
|
|
62
|
+
delta: { x, y: 0 },
|
|
63
|
+
velocity: { x: velocity, y: velocity },
|
|
64
|
+
cancelled,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
callbacks[phase]({}, info)
|
|
68
|
+
},
|
|
69
|
+
advance(ms: number) {
|
|
70
|
+
time += ms
|
|
71
|
+
const pending = [...frames.values()]
|
|
72
|
+
frames.clear()
|
|
73
|
+
pending.forEach((fn) => fn())
|
|
74
|
+
},
|
|
75
|
+
count: () => frames.size,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('bounded drag', () => {
|
|
80
|
+
it('clamps live displacement, keeps the other axis, and emits callbacks once', () => {
|
|
81
|
+
const onDragStart = vi.fn(),
|
|
82
|
+
onDrag = vi.fn(),
|
|
83
|
+
onDragEnd = vi.fn()
|
|
84
|
+
|
|
85
|
+
const f = fixture({
|
|
86
|
+
drag: 'x',
|
|
87
|
+
dragConstraints: { left: -20, right: 30 },
|
|
88
|
+
dragElastic: 0,
|
|
89
|
+
dragMomentum: false,
|
|
90
|
+
onDragStart,
|
|
91
|
+
onDrag,
|
|
92
|
+
onDragEnd,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
f.emit('start', 0)
|
|
96
|
+
f.emit('move', 100)
|
|
97
|
+
expect(f.controller.snapshot()).toEqual({ x: 30, y: 20 })
|
|
98
|
+
f.emit('end', 100, 500)
|
|
99
|
+
f.emit('end', 100)
|
|
100
|
+
expect(onDragStart).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(onDrag).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect(onDragEnd).toHaveBeenCalledTimes(1)
|
|
103
|
+
expect(f.count()).toBe(0)
|
|
104
|
+
f.off()
|
|
105
|
+
expect(f.detach).toHaveBeenCalledOnce()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('uses default elasticity and springs back inside constraints without momentum', () => {
|
|
109
|
+
const f = fixture({ drag: 'x', dragConstraints: { right: 30 }, dragMomentum: false })
|
|
110
|
+
f.emit('start', 0)
|
|
111
|
+
f.emit('move', 100)
|
|
112
|
+
expect(f.controller.value('x').get()).toBe(58)
|
|
113
|
+
f.emit('end', 100)
|
|
114
|
+
for (let i = 0; i < 200; i++) {
|
|
115
|
+
f.advance(16)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(f.controller.value('x').get()).toBe(30)
|
|
119
|
+
expect(f.delegate).not.toHaveBeenCalled()
|
|
120
|
+
f.off()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('hands release velocity to a JS spring and bounds every hard-constraint sample', () => {
|
|
124
|
+
const f = fixture({ drag: 'x', dragConstraints: { left: -20, right: 50 }, dragElastic: false })
|
|
125
|
+
f.emit('start', 0)
|
|
126
|
+
f.emit('move', 10)
|
|
127
|
+
f.emit('end', 10, 500)
|
|
128
|
+
f.advance(16)
|
|
129
|
+
expect(f.controller.value('x').get()).toBeGreaterThan(20)
|
|
130
|
+
for (let i = 0; i < 200; i++) {
|
|
131
|
+
f.advance(16)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expect(f.controller.value('x').get()).toBe(50)
|
|
135
|
+
expect(Math.max(...f.writes)).toBeLessThanOrEqual(50)
|
|
136
|
+
expect(f.delegate).not.toHaveBeenCalled()
|
|
137
|
+
f.off()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('cancellation drops momentum; reduced motion snaps; teardown cancels settlement', () => {
|
|
141
|
+
const cancel = fixture({ drag: true })
|
|
142
|
+
cancel.emit('start', 0)
|
|
143
|
+
cancel.emit('move', 20)
|
|
144
|
+
cancel.emit('end', 20, 500, true)
|
|
145
|
+
expect(cancel.count()).toBe(0)
|
|
146
|
+
cancel.off()
|
|
147
|
+
const reduced = fixture({ drag: 'x' }, true)
|
|
148
|
+
reduced.emit('start', 0)
|
|
149
|
+
reduced.emit('end', 10, 500)
|
|
150
|
+
expect(reduced.controller.value('x').get()).toBe(120)
|
|
151
|
+
expect(reduced.count()).toBe(0)
|
|
152
|
+
reduced.off()
|
|
153
|
+
const f = fixture({ drag: 'x' })
|
|
154
|
+
f.emit('start', 0)
|
|
155
|
+
f.emit('end', 10, 500)
|
|
156
|
+
expect(f.count()).toBe(1)
|
|
157
|
+
f.off()
|
|
158
|
+
expect(f.count()).toBe(0)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('rejects refs, invalid bounds, elasticity and competing axis targets', () => {
|
|
162
|
+
expect(() => validateDrag({ dragConstraints: { current: null } } as any)).toThrow('refs')
|
|
163
|
+
expect(() => validateDrag({ dragConstraints: { left: 20, right: 10 } })).toThrow('reversed')
|
|
164
|
+
expect(() => validateDrag({ dragElastic: 2 })).toThrow('dragElastic')
|
|
165
|
+
expect(() => validateDrag({ drag: 'x', animate: { x: 20 } })).toThrow('both own x')
|
|
166
|
+
})
|
|
167
|
+
})
|
package/src/drag.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Controller } from './controller'
|
|
2
|
+
import type { HostAdapter } from './host-types'
|
|
3
|
+
import type { DragInfo, MotionProps } from './types'
|
|
4
|
+
import type { MotionValue } from './value'
|
|
5
|
+
|
|
6
|
+
export function validateDrag(props: MotionProps) {
|
|
7
|
+
if (props.drag !== undefined && ![true, false, 'x', 'y'].includes(props.drag)) {
|
|
8
|
+
throw new Error('motion: drag must be boolean, x, or y')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const bounds = props.dragConstraints ?? {}
|
|
12
|
+
for (const [key, value] of Object.entries(bounds)) {
|
|
13
|
+
if (!['left', 'right', 'top', 'bottom'].includes(key) || !Number.isFinite(value)) {
|
|
14
|
+
throw new Error('motion: dragConstraints requires finite numeric edges; refs are deferred')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (
|
|
19
|
+
(bounds.left ?? -Infinity) > (bounds.right ?? Infinity) ||
|
|
20
|
+
(bounds.top ?? -Infinity) > (bounds.bottom ?? Infinity)
|
|
21
|
+
) {
|
|
22
|
+
throw new Error('motion: dragConstraints edges are reversed')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
typeof props.dragElastic !== 'boolean' &&
|
|
27
|
+
props.dragElastic !== undefined &&
|
|
28
|
+
(!Number.isFinite(props.dragElastic) || props.dragElastic < 0 || props.dragElastic > 1)
|
|
29
|
+
) {
|
|
30
|
+
throw new Error('motion: dragElastic must be boolean or a number from 0 to 1')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (props.drag) {
|
|
34
|
+
for (const axis of ['x', 'y'] as const) {
|
|
35
|
+
if (props.drag !== true && props.drag !== axis) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const target of [props.animate, props.whileTap, props.whileFocus]) {
|
|
40
|
+
if (
|
|
41
|
+
target &&
|
|
42
|
+
typeof target === 'object' &&
|
|
43
|
+
!Array.isArray(target) &&
|
|
44
|
+
target[axis] !== undefined
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`motion: drag and animation both own ${axis}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
|
54
|
+
|
|
55
|
+
/** Gesture writes and release springs bypass host delegation. */
|
|
56
|
+
export function attachDrag(
|
|
57
|
+
adapter: HostAdapter,
|
|
58
|
+
controller: Controller,
|
|
59
|
+
props: MotionProps,
|
|
60
|
+
valueFor: (axis: 'x' | 'y') => MotionValue,
|
|
61
|
+
reduced: () => boolean,
|
|
62
|
+
callbacks: () => MotionProps,
|
|
63
|
+
) {
|
|
64
|
+
if (!props.drag) {
|
|
65
|
+
return () => {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!adapter.pan) {
|
|
69
|
+
throw new Error('motion: this host does not support drag')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const axes = (['x', 'y'] as const).filter((axis) => props.drag === true || props.drag === axis)
|
|
73
|
+
const values = new Map(axes.map((axis) => [axis, valueFor(axis)]))
|
|
74
|
+
const value = (axis: 'x' | 'y') => values.get(axis)!
|
|
75
|
+
const bounds = props.dragConstraints ?? {}
|
|
76
|
+
const elastic =
|
|
77
|
+
typeof props.dragElastic === 'number'
|
|
78
|
+
? props.dragElastic
|
|
79
|
+
: props.dragElastic === false
|
|
80
|
+
? 0
|
|
81
|
+
: 0.35
|
|
82
|
+
|
|
83
|
+
const origin = { x: 0, y: 0 }
|
|
84
|
+
let active = false
|
|
85
|
+
const limits = (axis: 'x' | 'y') =>
|
|
86
|
+
axis === 'x'
|
|
87
|
+
? [bounds.left ?? -Infinity, bounds.right ?? Infinity]
|
|
88
|
+
: [bounds.top ?? -Infinity, bounds.bottom ?? Infinity]
|
|
89
|
+
|
|
90
|
+
const move = (info: DragInfo) => {
|
|
91
|
+
for (const axis of axes) {
|
|
92
|
+
const [min, max] = limits(axis)
|
|
93
|
+
const next = origin[axis] + info.offset[axis]
|
|
94
|
+
const bounded = clamp(next, min, max)
|
|
95
|
+
value(axis).jump(bounded + (next - bounded) * elastic)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const off = adapter.pan(props.drag, {
|
|
100
|
+
start(event, info) {
|
|
101
|
+
controller.stop()
|
|
102
|
+
active = true
|
|
103
|
+
for (const axis of axes) {
|
|
104
|
+
const channel = value(axis)
|
|
105
|
+
channel.stop()
|
|
106
|
+
origin[axis] = channel.get()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
callbacks().onDragStart?.(event, info)
|
|
110
|
+
},
|
|
111
|
+
move(event, info) {
|
|
112
|
+
if (!active) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
move(info)
|
|
117
|
+
callbacks().onDrag?.(event, info)
|
|
118
|
+
},
|
|
119
|
+
end(event, info) {
|
|
120
|
+
if (!active) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
active = false
|
|
125
|
+
move(info)
|
|
126
|
+
for (const axis of axes) {
|
|
127
|
+
const channel = value(axis)
|
|
128
|
+
const velocity = info.cancelled || props.dragMomentum === false ? 0 : info.velocity[axis]
|
|
129
|
+
const [min, max] = limits(axis)
|
|
130
|
+
const target = clamp(channel.get() + velocity * 0.2, min, max)
|
|
131
|
+
if (elastic === 0) {
|
|
132
|
+
channel.jump(clamp(channel.get(), min, max))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (reduced()) {
|
|
136
|
+
channel.jump(target)
|
|
137
|
+
} else if (target !== channel.get() || velocity !== 0) {
|
|
138
|
+
channel.animate(
|
|
139
|
+
target,
|
|
140
|
+
{ type: 'spring', stiffness: 200, damping: 30, velocity },
|
|
141
|
+
elastic === 0 ? (next) => clamp(next, min, max) : undefined,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
callbacks().onDragEnd?.(event, info)
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
active = false
|
|
152
|
+
off()
|
|
153
|
+
for (const channel of values.values()) {
|
|
154
|
+
channel.stop()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { DelegatedRequest, DelegatedRun } from './host-types'
|
|
2
|
+
import { bezierPoints, isBezierEase } from './engine'
|
|
3
|
+
import { delegationStats, trackDelegatedRun } from './delegation'
|
|
4
|
+
import type { Target } from './types'
|
|
5
|
+
|
|
6
|
+
const platform = globalThis as any
|
|
7
|
+
|
|
8
|
+
function iosRun(node: any, req: DelegatedRequest, write: (t: Target) => void): DelegatedRun | null {
|
|
9
|
+
const view = node.ios ?? node.nativeViewProtected
|
|
10
|
+
if (!view?.layer || !platform.UIViewPropertyAnimator) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { eff, target, transition: t } = req
|
|
15
|
+
const [x1, y1, x2, y2] = bezierPoints(t.ease)
|
|
16
|
+
const params = platform.UICubicTimingParameters.alloc().initWithControlPoint1ControlPoint2(
|
|
17
|
+
platform.CGPointMake(x1, y1),
|
|
18
|
+
platform.CGPointMake(x2, y2),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const animator = platform.UIViewPropertyAnimator.alloc().initWithDurationTimingParameters(
|
|
22
|
+
t.duration ?? 0.3,
|
|
23
|
+
params,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// UIViewPropertyAnimator owns view.transform/alpha presentation; the model
|
|
27
|
+
// props land at the destination immediately, so sample() reads the
|
|
28
|
+
// presentation layer and cancel() syncs the model back before stopping.
|
|
29
|
+
const radians = ((eff.rotate ?? 0) * Math.PI) / 180
|
|
30
|
+
const dest = platform.CGAffineTransformScale(
|
|
31
|
+
platform.CGAffineTransformRotate(
|
|
32
|
+
platform.CGAffineTransformMakeTranslation(eff.x ?? 0, eff.y ?? 0),
|
|
33
|
+
radians,
|
|
34
|
+
),
|
|
35
|
+
eff.scaleX ?? 1,
|
|
36
|
+
eff.scaleY ?? 1,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const sample = (): Target => {
|
|
40
|
+
const layer = view.layer.presentationLayer?.() ?? view.layer
|
|
41
|
+
const tr = layer.transform
|
|
42
|
+
return {
|
|
43
|
+
opacity: layer.opacity,
|
|
44
|
+
x: tr.m41,
|
|
45
|
+
y: tr.m42,
|
|
46
|
+
scaleX: Math.hypot(tr.m11, tr.m12),
|
|
47
|
+
scaleY: Math.hypot(tr.m21, tr.m22),
|
|
48
|
+
rotate: (Math.atan2(tr.m12, tr.m11) * 180) / Math.PI,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let resolve!: (result: 'finished' | 'cancelled') => void
|
|
53
|
+
const finished = new Promise<'finished' | 'cancelled'>((done) => {
|
|
54
|
+
resolve = done
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
let settled = false
|
|
58
|
+
const settle = (result: 'finished' | 'cancelled') => {
|
|
59
|
+
if (!settled) {
|
|
60
|
+
settled = true
|
|
61
|
+
resolve(result)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
animator.addAnimations(() => {
|
|
66
|
+
if (
|
|
67
|
+
target.x !== undefined ||
|
|
68
|
+
target.y !== undefined ||
|
|
69
|
+
target.rotate !== undefined ||
|
|
70
|
+
target.scale !== undefined ||
|
|
71
|
+
target.scaleX !== undefined ||
|
|
72
|
+
target.scaleY !== undefined
|
|
73
|
+
) {
|
|
74
|
+
view.transform = dest
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (target.opacity !== undefined) {
|
|
78
|
+
view.alpha = Math.min(1, Math.max(0, eff.opacity ?? target.opacity))
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
animator.addCompletion((position: number) => {
|
|
83
|
+
if (position === 0) {
|
|
84
|
+
write(req.dest)
|
|
85
|
+
settle('finished')
|
|
86
|
+
} else {
|
|
87
|
+
settle('cancelled')
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (t.delay) {
|
|
92
|
+
animator.startAnimationAfterDelay(t.delay)
|
|
93
|
+
} else {
|
|
94
|
+
animator.startAnimation()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
finished,
|
|
99
|
+
sample,
|
|
100
|
+
cancel() {
|
|
101
|
+
const current = sample()
|
|
102
|
+
animator.stopAnimation(true)
|
|
103
|
+
write({
|
|
104
|
+
opacity: current.opacity,
|
|
105
|
+
x: current.x,
|
|
106
|
+
y: current.y,
|
|
107
|
+
scaleX: current.scaleX,
|
|
108
|
+
scaleY: current.scaleY,
|
|
109
|
+
rotate: current.rotate,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
settle('cancelled')
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function androidRun(
|
|
118
|
+
node: any,
|
|
119
|
+
req: DelegatedRequest,
|
|
120
|
+
write: (t: Target) => void,
|
|
121
|
+
): DelegatedRun | null {
|
|
122
|
+
const view = node.nativeViewProtected ?? node.android
|
|
123
|
+
const android = platform.android
|
|
124
|
+
const androidx = platform.androidx
|
|
125
|
+
if (
|
|
126
|
+
!view?.animate ||
|
|
127
|
+
!android?.animation ||
|
|
128
|
+
!androidx?.core?.view?.animation?.PathInterpolatorCompat
|
|
129
|
+
) {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { eff, target, transition: t } = req
|
|
134
|
+
const animator = view.animate()
|
|
135
|
+
const [x1, y1, x2, y2] = bezierPoints(t.ease)
|
|
136
|
+
animator.setInterpolator(
|
|
137
|
+
androidx.core.view.animation.PathInterpolatorCompat.create(x1, y1, x2, y2),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
animator.setDuration(Math.round((t.duration ?? 0.3) * 1000))
|
|
141
|
+
animator.setStartDelay(Math.round((t.delay ?? 0) * 1000))
|
|
142
|
+
if (target.x !== undefined) {
|
|
143
|
+
animator.translationX(eff.x)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (target.y !== undefined) {
|
|
147
|
+
animator.translationY(eff.y)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (target.scale !== undefined || target.scaleX !== undefined) {
|
|
151
|
+
animator.scaleX(eff.scaleX)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (target.scale !== undefined || target.scaleY !== undefined) {
|
|
155
|
+
animator.scaleY(eff.scaleY)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (target.rotate !== undefined) {
|
|
159
|
+
animator.rotation(eff.rotate)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (target.opacity !== undefined) {
|
|
163
|
+
animator.alpha(Math.min(1, Math.max(0, eff.opacity ?? target.opacity)))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let resolve!: (result: 'finished' | 'cancelled') => void
|
|
167
|
+
const finished = new Promise<'finished' | 'cancelled'>((done) => {
|
|
168
|
+
resolve = done
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
let settled = false
|
|
172
|
+
const settle = (result: 'finished' | 'cancelled') => {
|
|
173
|
+
if (!settled) {
|
|
174
|
+
settled = true
|
|
175
|
+
resolve(result)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// onAnimationCancel is always followed by onAnimationEnd — track it so the
|
|
180
|
+
// end callback only commits the destination for a run that truly finished.
|
|
181
|
+
let cancelled = false
|
|
182
|
+
animator.setListener(
|
|
183
|
+
new android.animation.Animator.AnimatorListener({
|
|
184
|
+
onAnimationCancel() {
|
|
185
|
+
cancelled = true
|
|
186
|
+
settle('cancelled')
|
|
187
|
+
},
|
|
188
|
+
onAnimationEnd() {
|
|
189
|
+
if (!cancelled) {
|
|
190
|
+
// ViewPropertyAnimator leaves native props at the destination;
|
|
191
|
+
// committing keeps the NativeScript property cache coherent.
|
|
192
|
+
write(req.dest)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
settle(cancelled ? 'cancelled' : 'finished')
|
|
196
|
+
},
|
|
197
|
+
onAnimationStart() {},
|
|
198
|
+
onAnimationRepeat() {},
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
animator.start()
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
finished,
|
|
206
|
+
sample: () => ({
|
|
207
|
+
opacity: view.getAlpha(),
|
|
208
|
+
x: view.getTranslationX(),
|
|
209
|
+
y: view.getTranslationY(),
|
|
210
|
+
scaleX: view.getScaleX(),
|
|
211
|
+
scaleY: view.getScaleY(),
|
|
212
|
+
rotate: view.getRotation(),
|
|
213
|
+
}),
|
|
214
|
+
cancel() {
|
|
215
|
+
animator.cancel()
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Platform animators drive the presentation while MotionValues track sampled
|
|
221
|
+
// state; interruption stays velocity-exact on the JS engine. Falls back to
|
|
222
|
+
// null anywhere the platform object is missing (tests, unknown targets).
|
|
223
|
+
export function delegatedRun(
|
|
224
|
+
node: any,
|
|
225
|
+
req: DelegatedRequest,
|
|
226
|
+
write: (t: Target) => void,
|
|
227
|
+
): DelegatedRun | null {
|
|
228
|
+
// Dispatch on platform-object presence — no @nativescript/core import, so
|
|
229
|
+
// this module stays loadable in the object-driver test environment. Eases
|
|
230
|
+
// that are not cubic-bezier-expressible stay on the JS engine.
|
|
231
|
+
if (!isBezierEase(req.transition.ease)) {
|
|
232
|
+
delegationStats().fallback++
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const run =
|
|
237
|
+
node.ios && platform.UIViewPropertyAnimator
|
|
238
|
+
? iosRun(node, req, write)
|
|
239
|
+
: platform.android?.animation && (node.nativeViewProtected ?? node.android)
|
|
240
|
+
? androidRun(node, req, write)
|
|
241
|
+
: null
|
|
242
|
+
|
|
243
|
+
if (!run) {
|
|
244
|
+
delegationStats().fallback++
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return trackDelegatedRun(run)
|
|
249
|
+
}
|