@rafaelaugusto/jolt 0.0.0-stage → 0.1.0-beta.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -2
  3. package/dist/animate.d.ts +26 -0
  4. package/dist/animate.d.ts.map +1 -0
  5. package/dist/animate.js +153 -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 +68 -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 +47 -0
  24. package/dist/engine.d.ts.map +1 -0
  25. package/dist/engine.js +168 -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 +295 -0
  32. package/dist/presets.d.ts.map +1 -0
  33. package/dist/presets.js +123 -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 +32 -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 +25 -0
  44. package/dist/sequence.d.ts.map +1 -0
  45. package/dist/sequence.js +68 -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 +38 -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 +63 -4
  64. package/src/animate.ts +219 -0
  65. package/src/driver/android.ts +101 -0
  66. package/src/driver/index.ts +32 -0
  67. package/src/driver/ios.ts +88 -0
  68. package/src/easing.ts +100 -0
  69. package/src/engine.ts +208 -0
  70. package/src/index.ts +12 -0
  71. package/src/presets.ts +134 -0
  72. package/src/press.ts +43 -0
  73. package/src/properties.ts +51 -0
  74. package/src/sequence.ts +85 -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 +39 -0
package/src/engine.ts ADDED
@@ -0,0 +1,208 @@
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
+ }
24
+
25
+ interface Entry {
26
+ readonly track: Track
27
+ readonly startedAt: number
28
+ readonly owner: ViewAnimation
29
+ handle: unknown
30
+ done: boolean
31
+ }
32
+
33
+ const running = new WeakMap<View, Map<Property, Entry>>()
34
+ const arrivals = new WeakMap<Animation, Promise<unknown>>()
35
+
36
+ /**
37
+ * Resolves once the animation looks done, which for a spring is well before
38
+ * `finished`. Sequences move on to the next step then.
39
+ */
40
+ export function arrivalOf(animation: Animation): Promise<unknown> {
41
+ return arrivals.get(animation) ?? animation.finished
42
+ }
43
+
44
+ export function setArrival(animation: Animation, arrival: Promise<unknown>) {
45
+ arrivals.set(animation, arrival)
46
+ }
47
+
48
+ export class ViewAnimation implements Animation {
49
+ readonly finished: Promise<boolean>
50
+ private resolve!: (finished: boolean) => void
51
+ private readonly entries: Entry[] = []
52
+ private pending = 0
53
+ private complete = true
54
+ private settled = false
55
+ private cancelWait: (() => void) | undefined
56
+ private arrive!: () => void
57
+ private cancelArrival: (() => void) | undefined
58
+
59
+ constructor(
60
+ readonly view: View,
61
+ plans: readonly Plan[],
62
+ ) {
63
+ this.finished = new Promise((resolve) => (this.resolve = resolve))
64
+ setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
65
+ if (view.isLoaded && view.nativeViewProtected) this.start(plans)
66
+ else this.waitForLoad(plans)
67
+ }
68
+
69
+ then<A = boolean, B = never>(
70
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
71
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
72
+ ): Promise<A | B> {
73
+ return this.finished.then(onfulfilled, onrejected)
74
+ }
75
+
76
+ stop() {
77
+ if (this.settled) return
78
+ this.cancelWait?.()
79
+ const now = getDriver().now()
80
+ for (const entry of this.entries) {
81
+ if (!entry.done) release(entry, now, true)
82
+ }
83
+ this.complete = false
84
+ this.settle()
85
+ }
86
+
87
+ ended(finished: boolean) {
88
+ if (!finished) this.complete = false
89
+ if (--this.pending === 0) this.settle()
90
+ }
91
+
92
+ private start(plans: readonly Plan[]) {
93
+ const driver = getDriver()
94
+ const now = driver.now()
95
+ const entries = entriesOf(this.view)
96
+
97
+ const tracks = plans.map(({ property, keyframes, transition }) => {
98
+ const previous = entries.get(property)
99
+ let current: number
100
+ let velocity = 0
101
+ if (previous) {
102
+ const elapsed = now - previous.startedAt
103
+ current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
104
+ velocity = previous.track.velocityAt(elapsed)
105
+ release(previous, now, false)
106
+ } else {
107
+ current = readValue(this.view, property)
108
+ }
109
+ return createTrack(
110
+ property,
111
+ keyframes.map((value) => value ?? current),
112
+ transition,
113
+ velocity,
114
+ )
115
+ })
116
+
117
+ for (const track of tracks) writeValue(this.view, track.property, track.to)
118
+
119
+ for (const track of tracks) {
120
+ if (track.end === 0) continue
121
+ const entry: Entry = {
122
+ track,
123
+ startedAt: now,
124
+ owner: this,
125
+ handle: undefined,
126
+ done: false,
127
+ }
128
+ entries.set(track.property, entry)
129
+ this.entries.push(entry)
130
+ this.pending++
131
+ }
132
+
133
+ for (const entry of this.entries) {
134
+ entry.handle = driver.play(this.view, entry.track, now, () => {
135
+ if (entry.done) return
136
+ entry.done = true
137
+ forget(entry)
138
+ this.ended(true)
139
+ })
140
+ }
141
+
142
+ const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
143
+ if (this.pending === 0) this.settle()
144
+ else if (Number.isFinite(arrival)) {
145
+ this.cancelArrival = driver.after(arrival, this.arrive)
146
+ }
147
+ }
148
+
149
+ private waitForLoad(plans: readonly Plan[]) {
150
+ for (const { property, keyframes } of plans) {
151
+ const first = keyframes[0]
152
+ if (first !== null && first !== undefined) writeValue(this.view, property, first)
153
+ }
154
+ const onLoaded = () => {
155
+ this.cancelWait?.()
156
+ this.start(plans)
157
+ }
158
+ this.view.on('loaded', onLoaded)
159
+ this.cancelWait = () => {
160
+ this.view.off('loaded', onLoaded)
161
+ this.cancelWait = undefined
162
+ }
163
+ }
164
+
165
+ private settle() {
166
+ if (this.settled) return
167
+ this.settled = true
168
+ this.cancelArrival?.()
169
+ this.arrive()
170
+ this.resolve(this.complete)
171
+ }
172
+ }
173
+
174
+ /** Stops whatever is animating these properties of the view, or all of them. */
175
+ export function stopView(view: View, properties?: readonly Property[]) {
176
+ const entries = running.get(view)
177
+ if (!entries) return
178
+ const now = getDriver().now()
179
+ for (const [property, entry] of [...entries]) {
180
+ if (!properties || properties.includes(property)) release(entry, now, true)
181
+ }
182
+ }
183
+
184
+ function entriesOf(view: View) {
185
+ let entries = running.get(view)
186
+ if (!entries) running.set(view, (entries = new Map()))
187
+ return entries
188
+ }
189
+
190
+ function forget(entry: Entry) {
191
+ const entries = running.get(entry.owner.view)
192
+ if (entries?.get(entry.track.property) === entry) entries.delete(entry.track.property)
193
+ }
194
+
195
+ function release(entry: Entry, now: number, commit: boolean) {
196
+ const driver = getDriver()
197
+ const { view } = entry.owner
198
+ const { property } = entry.track
199
+ entry.done = true
200
+ forget(entry)
201
+ if (commit) {
202
+ const value =
203
+ driver.presented(view, property) ?? entry.track.valueAt(now - entry.startedAt)
204
+ writeValue(view, property, value)
205
+ }
206
+ driver.stop(view, entry.handle)
207
+ entry.owner.ended(false)
208
+ }
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 { presets } from './presets.js'
6
+ export type { PresetName } 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,134 @@
1
+ import type { AnimateOptions, Keyframes } from './types.js'
2
+
3
+ export interface Preset {
4
+ keyframes: Keyframes
5
+ transition: AnimateOptions
6
+ }
7
+
8
+ const fadeIn = { duration: 300, ease: 'easeOut' } as const
9
+ const fadeOut = { duration: 200, ease: 'easeIn' } as const
10
+ const enter = { type: 'spring', duration: 500, bounce: 0 } as const
11
+
12
+ export const presets = {
13
+ fadeIn: { keyframes: { opacity: [0, 1] }, transition: fadeIn },
14
+ fadeInUp: {
15
+ keyframes: { opacity: [0, 1], y: [24, 0] },
16
+ transition: { ...enter, opacity: fadeIn },
17
+ },
18
+ fadeInDown: {
19
+ keyframes: { opacity: [0, 1], y: [-24, 0] },
20
+ transition: { ...enter, opacity: fadeIn },
21
+ },
22
+ fadeInLeft: {
23
+ keyframes: { opacity: [0, 1], x: [-24, 0] },
24
+ transition: { ...enter, opacity: fadeIn },
25
+ },
26
+ fadeInRight: {
27
+ keyframes: { opacity: [0, 1], x: [24, 0] },
28
+ transition: { ...enter, opacity: fadeIn },
29
+ },
30
+ zoomIn: {
31
+ keyframes: { opacity: [0, 1], scale: [0.85, 1] },
32
+ transition: { ...enter, opacity: fadeIn },
33
+ },
34
+ popIn: {
35
+ keyframes: { opacity: [0, 1], scale: [0.5, 1] },
36
+ transition: {
37
+ type: 'spring',
38
+ duration: 550,
39
+ bounce: 0.4,
40
+ opacity: { duration: 150 },
41
+ },
42
+ },
43
+ flipInX: {
44
+ keyframes: { opacity: [0, 1], rotateX: [-90, 0] },
45
+ transition: {
46
+ type: 'spring',
47
+ duration: 600,
48
+ bounce: 0.25,
49
+ opacity: { duration: 200 },
50
+ },
51
+ },
52
+ flipInY: {
53
+ keyframes: { opacity: [0, 1], rotateY: [90, 0] },
54
+ transition: {
55
+ type: 'spring',
56
+ duration: 600,
57
+ bounce: 0.25,
58
+ opacity: { duration: 200 },
59
+ },
60
+ },
61
+
62
+ fadeOut: { keyframes: { opacity: 0 }, transition: fadeOut },
63
+ fadeOutUp: { keyframes: { opacity: 0, y: -24 }, transition: fadeOut },
64
+ fadeOutDown: { keyframes: { opacity: 0, y: 24 }, transition: fadeOut },
65
+ fadeOutLeft: { keyframes: { opacity: 0, x: -24 }, transition: fadeOut },
66
+ fadeOutRight: { keyframes: { opacity: 0, x: 24 }, transition: fadeOut },
67
+ zoomOut: { keyframes: { opacity: 0, scale: 0.85 }, transition: fadeOut },
68
+ popOut: {
69
+ keyframes: { opacity: 0, scale: [null, 1.08, 0.4] },
70
+ transition: {
71
+ duration: 250,
72
+ ease: ['easeOut', 'easeIn'],
73
+ times: [0, 0.3, 1],
74
+ opacity: { duration: 250, ease: 'easeIn' },
75
+ },
76
+ },
77
+
78
+ pulse: {
79
+ keyframes: { scale: [1, 1.06, 1] },
80
+ transition: { duration: 500, ease: 'easeInOut' },
81
+ },
82
+ bounce: {
83
+ keyframes: { y: [0, -18, 0, -8, 0] },
84
+ transition: {
85
+ duration: 650,
86
+ times: [0, 0.3, 0.6, 0.8, 1],
87
+ ease: ['easeOut', 'easeIn', 'easeOut', 'easeIn'],
88
+ },
89
+ },
90
+ shake: {
91
+ keyframes: { x: [0, -10, 10, -8, 8, -4, 4, 0] },
92
+ transition: { duration: 500, ease: 'easeInOut' },
93
+ },
94
+ wiggle: {
95
+ keyframes: { rotate: [0, -8, 8, -5, 5, 0] },
96
+ transition: { duration: 500, ease: 'easeInOut' },
97
+ },
98
+ heartbeat: {
99
+ keyframes: { scale: [1, 1.15, 1, 1.15, 1, 1] },
100
+ transition: {
101
+ duration: 1000,
102
+ times: [0, 0.14, 0.28, 0.42, 0.7, 1],
103
+ ease: 'easeInOut',
104
+ },
105
+ },
106
+ jelly: {
107
+ keyframes: {
108
+ scaleX: [1, 1.25, 0.75, 1.15, 0.95, 1.05, 1],
109
+ scaleY: [1, 0.75, 1.25, 0.85, 1.05, 0.95, 1],
110
+ },
111
+ transition: {
112
+ duration: 800,
113
+ times: [0, 0.3, 0.4, 0.5, 0.65, 0.75, 1],
114
+ ease: 'easeInOut',
115
+ },
116
+ },
117
+ flash: {
118
+ keyframes: { opacity: [1, 0, 1, 0, 1] },
119
+ transition: { duration: 800, ease: 'easeInOut' },
120
+ },
121
+ tada: {
122
+ keyframes: {
123
+ scale: [1, 0.9, 0.9, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1],
124
+ rotate: [0, -3, -3, 3, -3, 3, -3, 3, -3, 3, 0],
125
+ },
126
+ transition: { duration: 1000, ease: 'easeInOut' },
127
+ },
128
+ spin: {
129
+ keyframes: { rotate: [0, 360] },
130
+ transition: { duration: 1000, ease: 'linear' },
131
+ },
132
+ } satisfies Record<string, Preset>
133
+
134
+ export type PresetName = keyof typeof presets
package/src/press.ts ADDED
@@ -0,0 +1,43 @@
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(view, rest, options ?? release)
37
+ rest = undefined
38
+ }
39
+ }
40
+
41
+ view.on('touch', onTouch)
42
+ return () => view.off('touch', onTouch)
43
+ }
@@ -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,85 @@
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 keyframe right away, so a title that
21
+ * fades in third doesn't show up before its turn.
22
+ *
23
+ * ```ts
24
+ * await sequence([
25
+ * [logo, 'popIn'],
26
+ * [title, 'fadeInUp'],
27
+ * [buttons, 'fadeInUp', { stagger: 50 }],
28
+ * ])
29
+ * ```
30
+ */
31
+ export function sequence(steps: readonly Step[]): Animation {
32
+ applyStartingValues(steps)
33
+
34
+ const started: Animation[] = []
35
+ let stopped = false
36
+
37
+ const arrived = (async () => {
38
+ for (const step of steps) {
39
+ if (stopped) return
40
+ const animation = typeof step === 'function' ? step() : animate(...step)
41
+ started.push(animation)
42
+ await arrivalOf(animation)
43
+ }
44
+ })()
45
+
46
+ const finished = arrived.then(async () => {
47
+ const results = await Promise.all(started.map((animation) => animation.finished))
48
+ return !stopped && results.length === steps.length && results.every(Boolean)
49
+ })
50
+
51
+ const animation: Animation = {
52
+ finished,
53
+ stop() {
54
+ stopped = true
55
+ started.forEach((animation) => animation.stop())
56
+ },
57
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
58
+ }
59
+ setArrival(animation, arrived)
60
+ return animation
61
+ }
62
+
63
+ function applyStartingValues(steps: readonly Step[]) {
64
+ const claimed = new Map<View, Set<Property>>()
65
+ for (const step of steps) {
66
+ if (typeof step === 'function') continue
67
+ const [target, animation] = step
68
+ const starts = startingValues(animation)
69
+
70
+ for (const view of toViews(target)) {
71
+ let properties = claimed.get(view)
72
+ if (!properties) claimed.set(view, (properties = new Set()))
73
+
74
+ const fresh = [...starts].filter(([property]) => !properties.has(property))
75
+ stopView(
76
+ view,
77
+ fresh.map(([property]) => property),
78
+ )
79
+ for (const [property, value] of fresh) {
80
+ if (value !== null) writeValue(view, property, value)
81
+ }
82
+ for (const property of starts.keys()) properties.add(property)
83
+ }
84
+ }
85
+ }
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
+ }