@rafaelaugusto/jolt 0.1.0-beta.10 → 0.1.0-beta.12

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.
Files changed (92) hide show
  1. package/README.md +55 -6
  2. package/dist/animate.d.ts +14 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +50 -34
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.d.ts +17 -3
  7. package/dist/drag.d.ts.map +1 -1
  8. package/dist/drag.js +34 -11
  9. package/dist/drag.js.map +1 -1
  10. package/dist/driver/android-gradient.d.ts +5 -0
  11. package/dist/driver/android-gradient.d.ts.map +1 -0
  12. package/dist/driver/android-gradient.js +89 -0
  13. package/dist/driver/android-gradient.js.map +1 -0
  14. package/dist/driver/ios-gradient.d.ts +5 -0
  15. package/dist/driver/ios-gradient.d.ts.map +1 -0
  16. package/dist/driver/ios-gradient.js +109 -0
  17. package/dist/driver/ios-gradient.js.map +1 -0
  18. package/dist/easing.d.ts +1 -1
  19. package/dist/easing.d.ts.map +1 -1
  20. package/dist/easing.js +11 -0
  21. package/dist/easing.js.map +1 -1
  22. package/dist/engine.d.ts +18 -1
  23. package/dist/engine.d.ts.map +1 -1
  24. package/dist/engine.js +143 -36
  25. package/dist/engine.js.map +1 -1
  26. package/dist/gradient.d.ts +90 -0
  27. package/dist/gradient.d.ts.map +1 -0
  28. package/dist/gradient.js +253 -0
  29. package/dist/gradient.js.map +1 -0
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/keyboard.d.ts.map +1 -1
  35. package/dist/keyboard.js +63 -10
  36. package/dist/keyboard.js.map +1 -1
  37. package/dist/mixer.d.ts +49 -0
  38. package/dist/mixer.d.ts.map +1 -0
  39. package/dist/mixer.js +238 -0
  40. package/dist/mixer.js.map +1 -0
  41. package/dist/motion-scope.d.ts +4 -1
  42. package/dist/motion-scope.d.ts.map +1 -1
  43. package/dist/motion-scope.js +8 -3
  44. package/dist/motion-scope.js.map +1 -1
  45. package/dist/motion-states.d.ts +18 -6
  46. package/dist/motion-states.d.ts.map +1 -1
  47. package/dist/motion-states.js +114 -26
  48. package/dist/motion-states.js.map +1 -1
  49. package/dist/motion-value.d.ts +115 -0
  50. package/dist/motion-value.d.ts.map +1 -0
  51. package/dist/motion-value.js +437 -0
  52. package/dist/motion-value.js.map +1 -0
  53. package/dist/path.d.ts.map +1 -1
  54. package/dist/path.js +2 -2
  55. package/dist/path.js.map +1 -1
  56. package/dist/track.d.ts.map +1 -1
  57. package/dist/track.js +31 -12
  58. package/dist/track.js.map +1 -1
  59. package/dist/transition.d.ts +30 -4
  60. package/dist/transition.d.ts.map +1 -1
  61. package/dist/transition.js +63 -7
  62. package/dist/transition.js.map +1 -1
  63. package/dist/types.d.ts +5 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/vue/index.d.ts +28 -2
  66. package/dist/vue/index.d.ts.map +1 -1
  67. package/dist/vue/index.js +78 -2
  68. package/dist/vue/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +195 -0
  71. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
  72. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
  73. package/platforms/android/proguard-rules.pro +2 -0
  74. package/src/android-plugin.d.ts +56 -0
  75. package/src/animate.ts +99 -16
  76. package/src/drag.ts +67 -21
  77. package/src/driver/android-gradient.ts +109 -0
  78. package/src/driver/ios-gradient.ts +114 -0
  79. package/src/easing.ts +14 -0
  80. package/src/engine.ts +158 -53
  81. package/src/gradient.ts +378 -0
  82. package/src/index.ts +34 -3
  83. package/src/keyboard.ts +83 -18
  84. package/src/mixer.ts +264 -0
  85. package/src/motion-scope.ts +14 -6
  86. package/src/motion-states.ts +145 -41
  87. package/src/motion-value.ts +563 -0
  88. package/src/path.ts +2 -1
  89. package/src/track.ts +37 -12
  90. package/src/transition.ts +99 -10
  91. package/src/types.ts +5 -0
  92. package/src/vue/index.ts +98 -1
