@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.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/animate.d.ts +26 -0
- package/dist/animate.d.ts.map +1 -0
- package/dist/animate.js +161 -0
- package/dist/animate.js.map +1 -0
- package/dist/driver/android.d.ts +3 -0
- package/dist/driver/android.d.ts.map +1 -0
- package/dist/driver/android.js +73 -0
- package/dist/driver/android.js.map +1 -0
- package/dist/driver/index.d.ts +18 -0
- package/dist/driver/index.d.ts.map +1 -0
- package/dist/driver/index.js +15 -0
- package/dist/driver/index.js.map +1 -0
- package/dist/driver/ios.d.ts +3 -0
- package/dist/driver/ios.d.ts.map +1 -0
- package/dist/driver/ios.js +77 -0
- package/dist/driver/ios.js.map +1 -0
- package/dist/easing.d.ts +8 -0
- package/dist/easing.d.ts.map +1 -0
- package/dist/easing.js +71 -0
- package/dist/easing.js.map +1 -0
- package/dist/engine.d.ts +48 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +178 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/presets.d.ts +310 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +141 -0
- package/dist/presets.js.map +1 -0
- package/dist/press.d.ts +13 -0
- package/dist/press.d.ts.map +1 -0
- package/dist/press.js +34 -0
- package/dist/press.js.map +1 -0
- package/dist/properties.d.ts +8 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/properties.js +42 -0
- package/dist/properties.js.map +1 -0
- package/dist/sequence.d.ts +26 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +69 -0
- package/dist/sequence.js.map +1 -0
- package/dist/spring.d.ts +22 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +54 -0
- package/dist/spring.js.map +1 -0
- package/dist/track.d.ts +28 -0
- package/dist/track.d.ts.map +1 -0
- package/dist/track.js +103 -0
- package/dist/track.js.map +1 -0
- package/dist/transition.d.ts +58 -0
- package/dist/transition.d.ts.map +1 -0
- package/dist/transition.js +82 -0
- package/dist/transition.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -4
- package/src/animate.ts +246 -0
- package/src/driver/android.ts +101 -0
- package/src/driver/index.ts +32 -0
- package/src/driver/ios.ts +97 -0
- package/src/easing.ts +100 -0
- package/src/engine.ts +216 -0
- package/src/index.ts +12 -0
- package/src/presets.ts +158 -0
- package/src/press.ts +49 -0
- package/src/properties.ts +51 -0
- package/src/sequence.ts +88 -0
- package/src/spring.ts +95 -0
- package/src/track.ts +163 -0
- package/src/transition.ts +160 -0
- 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
|
+
}
|
package/src/sequence.ts
ADDED
|
@@ -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
|
+
}
|