@octane-xplat/motion 0.9.0 → 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/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
+ }
@@ -0,0 +1,180 @@
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
+ // All declarative channels map onto compositor-accelerated WAAPI properties:
7
+ // the spatial channels share one transform keyframe pair, opacity animates
8
+ // on its own property.
9
+ const TRANSFORM_KEYS: (keyof Target)[] = ['x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
10
+
11
+ // Matched function lists make WAAPI interpolate each channel numerically —
12
+ // the same order the adapter's write() composes (matrix = T·S·R). The scale
13
+ // channel arrives already folded into scaleX/scaleY on the request's `eff`.
14
+ const transform = (values: Target) =>
15
+ `translateX(${values.x ?? 0}px) translateY(${values.y ?? 0}px) scaleX(${values.scaleX ?? 1}) scaleY(${values.scaleY ?? 1}) rotate(${values.rotate ?? 0}deg)`
16
+
17
+ // A computed transform resolves to a matrix composed around transform-origin;
18
+ // peel the origin back off so rotation/scale do not leak into x/y. For the
19
+ // T·S·R order: a = sx·cos θ, b = sy·sin θ, c = -sx·sin θ, d = sy·cos θ.
20
+ function decompose(v: number[], origin: string): Target {
21
+ const [ox, oy] = origin.split(' ').map((part) => Number.parseFloat(part) || 0)
22
+ const [a, b, c, d, e, f] = v.length === 16 ? [v[0], v[1], v[4], v[5], v[12], v[13]] : v
23
+ const scaleX = Math.hypot(a, c)
24
+ const scaleY = Math.hypot(b, d)
25
+ let rotate = scaleX && scaleY ? (Math.atan2(b / scaleY, a / scaleX) * 180) / Math.PI : 0
26
+ if (!Number.isFinite(rotate)) {
27
+ rotate = 0
28
+ }
29
+
30
+ let flippedY = scaleY
31
+ // A negative determinant means one axis flipped; folding the sign into
32
+ // scaleY and the angle round-trips the same matrix for every composition.
33
+ if (a * d - b * c < 0) {
34
+ flippedY = -scaleY
35
+ rotate = -rotate
36
+ }
37
+
38
+ return {
39
+ x: e + (a - 1) * ox + c * oy,
40
+ y: f + b * ox + (d - 1) * oy,
41
+ scaleX,
42
+ scaleY: flippedY,
43
+ rotate,
44
+ }
45
+ }
46
+
47
+ function readChannels(node: HTMLElement, fallback: Target): Target {
48
+ const computed = getComputedStyle(node)
49
+ const opacity = Number(computed.opacity)
50
+ const base: Target = {
51
+ ...fallback,
52
+ opacity: Number.isFinite(opacity) ? opacity : (fallback.opacity ?? 1),
53
+ }
54
+
55
+ const match = /matrix(?:3d)?\(([^)]*)\)/.exec(computed.transform)
56
+ if (!match) {
57
+ return computed.transform === 'none'
58
+ ? { ...base, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 }
59
+ : base
60
+ }
61
+
62
+ const values = match[1].split(',').map(Number.parseFloat)
63
+ return values.every(Number.isFinite)
64
+ ? { ...base, ...decompose(values, computed.transformOrigin) }
65
+ : base
66
+ }
67
+
68
+ function waapiRun(
69
+ node: HTMLElement,
70
+ req: DelegatedRequest,
71
+ write: (t: Target) => void,
72
+ ): DelegatedRun | null {
73
+ if (typeof node.animate !== 'function') {
74
+ return null
75
+ }
76
+
77
+ const { target, eff, transition: t } = req
78
+ const animatesTransform = TRANSFORM_KEYS.some((key) => target[key] !== undefined)
79
+ if (!animatesTransform && target.opacity === undefined) {
80
+ return null
81
+ }
82
+
83
+ // Explicit keyframe pairs keep the interpolation in matched-list mode; an
84
+ // implicit "from" resolves to a matrix and would lose rotate windings.
85
+ let last = readChannels(node, { opacity: 1, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 })
86
+ const frames: Keyframe[] = [{}, {}]
87
+ if (animatesTransform) {
88
+ frames[0].transform = transform(last)
89
+ frames[1].transform = transform(eff)
90
+ }
91
+
92
+ if (target.opacity !== undefined) {
93
+ frames[0].opacity = last.opacity ?? 1
94
+ frames[1].opacity = Math.min(1, Math.max(0, eff.opacity ?? 1))
95
+ }
96
+
97
+ const [x1, y1, x2, y2] = bezierPoints(t.ease)
98
+ let animation: Animation
99
+ try {
100
+ animation = node.animate(frames, {
101
+ duration: (t.duration ?? 0.3) * 1000,
102
+ delay: (t.delay ?? 0) * 1000,
103
+ easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`,
104
+ fill: 'forwards',
105
+ })
106
+ } catch {
107
+ return null
108
+ }
109
+
110
+ const sample = (): Target => {
111
+ last = readChannels(node, last)
112
+ return { ...last }
113
+ }
114
+
115
+ let resolve!: (result: 'finished' | 'cancelled') => void
116
+ const finished = new Promise<'finished' | 'cancelled'>((done) => {
117
+ resolve = done
118
+ })
119
+
120
+ let settled = false
121
+ const settle = (result: 'finished' | 'cancelled') => {
122
+ if (!settled) {
123
+ settled = true
124
+ resolve(result)
125
+ }
126
+ }
127
+
128
+ // The platform run owns presentation; sample() reads the composited state.
129
+ // On finish the destination is committed to the base styles before the
130
+ // fill is released, so later adapter writes still apply.
131
+ const complete = () => {
132
+ write(req.dest)
133
+ settle('finished')
134
+ animation.cancel()
135
+ }
136
+
137
+ const cancelled = () => settle('cancelled')
138
+ if (typeof animation.finished?.then === 'function') {
139
+ // finished rejects with AbortError on cancel — observed here.
140
+ void animation.finished.then(complete, cancelled)
141
+ } else {
142
+ animation.onfinish = complete
143
+ animation.oncancel = cancelled
144
+ }
145
+
146
+ return {
147
+ finished,
148
+ sample,
149
+ cancel() {
150
+ // Cancel reverts the element to its base styles synchronously, so the
151
+ // presentation is sampled first and the adapter write holds the
152
+ // frozen values for the next run's "from" keyframe.
153
+ const current = sample()
154
+ animation.cancel()
155
+ write(current)
156
+ settle('cancelled')
157
+ },
158
+ }
159
+ }
160
+
161
+ // Mirrors the native delegatedRun contract: cubic-bezier eases only, null on
162
+ // any missing capability so the caller stays on the JS engine.
163
+ export function delegatedRun(
164
+ node: HTMLElement,
165
+ req: DelegatedRequest,
166
+ write: (t: Target) => void,
167
+ ): DelegatedRun | null {
168
+ if (!isBezierEase(req.transition.ease)) {
169
+ delegationStats().fallback++
170
+ return null
171
+ }
172
+
173
+ const run = waapiRun(node, req, write)
174
+ if (!run) {
175
+ delegationStats().fallback++
176
+ return null
177
+ }
178
+
179
+ return trackDelegatedRun(run)
180
+ }
@@ -137,3 +137,89 @@ describe('portable playback', () => {
137
137
  controller.destroy()
138
138
  })
139
139
  })
140
+
141
+ describe('upstream-shaped transitions', () => {
142
+ it('repeats a tween for the given number of additional legs', async () => {
143
+ const time = fakeClock()
144
+ const seen: number[] = []
145
+ const controls = runAnimation(
146
+ time.clock,
147
+ 0,
148
+ 100,
149
+ { duration: 0.1, ease: 'linear', repeat: 1 },
150
+ (value) => seen.push(value),
151
+ )
152
+
153
+ time.advance(120)
154
+ expect(seen.at(-1)).toBe(100)
155
+ time.advance(50)
156
+ expect(seen.at(-1)).toBe(50)
157
+ time.advance(120)
158
+ expect(seen.at(-1)).toBe(100)
159
+ expect(await controls.finished).toBe('finished')
160
+ })
161
+
162
+ it('reverses each repeated leg under repeatType reverse', async () => {
163
+ const time = fakeClock()
164
+ const seen: number[] = []
165
+ const controls = runAnimation(
166
+ time.clock,
167
+ 0,
168
+ 100,
169
+ { duration: 0.1, ease: 'linear', repeat: 1, repeatType: 'reverse' },
170
+ (value) => seen.push(value),
171
+ )
172
+
173
+ time.advance(120)
174
+ time.advance(50)
175
+ expect(seen.at(-1)).toBe(50)
176
+ time.advance(120)
177
+ expect(seen.at(-1)).toBe(0)
178
+ expect(await controls.finished).toBe('finished')
179
+ })
180
+
181
+ it('accepts a duration/bounce spring spec', async () => {
182
+ const time = fakeClock()
183
+ let value = 0
184
+ const controls = runAnimation(
185
+ time.clock,
186
+ 0,
187
+ 100,
188
+ { type: 'spring', duration: 0.4, bounce: 0.2 },
189
+ (next) => {
190
+ value = next
191
+ },
192
+ )
193
+
194
+ for (let i = 0; i < 100; i++) {
195
+ time.advance(16)
196
+ }
197
+
198
+ expect(await controls.finished).toBe('finished')
199
+ expect(value).toBe(100)
200
+ })
201
+
202
+ it('accepts the full named-ease set on the JS engine', async () => {
203
+ for (const ease of ['circIn', 'backOut', 'anticipate', 'circInOut', 'backInOut'] as const) {
204
+ const time = fakeClock()
205
+ let value = 0
206
+ const controls = runAnimation(time.clock, 0, 50, { duration: 0.1, ease }, (next) => {
207
+ value = next
208
+ })
209
+
210
+ for (let i = 0; i < 20; i++) {
211
+ time.advance(16)
212
+ }
213
+
214
+ expect(await controls.finished).toBe('finished')
215
+ expect(value).toBe(50)
216
+ }
217
+ })
218
+
219
+ it('rejects fractional repeats', () => {
220
+ const time = fakeClock()
221
+ expect(() => runAnimation(time.clock, 0, 1, { duration: 1, repeat: 1.5 }, () => {})).toThrow(
222
+ 'repeat',
223
+ )
224
+ })
225
+ })