package/src/mixer.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { View } from '@nativescript/core'
2
+ import { getDriver } from './driver/index.js'
3
+ import { readValue, writeValue, type Property } from './properties.js'
4
+ import type { Track } from './track.js'
5
+
6
+ /** The channel of regular animations; layers are named next to it. */
7
+ export const BASE = ''
8
+
9
+ const SAMPLES_PER_SECOND = 60
10
+ const MAX_SAMPLES = 6000
11
+ /** Endless channels loop natively over the least common multiple of their periods, up to this. */
12
+ const MAX_LOOP = 30000
13
+ /** Beyond it, the composition is played in windows and sampled again at each one. */
14
+ const WINDOW = 4000
15
+
16
+ export const multiplies = (property: Property) =>
17
+ property === 'opacity' || property === 'scaleX' || property === 'scaleY'
18
+ export const identity = (property: Property) => (multiplies(property) ? 1 : 0)
19
+ export const layerable = (property: Property) =>
20
+ multiplies(property) ||
21
+ property === 'x' ||
22
+ property === 'y' ||
23
+ property === 'rotate' ||
24
+ property === 'rotateX' ||
25
+ property === 'rotateY'
26
+
27
+ interface Channel {
28
+ track: Track
29
+ anchor: number
30
+ offset: number
31
+ paused: boolean
32
+ onEnd: (finished: boolean) => void
33
+ timer?: () => void
34
+ }
35
+
36
+ /** What the engine holds instead of a driver handle while a property is mixed. */
37
+ export class MixHandle {
38
+ constructor(
39
+ readonly mix: Mix,
40
+ readonly key: string,
41
+ ) {}
42
+ elapsed() {
43
+ return this.mix.elapsed(this.key)
44
+ }
45
+ control(position: number, paused: boolean) {
46
+ this.mix.control(this.key, position, paused)
47
+ }
48
+ }
49
+
50
+ const mixes = new WeakMap<View, Map<Property, Mix>>()
51
+
52
+ export function mixOf(view: View, property: Property): Mix | undefined {
53
+ return mixes.get(view)?.get(property)
54
+ }
55
+
56
+ export function mixesOf(view: View): Mix[] {
57
+ return [...(mixes.get(view)?.values() ?? [])]
58
+ }
59
+
60
+ export function mixFor(view: View, property: Property): Mix {
61
+ let properties = mixes.get(view)
62
+ if (!properties) mixes.set(view, (properties = new Map()))
63
+ let mix = properties.get(property)
64
+ if (!mix) properties.set(property, (mix = new Mix(view, property)))
65
+ return mix
66
+ }
67
+
68
+ /** One property of one view: a base and named layers, played natively as one composite track. */
69
+ export class Mix {
70
+ private readonly rests = new Map<string, number>()
71
+ private readonly channels = new Map<string, Channel>()
72
+ private native: unknown
73
+ private generation = 0
74
+
75
+ constructor(
76
+ readonly view: View,
77
+ readonly property: Property,
78
+ ) {
79
+ this.rests.set(BASE, readValue(view, property))
80
+ }
81
+
82
+ rest(key: string): number {
83
+ return (
84
+ this.rests.get(key) ??
85
+ (key === BASE ? readValue(this.view, this.property) : identity(this.property))
86
+ )
87
+ }
88
+
89
+ setRest(key: string, value: number) {
90
+ this.rests.set(key, value)
91
+ this.compose()
92
+ }
93
+
94
+ adopt(
95
+ key: string,
96
+ track: Track,
97
+ startedAt: number,
98
+ onEnd: (finished: boolean) => void,
99
+ ) {
100
+ this.channels.get(key)?.timer?.()
101
+ const channel: Channel = { track, anchor: startedAt, offset: 0, paused: false, onEnd }
102
+ this.channels.set(key, channel)
103
+ this.schedule(key, channel)
104
+ this.compose()
105
+ return new MixHandle(this, key)
106
+ }
107
+
108
+ elapsed(key: string, now = getDriver().now()): number {
109
+ const channel = this.channels.get(key)
110
+ if (!channel) return 0
111
+ return channel.paused ? channel.offset : channel.offset + now - channel.anchor
112
+ }
113
+
114
+ control(key: string, position: number, paused: boolean) {
115
+ const channel = this.channels.get(key)
116
+ if (!channel) return
117
+ channel.timer?.()
118
+ channel.timer = undefined
119
+ channel.offset = position
120
+ channel.anchor = getDriver().now()
121
+ channel.paused = paused
122
+ if (!paused) this.schedule(key, channel)
123
+ this.compose()
124
+ }
125
+
126
+ /**
127
+ * A channel leaves: replaced, stopped or done. A stopped layer takes its contribution
128
+ * with it; anything else rests where it is.
129
+ */
130
+ detach(key: string, stopped: boolean) {
131
+ const channel = this.channels.get(key)
132
+ if (!channel) return
133
+ const value = channel.track.valueAt(this.elapsed(key))
134
+ channel.timer?.()
135
+ this.channels.delete(key)
136
+ this.rests.set(key, stopped && key !== BASE ? identity(this.property) : value)
137
+ this.compose()
138
+ }
139
+
140
+ /** Everything stops: what is on screen becomes the base, and layers are cleared. */
141
+ freeze() {
142
+ const value = this.value()
143
+ for (const [key, channel] of [...this.channels]) {
144
+ channel.timer?.()
145
+ this.channels.delete(key)
146
+ channel.onEnd(false)
147
+ }
148
+ this.rests.clear()
149
+ this.rests.set(BASE, value)
150
+ this.compose()
151
+ }
152
+
153
+ /** The composed value on screen at `time`. */
154
+ value(time = getDriver().now()): number {
155
+ const now = getDriver().now()
156
+ let result = this.part(BASE, time, now)
157
+ const keys = new Set([...this.rests.keys(), ...this.channels.keys()])
158
+ for (const key of keys) {
159
+ if (key === BASE) continue
160
+ const part = this.part(key, time, now)
161
+ result = multiplies(this.property) ? result * part : result + part
162
+ }
163
+ return result
164
+ }
165
+
166
+ private part(key: string, time: number, now: number) {
167
+ const channel = this.channels.get(key)
168
+ if (!channel) return this.rest(key)
169
+ const elapsed = channel.paused
170
+ ? channel.offset
171
+ : channel.offset + now - channel.anchor + (time - now)
172
+ return channel.track.valueAt(elapsed)
173
+ }
174
+
175
+ private schedule(key: string, channel: Channel) {
176
+ const remaining = channel.track.end - this.elapsed(key)
177
+ if (!Number.isFinite(remaining)) return
178
+ channel.timer = getDriver().after(Math.max(0, remaining), () => {
179
+ if (this.channels.get(key) !== channel) return
180
+ this.channels.delete(key)
181
+ this.rests.set(key, channel.track.valueAt(channel.track.end))
182
+ channel.onEnd(true)
183
+ this.compose()
184
+ })
185
+ }
186
+
187
+ /** Samples the composition from now and plays it as one native track. */
188
+ private compose() {
189
+ const driver = getDriver()
190
+ const now = driver.now()
191
+ const instance = ++this.generation
192
+ const previous = this.native
193
+ this.native = undefined
194
+
195
+ let finite = 0
196
+ const periods: number[] = []
197
+ for (const [key, channel] of this.channels) {
198
+ if (channel.paused) continue
199
+ const elapsed = this.elapsed(key, now)
200
+ const { track } = channel
201
+ if (Number.isFinite(track.end)) finite = Math.max(finite, track.end - elapsed)
202
+ else if (elapsed < track.delay) finite = Math.max(finite, track.delay - elapsed)
203
+ else periods.push(Math.max(1, Math.round(track.duration * (track.reverse ? 2 : 1))))
204
+ }
205
+
206
+ if (previous !== undefined) driver.stop(this.view, previous)
207
+ const loop = finite <= 0 && periods.length ? leastCommonMultiple(periods) : undefined
208
+ if (finite <= 0 && !periods.length) {
209
+ writeValue(this.view, this.property, this.value(now))
210
+ driver.sync?.(this.view)
211
+ this.dissolve()
212
+ return
213
+ }
214
+
215
+ const span = finite > 0 ? finite : (loop ?? WINDOW)
216
+ const steps = Math.max(
217
+ 1,
218
+ Math.min(MAX_SAMPLES, Math.ceil((span / 1000) * SAMPLES_PER_SECOND)),
219
+ )
220
+ const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
221
+ const values = times.map((time) => this.value(now + time * span))
222
+ const endless = loop !== undefined
223
+ const at = (elapsed: number) =>
224
+ this.value(now + (endless ? elapsed % span : Math.min(Math.max(elapsed, 0), span)))
225
+ const track: Track = {
226
+ property: this.property,
227
+ delay: 0,
228
+ duration: span,
229
+ repeat: endless ? Infinity : 0,
230
+ reverse: false,
231
+ times,
232
+ values,
233
+ to: endless ? values[0]! : values[steps]!,
234
+ end: endless ? Infinity : span,
235
+ arrival: endless ? Infinity : span,
236
+ valueAt: at,
237
+ velocityAt: (elapsed) => (at(elapsed + 0.5) - at(elapsed - 0.5)) * 1000,
238
+ }
239
+ writeValue(this.view, this.property, track.to)
240
+ this.native = driver.play(this.view, track, now, (finished = true) => {
241
+ if (instance !== this.generation || !finished) return
242
+ this.native = undefined
243
+ this.compose()
244
+ })
245
+ }
246
+
247
+ private dissolve() {
248
+ if (this.channels.size) return
249
+ for (const [key, value] of this.rests)
250
+ if (key !== BASE && value !== identity(this.property)) return
251
+ const properties = mixes.get(this.view)
252
+ if (properties?.get(this.property) === this) properties.delete(this.property)
253
+ }
254
+ }
255
+
256
+ function leastCommonMultiple(periods: readonly number[]): number | undefined {
257
+ const gcd = (a: number, b: number): number => (b ? gcd(b, a % b) : a)
258
+ let result = periods[0]!
259
+ for (const period of periods.slice(1)) {
260
+ result = (result / gcd(result, period)) * period
261
+ if (result > MAX_LOOP) return undefined
262
+ }
263
+ return result
264
+ }
@@ -1,6 +1,7 @@
1
1
  import { animate, decay } from './animate.js'
