@rafaelaugusto/jolt 0.0.0-stage → 0.1.0-beta.1

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/dist/animate.d.ts +26 -0
  4. package/dist/animate.d.ts.map +1 -0
  5. package/dist/animate.js +161 -0
  6. package/dist/animate.js.map +1 -0
  7. package/dist/driver/android.d.ts +3 -0
  8. package/dist/driver/android.d.ts.map +1 -0
  9. package/dist/driver/android.js +73 -0
  10. package/dist/driver/android.js.map +1 -0
  11. package/dist/driver/index.d.ts +18 -0
  12. package/dist/driver/index.d.ts.map +1 -0
  13. package/dist/driver/index.js +15 -0
  14. package/dist/driver/index.js.map +1 -0
  15. package/dist/driver/ios.d.ts +3 -0
  16. package/dist/driver/ios.d.ts.map +1 -0
  17. package/dist/driver/ios.js +77 -0
  18. package/dist/driver/ios.js.map +1 -0
  19. package/dist/easing.d.ts +8 -0
  20. package/dist/easing.d.ts.map +1 -0
  21. package/dist/easing.js +71 -0
  22. package/dist/easing.js.map +1 -0
  23. package/dist/engine.d.ts +48 -0
  24. package/dist/engine.d.ts.map +1 -0
  25. package/dist/engine.js +178 -0
  26. package/dist/engine.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/presets.d.ts +310 -0
  32. package/dist/presets.d.ts.map +1 -0
  33. package/dist/presets.js +141 -0
  34. package/dist/presets.js.map +1 -0
  35. package/dist/press.d.ts +13 -0
  36. package/dist/press.d.ts.map +1 -0
  37. package/dist/press.js +34 -0
  38. package/dist/press.js.map +1 -0
  39. package/dist/properties.d.ts +8 -0
  40. package/dist/properties.d.ts.map +1 -0
  41. package/dist/properties.js +42 -0
  42. package/dist/properties.js.map +1 -0
  43. package/dist/sequence.d.ts +26 -0
  44. package/dist/sequence.d.ts.map +1 -0
  45. package/dist/sequence.js +69 -0
  46. package/dist/sequence.js.map +1 -0
  47. package/dist/spring.d.ts +22 -0
  48. package/dist/spring.d.ts.map +1 -0
  49. package/dist/spring.js +54 -0
  50. package/dist/spring.js.map +1 -0
  51. package/dist/track.d.ts +28 -0
  52. package/dist/track.d.ts.map +1 -0
  53. package/dist/track.js +103 -0
  54. package/dist/track.js.map +1 -0
  55. package/dist/transition.d.ts +58 -0
  56. package/dist/transition.d.ts.map +1 -0
  57. package/dist/transition.js +82 -0
  58. package/dist/transition.js.map +1 -0
  59. package/dist/types.d.ts +40 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/package.json +67 -4
  64. package/src/animate.ts +246 -0
  65. package/src/driver/android.ts +101 -0
  66. package/src/driver/index.ts +32 -0
  67. package/src/driver/ios.ts +97 -0
  68. package/src/easing.ts +100 -0
  69. package/src/engine.ts +216 -0
  70. package/src/index.ts +12 -0
  71. package/src/presets.ts +158 -0
  72. package/src/press.ts +49 -0
  73. package/src/properties.ts +51 -0
  74. package/src/sequence.ts +88 -0
  75. package/src/spring.ts +95 -0
  76. package/src/track.ts +163 -0
  77. package/src/transition.ts +160 -0
  78. package/src/types.ts +41 -0
