@rafaelaugusto/jolt 0.1.0-beta.8 → 0.1.0-beta.9
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 +3 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +40 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +10 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-layout.d.ts +9 -0
- package/dist/driver/ios-layout.d.ts.map +1 -0
- package/dist/driver/ios-layout.js +52 -0
- package/dist/driver/ios-layout.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +48 -1
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +4 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +17 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +13 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +348 -0
- package/dist/layout.js.map +1 -0
- package/dist/motion-scope.d.ts +2 -0
- package/dist/motion-scope.d.ts.map +1 -1
- package/dist/motion-scope.js +6 -0
- package/dist/motion-scope.js.map +1 -1
- package/package.json +1 -1
- package/src/driver/android.ts +38 -0
- package/src/driver/index.ts +11 -0
- package/src/driver/ios-layout.ts +63 -0
- package/src/driver/ios.ts +60 -17
- package/src/engine.ts +18 -0
- package/src/index.ts +3 -0
- package/src/layout.ts +408 -0
- package/src/motion-scope.ts +14 -0
package/src/driver/ios.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { isColorProperty } from '../color.js'
|
|
|
12
12
|
import { playIOSColor, presentedIOSColor } from './ios-color.js'
|
|
13
13
|
import { after, type Driver } from './index.js'
|
|
14
14
|
import { hasInteractionSurface } from '../interaction-surface.js'
|
|
15
|
+
import { retainInteractiveLayout } from './ios-layout.js'
|
|
15
16
|
|
|
16
17
|
interface Handle {
|
|
17
18
|
stop(): void
|
|
@@ -72,28 +73,36 @@ export function createIOSDriver(): Driver {
|
|
|
72
73
|
throw new Error('Rebuild the iOS app to enable Jolt interaction surfaces.')
|
|
73
74
|
if (track.property === 'rotateX' || track.property === 'rotateY')
|
|
74
75
|
ensurePerspective(view, layers)
|
|
76
|
+
const releaseLayout = retainInteractiveLayout(view)
|
|
75
77
|
const suspend = () => stopView(view, [track.property])
|
|
76
78
|
const completed = (finished: boolean) => {
|
|
77
79
|
Application.off(Application.suspendEvent, suspend)
|
|
80
|
+
releaseLayout()
|
|
78
81
|
onEnd(finished)
|
|
79
82
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
track.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
83
|
+
let native: JoltInteractiveAnimation
|
|
84
|
+
try {
|
|
85
|
+
native =
|
|
86
|
+
JoltInteractiveAnimation.startViewLayersKeyPathValuesTimesDurationDelayRepeatReverseFinalValueStartedAtCompletion(
|
|
87
|
+
view.nativeViewProtected,
|
|
88
|
+
NSArray.arrayWithArray(layers),
|
|
89
|
+
keyPaths[track.property as NumericProperty],
|
|
90
|
+
NSArray.arrayWithArray(
|
|
91
|
+
track.values.map((value) => (rotation ? radians(value) : value)),
|
|
92
|
+
),
|
|
93
|
+
NSArray.arrayWithArray(track.times as number[]),
|
|
94
|
+
track.duration,
|
|
95
|
+
track.delay,
|
|
96
|
+
track.repeat,
|
|
97
|
+
track.reverse,
|
|
98
|
+
rotation ? radians(track.to) : track.to,
|
|
99
|
+
startedAt,
|
|
100
|
+
completed,
|
|
101
|
+
)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
releaseLayout()
|
|
104
|
+
throw error
|
|
105
|
+
}
|
|
97
106
|
Application.on(Application.suspendEvent, suspend)
|
|
98
107
|
return {
|
|
99
108
|
elapsed: () => native.elapsed,
|
|
@@ -201,6 +210,40 @@ export function createIOSDriver(): Driver {
|
|
|
201
210
|
return property.startsWith('rotate') ? (value * 180) / Math.PI : Number(value)
|
|
202
211
|
},
|
|
203
212
|
|
|
213
|
+
layoutFrame(view) {
|
|
214
|
+
const native = view.nativeViewProtected as UIView | undefined
|
|
215
|
+
if (!native) return undefined
|
|
216
|
+
const { width, height } = native.bounds.size
|
|
217
|
+
const { position, anchorPoint } = native.layer
|
|
218
|
+
return {
|
|
219
|
+
x: position.x - width * anchorPoint.x,
|
|
220
|
+
y: position.y - height * anchorPoint.y,
|
|
221
|
+
width,
|
|
222
|
+
height,
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
afterLayout(view, callback) {
|
|
227
|
+
const original = view.layout
|
|
228
|
+
const descriptor = Object.getOwnPropertyDescriptor(view, 'layout')
|
|
229
|
+
let active = true
|
|
230
|
+
const detach = () => {
|
|
231
|
+
active = false
|
|
232
|
+
if (view.layout !== wrapper) return
|
|
233
|
+
if (descriptor) Object.defineProperty(view, 'layout', descriptor)
|
|
234
|
+
else delete (view as Partial<View>).layout
|
|
235
|
+
}
|
|
236
|
+
const wrapper: View['layout'] = function (this: View, ...args) {
|
|
237
|
+
original.apply(this, args)
|
|
238
|
+
if (active) {
|
|
239
|
+
detach()
|
|
240
|
+
callback()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
view.layout = wrapper
|
|
244
|
+
return detach
|
|
245
|
+
},
|
|
246
|
+
|
|
204
247
|
after,
|
|
205
248
|
|
|
206
249
|
reducedMotion: () => UIAccessibilityIsReduceMotionEnabled(),
|
package/src/engine.ts
CHANGED
|
@@ -447,6 +447,24 @@ export function currentValue(view: View, property: Property): number {
|
|
|
447
447
|
)
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
+
/** Internal property ownership marker; layout cleanup must preserve newer animations. */
|
|
451
|
+
export function propertyRevision(view: View, property: Property) {
|
|
452
|
+
return revisions.get(view)?.get(property)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Sample an existing owner at layout time, without stealing an animation started meanwhile. */
|
|
456
|
+
export function captureValue(view: View, property: Property): () => number | undefined {
|
|
457
|
+
const token = propertyRevision(view, property)
|
|
458
|
+
const entry = running.get(view)?.get(property)
|
|
459
|
+
const initial = currentValue(view, property)
|
|
460
|
+
return () => {
|
|
461
|
+
if (propertyRevision(view, property) !== token) return undefined
|
|
462
|
+
if (entry && running.get(view)?.get(property) === entry)
|
|
463
|
+
return currentValue(view, property)
|
|
464
|
+
return entry?.finished ? entry.track.to : initial
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
450
468
|
/** Stops whatever is animating these properties of the view, or all of them. */
|
|
451
469
|
export function stopView(view: View, properties?: readonly Property[]) {
|
|
452
470
|
const tokens = revisions.get(view)
|
package/src/index.ts
CHANGED
package/src/layout.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Application, type View } from '@nativescript/core'
|
|
2
|
+
import { prepareAnimation, toView } from './animate.js'
|
|
3
|
+
import { getDriver, type LayoutFrame } from './driver/index.js'
|
|
4
|
+
import {
|
|
5
|
+
captureValue,
|
|
6
|
+
propertyRevision,
|
|
7
|
+
arrivalOf,
|
|
8
|
+
setArrival,
|
|
9
|
+
type Animation,
|
|
10
|
+
} from './engine.js'
|
|
11
|
+
import { interactionSurface } from './interaction-surface.js'
|
|
12
|
+
import { readValue, writeValue, type NumericProperty } from './properties.js'
|
|
13
|
+
import { springs, type Spring, type Tween, type SpringName } from './transition.js'
|
|
14
|
+
import type { Keyframes, Target } from './types.js'
|
|
15
|
+
|
|
16
|
+
export type LayoutOptions = (
|
|
17
|
+
Omit<Spring, 'repeat' | 'repeatType'> | Omit<Tween, 'repeat' | 'repeatType'>
|
|
18
|
+
) & {
|
|
19
|
+
/** Scale from the previous size (including content); false snaps size and only moves. Default: 'scale'. */
|
|
20
|
+
size?: 'scale' | false
|
|
21
|
+
/** Fade new children from zero to their configured opacity. Default: true. */
|
|
22
|
+
enter?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Geometry = 'x' | 'y' | 'scaleX' | 'scaleY'
|
|
26
|
+
interface Snapshot {
|
|
27
|
+
frame: LayoutFrame
|
|
28
|
+
originX: number
|
|
29
|
+
originY: number
|
|
30
|
+
rotate: number
|
|
31
|
+
rotateX: number
|
|
32
|
+
rotateY: number
|
|
33
|
+
values: Record<Geometry, () => number | undefined>
|
|
34
|
+
}
|
|
35
|
+
interface Owned {
|
|
36
|
+
view: View
|
|
37
|
+
handle: Animation
|
|
38
|
+
detach(): void
|
|
39
|
+
targets: { property: NumericProperty; value: number; token: symbol | undefined }[]
|
|
40
|
+
}
|
|
41
|
+
const pending = new WeakMap<View, Animation>()
|
|
42
|
+
const playing = new WeakMap<View, Set<Animation>>()
|
|
43
|
+
const properties = ['x', 'y', 'scaleX', 'scaleY'] as const
|
|
44
|
+
|
|
45
|
+
/** Animate the next layout of a container's direct children. Call before a synchronous mutation. */
|
|
46
|
+
export function animateLayout(
|
|
47
|
+
container: Target,
|
|
48
|
+
options?: LayoutOptions | SpringName,
|
|
49
|
+
): Animation
|
|
50
|
+
export function animateLayout(
|
|
51
|
+
container: Target,
|
|
52
|
+
change: () => void,
|
|
53
|
+
options?: LayoutOptions | SpringName,
|
|
54
|
+
): Animation
|
|
55
|
+
export function animateLayout(
|
|
56
|
+
container: Target,
|
|
57
|
+
changeOrOptions?: (() => void) | LayoutOptions | SpringName,
|
|
58
|
+
options?: LayoutOptions | SpringName,
|
|
59
|
+
): Animation {
|
|
60
|
+
const host = toView(container)
|
|
61
|
+
const change = typeof changeOrOptions === 'function' ? changeOrOptions : undefined
|
|
62
|
+
const input = change ? options : changeOrOptions
|
|
63
|
+
const {
|
|
64
|
+
size = 'scale',
|
|
65
|
+
enter = true,
|
|
66
|
+
...transition
|
|
67
|
+
} = normalize(input as LayoutOptions | SpringName | undefined)
|
|
68
|
+
// Validate the complete transition before changing children or cancelling an earlier request.
|
|
69
|
+
prepareAnimation(
|
|
70
|
+
host,
|
|
71
|
+
{ x: [0, 1], y: [0, 1], scaleX: [1, 2], scaleY: [1, 2], opacity: [0, 1] },
|
|
72
|
+
transition,
|
|
73
|
+
)
|
|
74
|
+
const driver = getDriver()
|
|
75
|
+
const before = new Map<View, Snapshot>()
|
|
76
|
+
const ready = laidOut(host)
|
|
77
|
+
if (ready)
|
|
78
|
+
for (const child of childrenOf(host)) {
|
|
79
|
+
const frame = frameOf(child)
|
|
80
|
+
if (frame)
|
|
81
|
+
before.set(child, {
|
|
82
|
+
frame,
|
|
83
|
+
originX: child.originX,
|
|
84
|
+
originY: child.originY,
|
|
85
|
+
rotate: child.rotate,
|
|
86
|
+
rotateX: child.rotateX,
|
|
87
|
+
rotateY: child.rotateY,
|
|
88
|
+
values: {
|
|
89
|
+
x: captureValue(child, 'x'),
|
|
90
|
+
y: captureValue(child, 'y'),
|
|
91
|
+
scaleX: captureValue(child, 'scaleX'),
|
|
92
|
+
scaleY: captureValue(child, 'scaleY'),
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let resolve!: (finished: boolean) => void
|
|
98
|
+
let reject!: (error: unknown) => void
|
|
99
|
+
const finished = new Promise<boolean>((done, fail) => {
|
|
100
|
+
resolve = done
|
|
101
|
+
reject = fail
|
|
102
|
+
})
|
|
103
|
+
let arrive!: () => void
|
|
104
|
+
const arrival = new Promise<void>((done) => (arrive = done))
|
|
105
|
+
const owned: Owned[] = []
|
|
106
|
+
let settled = false
|
|
107
|
+
let detach: (() => void) | undefined
|
|
108
|
+
const cleanup = () => {
|
|
109
|
+
detach?.()
|
|
110
|
+
detach = undefined
|
|
111
|
+
host.off('unloaded', stop)
|
|
112
|
+
Application.off(Application.suspendEvent, stop)
|
|
113
|
+
if (pending.get(host) === handle) pending.delete(host)
|
|
114
|
+
const active = playing.get(host)
|
|
115
|
+
active?.delete(handle)
|
|
116
|
+
if (!active?.size) playing.delete(host)
|
|
117
|
+
before.clear()
|
|
118
|
+
owned.forEach((child) => child.detach())
|
|
119
|
+
arrive()
|
|
120
|
+
}
|
|
121
|
+
const complete = (result: boolean, error?: unknown) => {
|
|
122
|
+
if (settled) return
|
|
123
|
+
settled = true
|
|
124
|
+
cleanup()
|
|
125
|
+
if (error !== undefined) reject(error)
|
|
126
|
+
else resolve(result)
|
|
127
|
+
}
|
|
128
|
+
// A cancelled FLIP settles into the real layout rather than leaving a temporary inverse offset.
|
|
129
|
+
// The engine marker protects any animation that took ownership after this operation.
|
|
130
|
+
const restoreOne = ({ view, handle: child, targets }: Owned) => {
|
|
131
|
+
const current = targets.filter(
|
|
132
|
+
(target) => propertyRevision(view, target.property) === target.token,
|
|
133
|
+
)
|
|
134
|
+
child.stop()
|
|
135
|
+
for (const target of current)
|
|
136
|
+
if (propertyRevision(view, target.property) === target.token)
|
|
137
|
+
writeValue(view, target.property, target.value)
|
|
138
|
+
if (current.length) driver.sync?.(view)
|
|
139
|
+
}
|
|
140
|
+
const restore = () => {
|
|
141
|
+
owned.forEach(restoreOne)
|
|
142
|
+
}
|
|
143
|
+
function stop() {
|
|
144
|
+
if (settled) return
|
|
145
|
+
restore()
|
|
146
|
+
complete(false)
|
|
147
|
+
}
|
|
148
|
+
const handle: Animation = {
|
|
149
|
+
finished,
|
|
150
|
+
stop,
|
|
151
|
+
then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
|
|
152
|
+
}
|
|
153
|
+
setArrival(handle, arrival)
|
|
154
|
+
const run = () => {
|
|
155
|
+
if (settled) return
|
|
156
|
+
detach?.()
|
|
157
|
+
detach = undefined
|
|
158
|
+
if (!laidOut(host)) {
|
|
159
|
+
stop()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (pending.get(host) === handle) pending.delete(host)
|
|
163
|
+
const previous = [...(playing.get(host) ?? [])]
|
|
164
|
+
try {
|
|
165
|
+
const prepared: { view: View; frames: Keyframes; start: () => Animation }[] = []
|
|
166
|
+
for (const child of childrenOf(host)) {
|
|
167
|
+
const now = frameOf(child)
|
|
168
|
+
if (!now) continue
|
|
169
|
+
const was = before.get(child)
|
|
170
|
+
const frames = was
|
|
171
|
+
? inverse(child, was, now, size)
|
|
172
|
+
: enter
|
|
173
|
+
? { opacity: [0, child.opacity] }
|
|
174
|
+
: undefined
|
|
175
|
+
if (frames && Object.keys(frames).length)
|
|
176
|
+
prepared.push({
|
|
177
|
+
view: child,
|
|
178
|
+
frames,
|
|
179
|
+
start: prepareAnimation(child, frames, transition),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
before.clear()
|
|
183
|
+
// Selection lasts only for starting these tracks; their native geometry driver stays until completion.
|
|
184
|
+
for (const { view, frames, start } of prepared) {
|
|
185
|
+
const surface = interactionSurface(view)
|
|
186
|
+
try {
|
|
187
|
+
const child = start()
|
|
188
|
+
const targets = Object.entries(frames).map(([property, frames]) => ({
|
|
189
|
+
property: property as NumericProperty,
|
|
190
|
+
value: (frames as number[]).at(-1)!,
|
|
191
|
+
token: propertyRevision(view, property as NumericProperty),
|
|
192
|
+
}))
|
|
193
|
+
const item: Owned = {
|
|
194
|
+
view,
|
|
195
|
+
handle: child,
|
|
196
|
+
targets,
|
|
197
|
+
detach: () => view.off('unloaded', unloaded),
|
|
198
|
+
}
|
|
199
|
+
const unloaded = () => {
|
|
200
|
+
item.detach()
|
|
201
|
+
if (!settled) restoreOne(item)
|
|
202
|
+
}
|
|
203
|
+
// Keyed reorder may unload/reinsert the same native view. Do not persist its FLIP offset.
|
|
204
|
+
view.on('unloaded', unloaded)
|
|
205
|
+
owned.push(item)
|
|
206
|
+
} finally {
|
|
207
|
+
surface.dispose()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const active = playing.get(host) ?? new Set<Animation>()
|
|
211
|
+
active.add(handle)
|
|
212
|
+
playing.set(host, active)
|
|
213
|
+
// Replacements start before old handles stop so the engine can transfer velocity.
|
|
214
|
+
for (const old of previous) old.stop()
|
|
215
|
+
void Promise.all(owned.map(({ handle }) => arrivalOf(handle))).then(arrive)
|
|
216
|
+
void Promise.all(owned.map(({ handle }) => handle.finished)).then(
|
|
217
|
+
(results) => complete(results.every(Boolean)),
|
|
218
|
+
(error) => {
|
|
219
|
+
restore()
|
|
220
|
+
complete(false, error)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
} catch (error) {
|
|
224
|
+
restore()
|
|
225
|
+
complete(false, error)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
pending.get(host)?.stop()
|
|
230
|
+
pending.set(host, handle)
|
|
231
|
+
host.on('unloaded', stop)
|
|
232
|
+
Application.on(Application.suspendEvent, stop)
|
|
233
|
+
try {
|
|
234
|
+
const result = change?.() as unknown
|
|
235
|
+
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
|
236
|
+
void Promise.resolve(result).catch(() => {})
|
|
237
|
+
throw new TypeError(
|
|
238
|
+
'Layout mutations must be synchronous. Call animateLayout after awaited work.',
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (!settled) {
|
|
242
|
+
if (!ready || !laidOut(host)) complete(true)
|
|
243
|
+
else {
|
|
244
|
+
if (!driver.afterLayout || !driver.layoutFrame)
|
|
245
|
+
throw new Error('The animation driver does not support layout changes.')
|
|
246
|
+
detach = driver.afterLayout(host, run)
|
|
247
|
+
// Always request a pass, including a no-op mutation: no arbitrary timer or hanging wait.
|
|
248
|
+
host.requestLayout()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
stop()
|
|
253
|
+
throw error
|
|
254
|
+
}
|
|
255
|
+
return handle
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalize(input: LayoutOptions | SpringName | undefined): LayoutOptions {
|
|
259
|
+
if (typeof input === 'string') {
|
|
260
|
+
if (!Object.hasOwn(springs, input))
|
|
261
|
+
throw new TypeError(`Unknown layout spring "${input}".`)
|
|
262
|
+
return normalize({ ...springs[input] })
|
|
263
|
+
}
|
|
264
|
+
if (input === undefined) return {}
|
|
265
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
266
|
+
throw new TypeError('Invalid layout options.')
|
|
267
|
+
const allowed = new Set([
|
|
268
|
+
'type',
|
|
269
|
+
'duration',
|
|
270
|
+
'ease',
|
|
271
|
+
'times',
|
|
272
|
+
'stiffness',
|
|
273
|
+
'damping',
|
|
274
|
+
'mass',
|
|
275
|
+
'bounce',
|
|
276
|
+
'velocity',
|
|
277
|
+
'delay',
|
|
278
|
+
'size',
|
|
279
|
+
'enter',
|
|
280
|
+
])
|
|
281
|
+
for (const key of Object.keys(input))
|
|
282
|
+
if (!allowed.has(key)) throw new TypeError(`Unsupported layout option "${key}".`)
|
|
283
|
+
if (input.size !== undefined && input.size !== false && input.size !== 'scale')
|
|
284
|
+
throw new TypeError('Layout size must be "scale" or false.')
|
|
285
|
+
if (input.enter !== undefined && typeof input.enter !== 'boolean')
|
|
286
|
+
throw new TypeError('Layout enter must be boolean.')
|
|
287
|
+
if (input.type !== undefined && input.type !== 'spring' && input.type !== 'tween')
|
|
288
|
+
throw new TypeError('Layout type must be "spring" or "tween".')
|
|
289
|
+
const numbers = input as Record<string, unknown>
|
|
290
|
+
for (const key of [
|
|
291
|
+
'duration',
|
|
292
|
+
'delay',
|
|
293
|
+
'stiffness',
|
|
294
|
+
'damping',
|
|
295
|
+
'mass',
|
|
296
|
+
'bounce',
|
|
297
|
+
'velocity',
|
|
298
|
+
]) {
|
|
299
|
+
const value = numbers[key]
|
|
300
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value)))
|
|
301
|
+
throw new RangeError(`Layout ${key} must be finite.`)
|
|
302
|
+
if (typeof value === 'number') {
|
|
303
|
+
if (['duration', 'delay', 'damping'].includes(key) && value < 0)
|
|
304
|
+
throw new RangeError(`Layout ${key} must be nonnegative.`)
|
|
305
|
+
if (['stiffness', 'mass'].includes(key) && value <= 0)
|
|
306
|
+
throw new RangeError(`Layout ${key} must be positive.`)
|
|
307
|
+
if (key === 'bounce' && (value < -1 || value > 1))
|
|
308
|
+
throw new RangeError('Layout bounce must be between -1 and 1.')
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const physical = ['stiffness', 'damping', 'mass'].some(
|
|
312
|
+
(key) => numbers[key] !== undefined,
|
|
313
|
+
)
|
|
314
|
+
const spring =
|
|
315
|
+
input.type === 'spring' ||
|
|
316
|
+
(input.type !== 'tween' &&
|
|
317
|
+
['bounce', 'velocity', 'stiffness', 'damping', 'mass'].some(
|
|
318
|
+
(key) => numbers[key] !== undefined,
|
|
319
|
+
))
|
|
320
|
+
if (spring && !physical && input.duration === 0)
|
|
321
|
+
throw new RangeError(
|
|
322
|
+
'A duration-based layout spring must have positive duration; use a zero-duration tween to settle immediately.',
|
|
323
|
+
)
|
|
324
|
+
const result = { ...input }
|
|
325
|
+
if ('ease' in result && Array.isArray(result.ease)) result.ease = [...result.ease]
|
|
326
|
+
if ('times' in result && result.times) result.times = [...result.times]
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function inverse(
|
|
331
|
+
view: View,
|
|
332
|
+
was: Snapshot,
|
|
333
|
+
now: LayoutFrame,
|
|
334
|
+
size: LayoutOptions['size'],
|
|
335
|
+
): Keyframes | undefined {
|
|
336
|
+
// Origin/rotation changes require a different affine transform, not a translation/scale FLIP.
|
|
337
|
+
if (
|
|
338
|
+
view.originX !== was.originX ||
|
|
339
|
+
view.originY !== was.originY ||
|
|
340
|
+
view.rotate !== was.rotate ||
|
|
341
|
+
was.rotateX ||
|
|
342
|
+
was.rotateY ||
|
|
343
|
+
view.rotateX ||
|
|
344
|
+
view.rotateY
|
|
345
|
+
)
|
|
346
|
+
return undefined
|
|
347
|
+
const frames: Keyframes = {}
|
|
348
|
+
const width = was.frame.width - now.width,
|
|
349
|
+
height = was.frame.height - now.height
|
|
350
|
+
const resized = Math.abs(width) > 0.25 || Math.abs(height) > 0.25
|
|
351
|
+
for (const property of properties) {
|
|
352
|
+
const current = was.values[property]()
|
|
353
|
+
if (current === undefined) continue // A newer explicit animation owns this property.
|
|
354
|
+
let start: number
|
|
355
|
+
if (property === 'x')
|
|
356
|
+
start =
|
|
357
|
+
was.frame.x -
|
|
358
|
+
now.x +
|
|
359
|
+
current +
|
|
360
|
+
(size === 'scale' && resized ? width * was.originX : 0)
|
|
361
|
+
else if (property === 'y')
|
|
362
|
+
start =
|
|
363
|
+
was.frame.y -
|
|
364
|
+
now.y +
|
|
365
|
+
current +
|
|
366
|
+
(size === 'scale' && resized ? height * was.originY : 0)
|
|
367
|
+
else {
|
|
368
|
+
if (size !== 'scale') continue
|
|
369
|
+
start =
|
|
370
|
+
current *
|
|
371
|
+
(property === 'scaleX'
|
|
372
|
+
? was.frame.width / now.width
|
|
373
|
+
: was.frame.height / now.height)
|
|
374
|
+
}
|
|
375
|
+
const end = readValue(view, property)
|
|
376
|
+
if (Math.abs(start - end) > (property.startsWith('scale') ? 0.0001 : 0.25))
|
|
377
|
+
frames[property] = [start, end]
|
|
378
|
+
}
|
|
379
|
+
return frames
|
|
380
|
+
}
|
|
381
|
+
function laidOut(view: View): boolean {
|
|
382
|
+
if (!view.isLoaded || !view.nativeViewProtected) return false
|
|
383
|
+
for (
|
|
384
|
+
let current: View | undefined = view;
|
|
385
|
+
current;
|
|
386
|
+
current = current.parent as View | undefined
|
|
387
|
+
)
|
|
388
|
+
if (current.isCollapsed) return false
|
|
389
|
+
return true
|
|
390
|
+
}
|
|
391
|
+
function frameOf(view: View): LayoutFrame | undefined {
|
|
392
|
+
if (!laidOut(view)) return undefined
|
|
393
|
+
const frame = getDriver().layoutFrame?.(view)
|
|
394
|
+
return frame &&
|
|
395
|
+
Object.values(frame).every(Number.isFinite) &&
|
|
396
|
+
frame.width > 0 &&
|
|
397
|
+
frame.height > 0
|
|
398
|
+
? frame
|
|
399
|
+
: undefined
|
|
400
|
+
}
|
|
401
|
+
function childrenOf(host: View): View[] {
|
|
402
|
+
const children: View[] = []
|
|
403
|
+
host.eachChildView((child) => {
|
|
404
|
+
children.push(child)
|
|
405
|
+
return true
|
|
406
|
+
})
|
|
407
|
+
return children
|
|
408
|
+
}
|
package/src/motion-scope.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { animate, decay } from './animate.js'
|
|
2
2
|
import { press } from './press.js'
|
|
3
3
|
import { drag } from './drag.js'
|
|
4
|
+
import { animateLayout, type LayoutOptions } from './layout.js'
|
|
5
|
+
import type { SpringName } from './transition.js'
|
|
4
6
|
import { sequence } from './sequence.js'
|
|
5
7
|
import {
|
|
6
8
|
motionStates,
|
|
@@ -31,6 +33,7 @@ export interface MotionScope {
|
|
|
31
33
|
readonly suspended: boolean
|
|
32
34
|
/** Owned handles/subscriptions, including suspended subscription factories. */
|
|
33
35
|
readonly size: number
|
|
36
|
+
animateLayout: typeof animateLayout
|
|
34
37
|
animate: typeof animate
|
|
35
38
|
decay: typeof decay
|
|
36
39
|
sequence: typeof sequence
|
|
@@ -175,6 +178,17 @@ export function motionScope(): MotionScope {
|
|
|
175
178
|
get size() {
|
|
176
179
|
return entries.size
|
|
177
180
|
},
|
|
181
|
+
animateLayout(
|
|
182
|
+
container: Target,
|
|
183
|
+
changeOrOptions?: (() => void) | LayoutOptions | SpringName,
|
|
184
|
+
options?: LayoutOptions | SpringName,
|
|
185
|
+
) {
|
|
186
|
+
return startOwned(() =>
|
|
187
|
+
typeof changeOrOptions === 'function'
|
|
188
|
+
? animateLayout(container, changeOrOptions, options)
|
|
189
|
+
: animateLayout(container, changeOrOptions),
|
|
190
|
+
)
|
|
191
|
+
},
|
|
178
192
|
animate(...args) {
|
|
179
193
|
return startOwned(() => animate(...args))
|
|
180
194
|
},
|