2
2
  import { press } from './press.js'
3
3
  import { drag } from './drag.js'
4
+ import { gradient } from './gradient.js'
4
5
  import { animateLayout, type LayoutOptions } from './layout.js'
5
6
  import type { SpringName } from './transition.js'
6
7
  import { sequence } from './sequence.js'
@@ -39,14 +40,16 @@ export interface MotionScope {
39
40
  sequence: typeof sequence
40
41
  press: typeof press
41
42
  drag: typeof drag
43
+ /** Removed with the scope; suspending it stops a gradient animation where it is. */
44
+ gradient: typeof gradient
42
45
  states<
43
- T extends Record<string, Target>,
46
+ T extends Record<string, Target | null>,
44
47
  const S extends Record<string, MotionState<keyof T>>,
45
48
  >(
46
49
  targets: T,
47
50
  definitions: MotionDefinitions<T, S>,
48
- options?: Omit<MotionStatesOptions, 'scope'>,
49
- ): MotionStates<Extract<keyof S, string>>
51
+ options?: Omit<MotionStatesOptions<Extract<keyof T, string>>, 'scope'>,
52
+ ): MotionStates<Extract<keyof S, string>, Extract<keyof T, string>>
50
53
  /** Adopt an existing handle. Completed animations are released automatically. */
