@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.
- package/LICENSE +21 -0
- package/README.md +256 -2
- package/dist/animate.d.ts +26 -0
- package/dist/animate.d.ts.map +1 -0
- package/dist/animate.js +153 -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 +68 -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 +47 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +168 -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 +295 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +123 -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 +32 -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 +25 -0
- package/dist/sequence.d.ts.map +1 -0
- package/dist/sequence.js +68 -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 +38 -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 +63 -4
- package/src/animate.ts +219 -0
- package/src/driver/android.ts +101 -0
- package/src/driver/index.ts +32 -0
- package/src/driver/ios.ts +88 -0
- package/src/easing.ts +100 -0
- package/src/engine.ts +208 -0
- package/src/index.ts +12 -0
- package/src/presets.ts +134 -0
- package/src/press.ts +43 -0
- package/src/properties.ts +51 -0
- package/src/sequence.ts +85 -0
- package/src/spring.ts +95 -0
- package/src/track.ts +163 -0
- package/src/transition.ts +160 -0
- 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
|
+
}
|
package/src/sequence.ts
ADDED
|
@@ -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
|
+
}
|