@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/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
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
|
+
}
|
package/src/engine.test.ts
CHANGED
|
@@ -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
|
+
})
|