51
54
  add<T extends MotionResource>(resource: T): T
52
55
  /** Attach a subscription, detach on suspension, and recreate on resume. */
@@ -189,9 +192,8 @@ export function motionScope(): MotionScope {
189
192
  : animateLayout(container, changeOrOptions),
190
193
  )
191
194
  },
192
- animate(...args) {
193
- return startOwned(() => animate(...args))
194
- },
195
+ animate: ((...args: Parameters<typeof animate>) =>
196
+ startOwned(() => animate(...args))) as typeof animate,
195
197
  decay(...args) {
196
198
  return startOwned(() => decay(...args))
197
199
  },
@@ -204,6 +206,12 @@ export function motionScope(): MotionScope {
204
206
  drag(...args) {
205
207
  return startOwned(() => drag(...args))
206
208
  },
209
+ gradient(target, options) {
210
+ assertActive()
211
+ const made = gradient(target, options)
212
+ scope.add({ pause: () => made.stop(), play() {}, dispose: () => made.remove() })
213
+ return made
214
+ },
207
215
  states(targets, definitions, options) {
208
216
  return startOwned(() => motionStates(targets, definitions, options))
209
217
  },
@@ -1,9 +1,11 @@
1
- import { prepareAnimation, toView } from './animate.js'
1
+ import { endingValues, prepareAnimation, set, toView } from './animate.js'
2
2
  import { isColorProperty, type ColorValue } from './color.js'
3
3
  import { isProperty } from './properties.js'
4
4
  import type { Animation } from './engine.js'
5
5
  import type { PlaybackAnimation } from './playback.js'
6
- import type { KeyframeProperty, Keyframes, Target } from './types.js'
6
+ import type { KeyframeProperty, Keyframes, Target, Values } from './types.js'
7
+ import type { View } from '@nativescript/core'
8
+ import { mergeTransitions, springs } from './transition.js'
7
9
  import type { Spring, Tween, SpringName } from './transition.js'
8
10
  import type { MotionScope } from './motion-scope.js'
9
11
 
@@ -20,21 +22,33 @@ export type MotionDefinitions<T, S> = S & {
20
22
  [K in keyof S[N]]: Record<Exclude<keyof S[N][K], KeyframeProperty>, never>
21
23
  }
22
24
  }