package/src/engine.ts ADDED
@@ -0,0 +1,216 @@
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 { createTrack, type Track } from './track.js'
5
+ import type { ResolvedTransition } from './transition.js'
6
+
7
+ export interface Animation extends PromiseLike<boolean> {
8
+ /**
9
+ * Resolves with `true` once the animation played to the end, or with `false`
10
+ * if it was stopped or another animation took over one of its properties.
11
+ * Never rejects.
12
+ */
13
+ readonly finished: Promise<boolean>
14
+ /** Stops where it is. The view keeps the values it had at that moment. */
15
+ stop(): void
16
+ }
17
+
18
+ export interface Plan {
19
+ property: Property
20
+ /** `null` stands for the value the property has when the animation starts. */
21
+ keyframes: readonly (number | null)[]
22
+ transition: ResolvedTransition
23
+ relative?: boolean
24
+ }
25
+
26
+ interface Entry {
27
+ readonly track: Track
28
+ readonly startedAt: number
29
+ readonly owner: ViewAnimation
30
+ handle: unknown
31
+ done: boolean
32
+ }
33
+
34
+ const running = new WeakMap<View, Map<Property, Entry>>()
35
+ const arrivals = new WeakMap<Animation, Promise<unknown>>()
36
+
37
+ /**
38
+ * Resolves once the animation looks done, which for a spring is well before
39
+ * `finished`. Sequences move on to the next step then.
40
+ */
41
+ export function arrivalOf(animation: Animation): Promise<unknown> {
42
+ return arrivals.get(animation) ?? animation.finished
43
+ }
44
+
45
+ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
46
+ arrivals.set(animation, arrival)
47
+ }
48
+
49
+ export class ViewAnimation implements Animation {
50
+ readonly finished: Promise<boolean>
51
+ private resolve!: (finished: boolean) => void
52
+ private readonly entries: Entry[] = []
53
+ private pending = 0
54
+ private complete = true
55
+ private settled = false
56
+ private cancelWait: (() => void) | undefined
57
+ private arrive!: () => void
58
+ private cancelArrival: (() => void) | undefined
59
+
60
+ constructor(
61
+ readonly view: View,
62
+ plans: readonly Plan[],
63
+ ) {
64
+ this.finished = new Promise((resolve) => (this.resolve = resolve))
65
+ setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
66
+ if (view.isLoaded && view.nativeViewProtected) this.start(plans)
67
+ else this.waitForLoad(plans)
68
+ }
69
+
70
+ then<A = boolean, B = never>(
71
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
72
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
73
+ ): Promise<A | B> {
74
+ return this.finished.then(onfulfilled, onrejected)
75
+ }
76
+
77
+ stop() {
78
+ if (this.settled) return
79
+ this.cancelWait?.()
80
+ const now = getDriver().now()
81
+ for (const entry of this.entries) {
82
+ if (!entry.done) release(entry, now, true)
83
+ }
84
+ this.complete = false
85
+ this.settle()
86
+ }
87
+
88
+ ended(finished: boolean) {
89
+ if (!finished) this.complete = false
90
+ if (--this.pending === 0) this.settle()
91
+ }
92
+
93
+ private start(plans: readonly Plan[]) {
94
+ const driver = getDriver()
95
+ const now = driver.now()
96
+ const entries = entriesOf(this.view)
97
+
98
+ const tracks = plans.map(({ property, keyframes, transition, relative }) => {
99
+ const previous = entries.get(property)
100
+ let current: number
101
+ let velocity = 0
102
+ if (previous) {
103
+ const elapsed = now - previous.startedAt
104
+ current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
105
+ velocity = previous.track.velocityAt(elapsed)
106
+ release(previous, now, false)
107
+ } else {
108
+ current = readValue(this.view, property)
109
+ }
110
+ return createTrack(
111
+ property,
112
+ keyframes.map((value) => {
113
+ if (value === null) return current
114
+ if (!relative) return value
115
+ return property === 'opacity' || property.startsWith('scale')
116
+ ? current * value
117
+ : current + value
118
+ }),
119
+ transition,
120
+ velocity,
121
+ )
122
+ })
123
+
124
+ for (const track of tracks) writeValue(this.view, track.property, track.to)
125
+
126
+ for (const track of tracks) {
127
+ if (track.end === 0) continue
128
+ const entry: Entry = {
129
+ track,
130
+ startedAt: now,
131
+ owner: this,
132
+ handle: undefined,
133
+ done: false,
134
+ }
135
+ entries.set(track.property, entry)
136
+ this.entries.push(entry)
137
+ this.pending++
138
+ }
139
+
140
+ for (const entry of this.entries) {
141
+ entry.handle = driver.play(this.view, entry.track, now, () => {
142
+ if (entry.done) return
143
+ entry.done = true
144
+ forget(entry)
145
+ this.ended(true)
146
+ })
147
+ }
148
+
149
+ const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
150
+ if (this.pending === 0) this.settle()
151
+ else if (Number.isFinite(arrival)) {
152
+ this.cancelArrival = driver.after(arrival, this.arrive)
153
+ }
154
+ }
155
+
156
+ private waitForLoad(plans: readonly Plan[]) {
157
+ for (const { property, keyframes, relative } of plans) {
158
+ if (relative) continue
159
+ const first = keyframes[0]
160
+ if (first !== null && first !== undefined) writeValue(this.view, property, first)
161
+ }
162
+ const onLoaded = () => {
163
+ this.cancelWait?.()
164
+ this.start(plans)
165
+ }
166
+ this.view.on('loaded', onLoaded)
167
+ this.cancelWait = () => {
168
+ this.view.off('loaded', onLoaded)
169
+ this.cancelWait = undefined
170
+ }
171
+ }
172
+
173
+ private settle() {
174
+ if (this.settled) return
175
+ this.settled = true
176
+ this.cancelArrival?.()
177
+ this.arrive()
178
+ this.resolve(this.complete)
179
+ }
180
+ }
181
+
182
+ /** Stops whatever is animating these properties of the view, or all of them. */
183
+ export function stopView(view: View, properties?: readonly Property[]) {
184
+ const entries = running.get(view)
185
+ if (!entries) return
186
+ const now = getDriver().now()
187
+ for (const [property, entry] of [...entries]) {
188
+ if (!properties || properties.includes(property)) release(entry, now, true)
189
+ }
190
+ }
191
+
192
+ function entriesOf(view: View) {
193
+ let entries = running.get(view)
194
+ if (!entries) running.set(view, (entries = new Map()))
195
+ return entries
196
+ }
197
+
198
+ function forget(entry: Entry) {
199
+ const entries = running.get(entry.owner.view)
200
+ if (entries?.get(entry.track.property) === entry) entries.delete(entry.track.property)
201
+ }
202
+
203
+ function release(entry: Entry, now: number, commit: boolean) {
204
+ const driver = getDriver()
205
+ const { view } = entry.owner
206
+ const { property } = entry.track
207
+ entry.done = true
208
+ forget(entry)
209
+ if (commit) {
210
+ const value =
211
+ driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
212
+ writeValue(view, property, value)
213
+ }
214
+ driver.stop(view, entry.handle)
215
+ entry.owner.ended(false)
216
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { animate, stop } from './animate.js'
2
+ export { sequence } from './sequence.js'
3
+ export type { Step } from './sequence.js'
4
+ export { press } from './press.js'
5
+ export { definePreset, presets } from './presets.js'
6
+ export type { Preset, PresetName, Presets } from './presets.js'
7
+ export { springs } from './transition.js'
8
+ export type { Spring, SpringName, Timing, Transition, Tween } from './transition.js'
9
+ export { cubicBezier } from './easing.js'
10
+ export type { CubicBezier, Easing, EasingFunction, EasingName } from './easing.js'
11
+ export type { Animation } from './engine.js'
12
+ export type * from './types.js'
package/src/presets.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { AnimateOptions, Keyframes } from './types.js'
2
+
3
+ export interface Preset {
4
+ keyframes: Keyframes
5
+ transition: AnimateOptions
6
+ }
7
+
8
+ /** Add custom preset names through module augmentation, with `true` as each value. */
9
+ export interface Presets {}
10
+
11
+ const fadeIn = { duration: 300, ease: 'easeOut' } as const
12
+ const fadeOut = { duration: 200, ease: 'easeIn' } as const
13
+ const enter = { type: 'spring', duration: 500, bounce: 0 } as const
14
+
15
+ export const presets = {
16
+ fadeIn: { keyframes: { opacity: [0, 1] }, transition: fadeIn },
17
+ fadeInUp: {
18
+ keyframes: { opacity: [0, 1], y: [24, 0] },
19
+ transition: { ...enter, opacity: fadeIn },
20
+ },
21
+ fadeInDown: {
22
+ keyframes: { opacity: [0, 1], y: [-24, 0] },
23
+ transition: { ...enter, opacity: fadeIn },
24
+ },
25
+ fadeInLeft: {
26
+ keyframes: { opacity: [0, 1], x: [-24, 0] },
27
+ transition: { ...enter, opacity: fadeIn },
28
+ },
29
+ fadeInRight: {
30
+ keyframes: { opacity: [0, 1], x: [24, 0] },
31
+ transition: { ...enter, opacity: fadeIn },
32
+ },
33
+ zoomIn: {
34
+ keyframes: { opacity: [0, 1], scale: [0.85, 1] },
35
+ transition: { ...enter, opacity: fadeIn },
36
+ },
37
+ popIn: {
38
+ keyframes: { opacity: [0, 1], scale: [0.5, 1] },
39
+ transition: {
40
+ type: 'spring',
41
+ duration: 550,
42
+ bounce: 0.4,
43
+ opacity: { duration: 150 },
44
+ },
45
+ },
46
+ flipInX: {
47
+ keyframes: { opacity: [0, 1], rotateX: [-90, 0] },
48
+ transition: {
49
+ type: 'spring',
50
+ duration: 600,
51
+ bounce: 0.25,
52
+ opacity: { duration: 200 },
53
+ },
54
+ },
55
+ flipInY: {
56
+ keyframes: { opacity: [0, 1], rotateY: [90, 0] },
57
+ transition: {
58
+ type: 'spring',
59
+ duration: 600,
60
+ bounce: 0.25,
61
+ opacity: { duration: 200 },
62
+ },
63
+ },
64
+
65
+ fadeOut: { keyframes: { opacity: 0 }, transition: fadeOut },
66
+ fadeOutUp: { keyframes: { opacity: 0, y: -24 }, transition: fadeOut },
67
+ fadeOutDown: { keyframes: { opacity: 0, y: 24 }, transition: fadeOut },
68
+ fadeOutLeft: { keyframes: { opacity: 0, x: -24 }, transition: fadeOut },
69
+ fadeOutRight: { keyframes: { opacity: 0, x: 24 }, transition: fadeOut },
70
+ zoomOut: { keyframes: { opacity: 0, scale: 0.85 }, transition: fadeOut },
71
+ popOut: {
72
+ keyframes: { opacity: 0, scale: [null, 1.08, 0.4] },
73
+ transition: {
74
+ duration: 250,
75
+ ease: ['easeOut', 'easeIn'],
76
+ times: [0, 0.3, 1],
77
+ opacity: { duration: 250, ease: 'easeIn' },
78
+ },
79
+ },
80
+
81
+ pulse: {
82
+ keyframes: { scale: [1, 1.06, 1] },
83
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
84
+ },
85
+ bounce: {
86
+ keyframes: { y: [0, -18, 0, -8, 0] },
87
+ transition: {
88
+ duration: 650,
89
+ relative: true,
90
+ times: [0, 0.3, 0.6, 0.8, 1],
91
+ ease: ['easeOut', 'easeIn', 'easeOut', 'easeIn'],
92
+ },
93
+ },
94
+ shake: {
95
+ keyframes: { x: [0, -10, 10, -8, 8, -4, 4, 0] },
96
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
97
+ },
98
+ wiggle: {
99
+ keyframes: { rotate: [0, -8, 8, -5, 5, 0] },
100
+ transition: { duration: 500, ease: 'easeInOut', relative: true },
101
+ },
102
+ heartbeat: {
103
+ keyframes: { scale: [1, 1.15, 1, 1.15, 1, 1] },
104
+ transition: {
105
+ duration: 1000,
106
+ relative: true,
107
+ times: [0, 0.14, 0.28, 0.42, 0.7, 1],
108
+ ease: 'easeInOut',
109
+ },
110
+ },
111
+ jelly: {
112
+ keyframes: {
113
+ scaleX: [1, 1.25, 0.75, 1.15, 0.95, 1.05, 1],
114
+ scaleY: [1, 0.75, 1.25, 0.85, 1.05, 0.95, 1],
115
+ },
116
+ transition: {
117
+ duration: 800,
118
+ relative: true,
119
+ times: [0, 0.3, 0.4, 0.5, 0.65, 0.75, 1],
120
+ ease: 'easeInOut',
121
+ },
122
+ },
123
+ flash: {
124
+ keyframes: { opacity: [1, 0, 1, 0, 1] },
125
+ transition: { duration: 800, ease: 'easeInOut', relative: true },
126
+ },
127
+ tada: {
128
+ keyframes: {
129
+ scale: [1, 0.9, 0.9, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1],
130
+ rotate: [0, -3, -3, 3, -3, 3, -3, 3, -3, 3, 0],
131
+ },
132
+ transition: { duration: 1000, ease: 'easeInOut', relative: true },
133
+ },
134
+ spin: {
135
+ keyframes: { rotate: [0, 360] },
136
+ transition: { duration: 1000, ease: 'linear', relative: true },
137
+ },
138
+ } satisfies Record<string, Preset>
139
+
140
+ export type PresetName = keyof typeof presets | Extract<keyof Presets, string>
141
+
142
+ const customPresets = new Map<string, Preset>()
143
+
144
+ /** Registers a custom preset. Registering it again replaces it for future animations. */
145
+ export function definePreset(name: PresetName, preset: Preset): void {
146
+ if (Object.hasOwn(presets, name)) {
147
+ throw new Error(`Preset "${name}" is built in and cannot be replaced.`)
148
+ }
149
+ customPresets.set(name, preset)
150
+ }
151
+
152
+ export function getPreset(name: PresetName): Preset {
153
+ const preset =
154
+ customPresets.get(name) ??
155
+ (Object.hasOwn(presets, name) ? presets[name as keyof typeof presets] : undefined)
156
+ if (!preset) throw new Error(`Unknown preset "${name}".`)
157
+ return preset
158
+ }
package/src/press.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { EventData, TouchGestureEventData } from '@nativescript/core'
2
+ import { animate, toView } from './animate.js'
3
+ import { isProperty, readValue, type Property } from './properties.js'
4
+ import type { SpringName, Transition } from './transition.js'
5
+ import type { AnimateOptions, Keyframes, Target } from './types.js'
6
+
7
+ const pressIn: Transition = { type: 'spring', duration: 200, bounce: 0 }
8
+ const release: Transition = { type: 'spring', duration: 450, bounce: 0.35 }
9
+
10
+ /**
11
+ * Animates the view to `pressed` while a finger is on it, and back once it's lifted.
12
+ * Returns a function that removes the effect.
13
+ *
14
+ * ```ts
15
+ * press(button)
16
+ * press(card, { scale: 0.97, opacity: 0.85 })
17
+ * ```
18
+ */
19
+ export function press(
20
+ target: Target,
21
+ pressed: Keyframes = { scale: 0.96 },
22
+ options?: AnimateOptions | SpringName,
23
+ ): () => void {
24
+ const view = toView(target)
25
+ const properties = Object.keys(pressed).flatMap((key): Property[] =>
26
+ key === 'scale' ? ['scaleX', 'scaleY'] : isProperty(key) ? [key] : [],
27
+ )
28
+ let rest: Keyframes | undefined
29
+
30
+ const onTouch = (event: EventData) => {
31
+ const { action } = event as TouchGestureEventData
32
+ if (action === 'down' && !rest && view.isEnabled !== false) {
33
+ rest = Object.fromEntries(properties.map((p) => [p, readValue(view, p)]))
34
+ animate(view, pressed, options ?? pressIn)
35
+ } else if ((action === 'up' || action === 'cancel') && rest) {
36
+ animate(
37
+ view,
38
+ rest,
39
+ typeof options === 'object'
40
+ ? { ...options, relative: false }
41
+ : (options ?? release),
42
+ )
43
+ rest = undefined
44
+ }
45
+ }
46
+
47
+ view.on('touch', onTouch)
48
+ return () => view.off('touch', onTouch)
49
+ }
@@ -0,0 +1,51 @@
1
+ import type { View } from '@nativescript/core'
2
+
3
+ export type Property =
4
+ 'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
5
+
6
+ const viewKeys = {
7
+ opacity: 'opacity',
8
+ x: 'translateX',
9
+ y: 'translateY',
10
+ scaleX: 'scaleX',
11
+ scaleY: 'scaleY',
12
+ rotate: 'rotate',
13
+ rotateX: 'rotateX',
14
+ rotateY: 'rotateY',
15
+ } as const satisfies Record<Property, keyof View>
16
+
17
+ const defaults: Record<Property, number> = {
18
+ opacity: 1,
19
+ x: 0,
20
+ y: 0,
21
+ scaleX: 1,
22
+ scaleY: 1,
23
+ rotate: 0,
24
+ rotateX: 0,
25
+ rotateY: 0,
26
+ }
27
+
28
+ /** How close to its target a spring has to be to count as settled. */
29
+ export const precision: Record<Property, number> = {
30
+ opacity: 0.002,
31
+ x: 0.25,
32
+ y: 0.25,
33
+ scaleX: 0.001,
34
+ scaleY: 0.001,
35
+ rotate: 0.1,
36
+ rotateX: 0.1,
37
+ rotateY: 0.1,
38
+ }
39
+
40
+ export function isProperty(key: string): key is Property {
41
+ return key in viewKeys
42
+ }
43
+
44
+ export function readValue(view: View, property: Property): number {
45
+ const value = view[viewKeys[property]]
46
+ return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
47
+ }
48
+
49
+ export function writeValue(view: View, property: Property, value: number) {
50
+ view[viewKeys[property]] = value
51
+ }
@@ -0,0 +1,88 @@
1
+ import type { View } from '@nativescript/core'
2
+ import { animate, startingValues, toViews } from './animate.js'
3
+ import { arrivalOf, setArrival, stopView, type Animation } from './engine.js'
4
+ import type { PresetName } from './presets.js'
5
+ import { writeValue, type Property } from './properties.js'
6
+ import type { SpringName } from './transition.js'
7
+ import type { AnimateOptions, Keyframes, Target } from './types.js'
8
+
9
+ export type Step =
10
+ | readonly [
11
+ target: Target | readonly Target[],
12
+ animation: Keyframes | PresetName,
13
+ options?: AnimateOptions | SpringName,
14
+ ]
15
+ | (() => Animation)
16
+
17
+ /**
18
+ * Plays steps one after the other. A step starts once the previous one looks
19
+ * done: a spring is close to its target, even if it hasn't fully settled.
20
+ * Views in later steps take their first absolute keyframe right away, so a title
21
+ * that fades in third doesn't show up before its turn. Relative and null keyframes
22
+ * read the current value when their step starts.
23
+ *
24
+ * ```ts
25
+ * await sequence([
26
+ * [logo, 'popIn'],
27
+ * [title, 'fadeInUp'],
28
+ * [buttons, 'fadeInUp', { stagger: 50 }],
29
+ * ])
30
+ * ```
31
+ */
32
+ export function sequence(steps: readonly Step[]): Animation {
33
+ applyStartingValues(steps)
34
+
35
+ const started: Animation[] = []
36
+ let stopped = false
37
+
38
+ const arrived = (async () => {
39
+ for (const step of steps) {
40
+ if (stopped) return
41
+ const animation = typeof step === 'function' ? step() : animate(...step)
42
+ started.push(animation)
43
+ await arrivalOf(animation)
44
+ }
45
+ })()
46
+
47
+ const finished = arrived.then(async () => {
48
+ const results = await Promise.all(started.map((animation) => animation.finished))
49
+ return !stopped && results.length === steps.length && results.every(Boolean)
50
+ })
51
+
52
+ const animation: Animation = {
53
+ finished,
54
+ stop() {
55
+ stopped = true
56
+ started.forEach((animation) => animation.stop())
57
+ },
58
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
59
+ }
60
+ setArrival(animation, arrived)
61
+ return animation
62
+ }
63
+
64
+ function applyStartingValues(steps: readonly Step[]) {
65
+ const claimed = new Map<View, Set<Property>>()
66
+ for (const step of steps) {
67
+ if (typeof step === 'function') continue
68
+ const [target, animation, options] = step
69
+ const starts = startingValues(animation, options)
70
+
71
+ for (const view of toViews(target)) {
72
+ let properties = claimed.get(view)
73
+ if (!properties) claimed.set(view, (properties = new Set()))
74
+
75
+ const fresh = [...starts].filter(
76
+ ([property, value]) => !properties.has(property) && value !== null,
77
+ )
78
+ stopView(
79
+ view,
80
+ fresh.map(([property]) => property),
81
+ )
82
+ for (const [property, value] of fresh) {
83
+ if (value !== null) writeValue(view, property, value)
84
+ }
85
+ for (const property of starts.keys()) properties.add(property)
86
+ }
87
+ }
88
+ }
package/src/spring.ts ADDED
@@ -0,0 +1,95 @@
1
+ export interface SpringPhysics {
2
+ stiffness: number
3
+ damping: number
4
+ mass: number
5
+ }
6
+
7
+ export interface SpringParams {
8
+ stiffness?: number
9
+ damping?: number
10
+ mass?: number
11
+ bounce?: number
12
+ duration?: number
13
+ }
14
+
15
+ export interface SpringMotion {
16
+ /** Milliseconds until the spring settles. */
17
+ duration: number
18
+ position(time: number): number
19
+ /** Units per second. */
20
+ velocity(time: number): number
21
+ }
22
+
23
+ const MAX_DURATION = 10_000
24
+ const STEP = 1000 / 120
25
+
26
+ export function springPhysics({
27
+ stiffness,
28
+ damping,
29
+ mass,
30
+ bounce,
31
+ duration,
32
+ }: SpringParams): SpringPhysics {
33
+ if (stiffness !== undefined || damping !== undefined || mass !== undefined) {
34
+ return { stiffness: stiffness ?? 170, damping: damping ?? 26, mass: mass ?? 1 }
35
+ }
36
+
37
+ // The same model as SwiftUI: duration is the period of the undamped spring,
38
+ // bounce 0 is critically damped, negative values are overdamped.
39
+ const omega = (2 * Math.PI) / ((duration ?? 400) / 1000)
40
+ const b = Math.min(Math.max(bounce ?? 0.15, -0.99), 0.99)
41
+ const ratio = b >= 0 ? 1 - b : 1 / (1 + b)
42
+ return { stiffness: omega * omega, damping: 2 * ratio * omega, mass: 1 }
43
+ }
44
+
45
+ export function solveSpring(
46
+ { stiffness, damping, mass }: SpringPhysics,
47
+ from: number,
48
+ to: number,
49
+ velocity: number,
50
+ precision: number,
51
+ ): SpringMotion {
52
+ const x0 = from - to
53
+ const w0 = Math.sqrt(stiffness / mass)
54
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass))
55
+
56
+ let displacement: (t: number) => number
57
+ if (Math.abs(zeta - 1) < 1e-6) {
58
+ const b = velocity + w0 * x0
59
+ displacement = (t) => Math.exp(-w0 * t) * (x0 + b * t)
60
+ } else if (zeta < 1) {
61
+ const wd = w0 * Math.sqrt(1 - zeta * zeta)
62
+ const b = (velocity + zeta * w0 * x0) / wd
63
+ displacement = (t) =>
64
+ Math.exp(-zeta * w0 * t) * (x0 * Math.cos(wd * t) + b * Math.sin(wd * t))
65
+ } else {
66
+ const s = w0 * Math.sqrt(zeta * zeta - 1)
67
+ const r1 = -zeta * w0 + s
68
+ const r2 = -zeta * w0 - s
69
+ const c1 = (velocity - r2 * x0) / (r1 - r2)
70
+ const c2 = x0 - c1
71
+ displacement = (t) => c1 * Math.exp(r1 * t) + c2 * Math.exp(r2 * t)
72
+ }
73
+
74
+ const position = (time: number) => to + displacement(time / 1000)
75
+ const speed = (time: number) => {
76
+ const t = time / 1000
77
+ return (displacement(t + 0.0005) - displacement(t - 0.0005)) / 0.001
78
+ }
79
+
80
+ let duration = 0
81
+ while (
82
+ duration < MAX_DURATION &&
83
+ (Math.abs(displacement(duration / 1000)) > precision ||
84
+ Math.abs(speed(duration)) > precision * 10)
85
+ ) {
86
+ duration += STEP
87
+ }
88
+ duration = Math.ceil(duration)
89
+
90
+ return {
91
+ duration,
92
+ position: (time) => (time >= duration ? to : position(time)),
93
+ velocity: (time) => (time >= duration ? 0 : speed(time)),
94
+ }
95
+ }