23
- export type StateTransition =
25
+ type StateCurve =
24
26
  | SpringName
25
27
  | Omit<Spring, 'repeat' | 'repeatType'>
26
28
  | Omit<Tween, 'repeat' | 'repeatType'>
27
- export interface MotionStatesOptions {
28
- transition?: StateTransition
29
+ /** One transition for every target, or a main one with some targets set apart by name. */
30
+ export type StateTransition<T extends string = string> =
31
+ | StateCurve
32
+ | (Exclude<StateCurve, SpringName> &
33
+ (string extends T ? unknown : { [K in T]?: StateCurve }))
34
+ export interface MotionStatesOptions<T extends string = string> {
35
+ transition?: StateTransition<T>
29
36
  scope?: MotionScope
30
37
  }
31
- export interface MotionStates<S extends string = string> {
38
+ export interface MotionStates<S extends string = string, T extends string = string> {
32
39
  /** Latest requested state, not a claim that playback has finished. */
33
40
  readonly current: S | undefined
34
41
  readonly settled: S | undefined
35
42
  readonly disposed: boolean
36
43
  readonly paused: boolean
37
- to(state: S, transition?: StateTransition): Animation
44
+ to(state: S, transition?: StateTransition<T>): Animation
45
+ /**
46
+ * Point a target at a new view, or at none with `null`. The new view takes the current
47
+ * state at once, or joins the transition in progress from where it is.
48
+ */
49
+ attach(target: T, view: Target | null): void
50
+ /** A stable callback for a template ref: attaches the element and detaches it on unmount. */
51
+ ref(target: T): (view: Target | null) => void
38
52
  pause(): void
39
53
  play(): void
40
54
  stop(): void
@@ -43,28 +57,38 @@ export interface MotionStates<S extends string = string> {
43
57
 
44
58
  /** Coordinated named targets. Omitted properties freeze when leaving their previous state. */
45
59
  export function motionStates<
46
- T extends Record<string, Target>,
60
+ T extends Record<string, Target | null>,
47
61
  const S extends Record<string, MotionState<keyof T>>,
48
62
  >(
49
63
  targets: T,
50
64
  definitions: MotionDefinitions<T, S>,
51
- options: MotionStatesOptions = {},
52
- ): MotionStates<Extract<keyof S, string>> {
65
+ options: MotionStatesOptions<Extract<keyof T, string>> = {},
66
+ ): MotionStates<Extract<keyof S, string>, Extract<keyof T, string>> {
53
67
  type Name = Extract<keyof S, string>
54
- const views = new Map(
55
- Object.entries(targets).map(([name, target]) => [name, toView(target)]),
68
+ type Slot = Extract<keyof T, string>
69
+ const views = new Map<string, View | null>(
70
+ Object.entries(targets).map(([name, target]) => [
71
+ name,
72
+ target === null ? null : toView(target),
73
+ ]),
56
74
  )
57
- if (new Set(views.values()).size !== views.size)
75
+ const assigned = [...views.values()].filter(Boolean)
76
+ if (new Set(assigned).size !== assigned.length)
58
77
  throw new TypeError('Motion state targets must refer to distinct views.')
59
- const states = new Map<string, [Target, Keyframes][]>()
60
- const defaults = normalize(options.transition)
78
+ for (const name of views.keys())
79
+ if (curveKeys.has(name))
80
+ throw new TypeError(
81
+ `A motion target can't be named "${name}", like a transition option.`,
82
+ )
83
+ const states = new Map<string, [string, Keyframes][]>()
84
+ const defaults = normalize(options.transition, views)
61
85
  for (const [name, state] of Object.entries(definitions)) {
62
86
  if (!state || typeof state !== 'object' || Array.isArray(state))
63
87
  throw new TypeError(`Invalid motion state "${name}".`)
64
- const rows: [Target, Keyframes][] = []
88
+ const rows: [string, Keyframes][] = []
65
89
  for (const [target, values] of Object.entries(state)) {
90
+ if (!views.has(target)) throw new TypeError(`Unknown motion target "${target}".`)
66
91
  const view = views.get(target)
67
- if (!view) throw new TypeError(`Unknown motion target "${target}".`)
68
92
  if (!values || typeof values !== 'object' || Array.isArray(values))
69
93
  throw new TypeError(`Invalid motion values for "${target}".`)
70
94
  const frames: Keyframes = {}
@@ -82,8 +106,9 @@ export function motionStates<
82
106
  Object.assign(frames, { [property]: value })
83
107
  }
84
108
  // Validate every definition up front without claiming any native property.
85
- prepareAnimation(view, frames, defaults)
86
- rows.push([view, frames])
109
+ if (view) prepareAnimation(view, frames, timingFor(defaults, target))
110
+ else endingValues(frames, timingFor(defaults, target))
111
+ rows.push([target, frames])
87
112
  }
88
113
  states.set(name, rows)
89
114
  }
@@ -91,8 +116,14 @@ export function motionStates<
91
116
  let settled: Name | undefined
92
117
  let disposed = false
93
118
  let paused = false
94
- let active: { handle: Animation; children: PlaybackAnimation[] } | undefined
95
- const controller: MotionStates<Name> = {
119
+ let active:
120
+ { handle: Animation; children: PlaybackAnimation[]; timing: Timing } | undefined
121
+ const refs = new Map<string, (view: Target | null) => void>()
122
+ const framesFor = (state: Name | undefined, target: string) =>
123
+ state === undefined
124
+ ? undefined
125
+ : states.get(state)!.find(([slot]) => slot === target)?.[1]
126
+ const controller: MotionStates<Name, Slot> = {
96
127
  get current() {
97
128
  return current
98
129
  },
@@ -109,10 +140,11 @@ export function motionStates<
109
140
  if (disposed) throw new Error('Motion states controller is disposed.')
110
141
  const rows = states.get(name)
111
142
  if (!rows) throw new TypeError(`Unknown motion state "${name}".`)
112
- const timing = transition === undefined ? defaults : normalize(transition)
113
- const prepared = rows.map(([view, frames]) =>
114
- prepareAnimation(view, frames, timing),
115
- )
143
+ const timing = normalize(transition, views, defaults)
144
+ const prepared = rows.flatMap(([target, frames]) => {
145
+ const view = views.get(target)
146
+ return view ? [prepareAnimation(view, frames, timingFor(timing, target))] : []
147
+ })
116
148
  const previous = active
117
149
  const children: PlaybackAnimation[] = []
118
150
  // Start replacements first: the engine transfers visible position AND velocity.
@@ -148,12 +180,45 @@ export function motionStates<
148
180
  },
149
181
  then: (resolve, reject) => finished.then(resolve, reject),
150
182
  }
151
- active = { handle, children }
183
+ active = { handle, children, timing }
152
184
  current = name
153
185
  // Also cancels old, omitted properties, without touching the new owners.
154
186
  previous?.handle.stop()
155
187
  return handle
156
188
  },
189
+ attach(target, next) {
190
+ if (disposed) throw new Error('Motion states controller is disposed.')
191
+ if (!views.has(target)) throw new TypeError(`Unknown motion target "${target}".`)
192
+ const view = next === null ? null : toView(next)
193
+ if (views.get(target) === view) return
194
+ if (view)
195
+ for (const [other, assignedView] of views)
196
+ if (other !== target && assignedView === view)
197
+ throw new TypeError('Motion state targets must refer to distinct views.')
198
+ if (view)
199
+ for (const [, rows] of states)
200
+ for (const [slot, frames] of rows)
201
+ if (slot === target)
202
+ prepareAnimation(view, frames, timingFor(defaults, target))
203
+ views.set(target, view)
204
+ const frames = view ? framesFor(current, target) : undefined
205
+ if (!view || !frames) return
206
+ if (active) {
207
+ const child = prepareAnimation(view, frames, timingFor(active.timing, target))()
208
+ active.children.push(child)
209
+ if (paused) child.pause()
210
+ } else set(view, frames as Values)
211
+ },
212
+ ref(target) {
213
+ let callback = refs.get(target)
214
+ if (!callback) {
215
+ callback = (view) => {
216
+ if (!disposed) controller.attach(target, view)
217
+ }
218
+ refs.set(target, callback)
219
+ }
220
+ return callback
221
+ },
157
222
  pause() {
158
223
  if (disposed || paused) return
159
224
  paused = true
@@ -177,8 +242,59 @@ export function motionStates<
177
242
  return options.scope ? options.scope.add(controller) : controller
178
243
  }
179
244
 
180
- function normalize(transition: StateTransition | undefined): StateTransition {
181
- if (transition === undefined) return 'snappy'
245
+ const curveKeys = new Set([
246
+ 'type',
247
+ 'delay',
248
+ 'duration',
249
+ 'ease',
250
+ 'times',
251
+ 'stiffness',
252
+ 'damping',
253
+ 'mass',
254
+ 'bounce',
255
+ 'velocity',
256
+ ])
257
+
258
+ type Curve = Exclude<StateCurve, SpringName>
259
+ interface Timing {
260
+ readonly main: StateCurve
261
+ readonly targets: ReadonlyMap<string, StateCurve>
262
+ }
263
+
264
+ /** The transition one target plays: its own merged over the main one, or the main one. */
265
+ function timingFor(timing: Timing, target: string): StateCurve {
266
+ const own = timing.targets.get(target)
267
+ if (own === undefined) return timing.main
268
+ const main = typeof timing.main === 'string' ? springs[timing.main] : timing.main
269
+ return mergeTransitions(main, typeof own === 'string' ? springs[own] : own) as Curve
270
+ }
271
+
272
+ const snappy: Timing = { main: 'snappy', targets: new Map() }
273
+
274
+ /** A transition that only names targets keeps the fallback for everything else. */
275
+ function normalize(
276
+ transition: StateTransition | undefined,
277
+ targets: ReadonlyMap<string, unknown>,
278
+ fallback = snappy,
279
+ ): Timing {
280
+ if (transition === undefined) return fallback
281
+ if (typeof transition === 'string')
282
+ return { main: curve(transition), targets: new Map() }
283
+ if (!transition || typeof transition !== 'object')
284
+ throw new TypeError('Invalid state transition.')
285
+ const main: Record<string, unknown> = {}
286
+ const own = new Map<string, StateCurve>()
287
+ for (const [key, value] of Object.entries(transition)) {
288
+ if (targets.has(key)) {
289
+ if (value !== undefined) own.set(key, curve(value as StateCurve))
290
+ } else main[key] = value
291
+ }
292
+ if (own.size && !Object.keys(main).length)
293
+ return { main: fallback.main, targets: new Map([...fallback.targets, ...own]) }
294
+ return { main: curve(main as Curve), targets: own }
295
+ }
296
+
297
+ function curve(transition: StateCurve): StateCurve {
182
298
  if (typeof transition === 'string') {
183
299
  if (!['smooth', 'snappy', 'bouncy'].includes(transition))
184
300
  throw new TypeError('Unknown state spring.')
@@ -186,20 +302,8 @@ function normalize(transition: StateTransition | undefined): StateTransition {
186
302
  }
187
303
  if (!transition || typeof transition !== 'object')
188
304
  throw new TypeError('Invalid state transition.')
189
- const allowed = new Set([
190
- 'type',
191
- 'delay',
192
- 'duration',
193
- 'ease',
194
- 'times',
195
- 'stiffness',
196
- 'damping',
197
- 'mass',
198
- 'bounce',
199
- 'velocity',
200
- ])
201
305
  for (const key of Object.keys(transition))
202
- if (!allowed.has(key))
306
+ if (!curveKeys.has(key))
203
307
  throw new TypeError(`State transitions do not support "${key}".`)
204
308
  if (
205
309
  transition.duration !== undefined &&