@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.3
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/README.md +115 -8
- package/dist/animate.d.ts +3 -1
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +83 -6
- package/dist/animate.js.map +1 -1
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +95 -5
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +3 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +7 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +6 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +21 -5
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +18 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +96 -0
- package/dist/haptics.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +172 -0
- package/dist/loader.js.map +1 -0
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +264 -0
- package/dist/particles.js.map +1 -0
- package/dist/properties.d.ts +1 -1
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +1 -1
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +293 -0
- package/dist/reveal.js.map +1 -0
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +160 -0
- package/dist/shimmer.js.map +1 -0
- package/dist/symbol-effect.d.ts +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +186 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/text-layout.d.ts +22 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +179 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +264 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +25 -3
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +5 -0
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/animate.ts +96 -4
- package/src/decay.ts +45 -0
- package/src/driver/android.ts +112 -6
- package/src/driver/index.ts +3 -1
- package/src/driver/ios.ts +11 -0
- package/src/engine.ts +22 -6
- package/src/haptics.ts +125 -0
- package/src/index.ts +18 -1
- package/src/loader.ts +201 -0
- package/src/particles.ts +323 -0
- package/src/properties.ts +1 -1
- package/src/reveal.ts +374 -0
- package/src/shimmer.ts +179 -0
- package/src/symbol-effect.ts +225 -0
- package/src/text-layout.ts +216 -0
- package/src/text.ts +296 -0
- package/src/track.ts +42 -3
- package/src/transition.ts +6 -0
- package/src/types.ts +16 -0
package/src/animate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { View } from '@nativescript/core'
|
|
2
|
+
import { DEFAULT_FRICTION, solveDecay } from './decay.js'
|
|
2
3
|
import {
|
|
3
4
|
arrivalOf,
|
|
4
5
|
setArrival,
|
|
@@ -8,7 +9,8 @@ import {
|
|
|
8
9
|
type Plan,
|
|
9
10
|
} from './engine.js'
|
|
10
11
|
import { getPreset, type PresetName } from './presets.js'
|
|
11
|
-
import {
|
|
12
|
+
import { resolveHaptic, type Haptic } from './haptics.js'
|
|
13
|
+
import { isProperty, precision, type Property } from './properties.js'
|
|
12
14
|
import {
|
|
13
15
|
hasCurve,
|
|
14
16
|
mergeTransitions,
|
|
@@ -19,6 +21,8 @@ import {
|
|
|
19
21
|
} from './transition.js'
|
|
20
22
|
import type {
|
|
21
23
|
AnimateOptions,
|
|
24
|
+
DecayOptions,
|
|
25
|
+
DecayVelocity,
|
|
22
26
|
Keyframes,
|
|
23
27
|
KeyframeValue,
|
|
24
28
|
Stagger,
|
|
@@ -30,6 +34,7 @@ interface Options {
|
|
|
30
34
|
overrides: Partial<Record<keyof Keyframes, Transition>>
|
|
31
35
|
stagger: number | Stagger | undefined
|
|
32
36
|
relative: boolean | undefined
|
|
37
|
+
haptic: Haptic | undefined
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
@@ -47,12 +52,17 @@ export function animate(
|
|
|
47
52
|
animation: Keyframes | PresetName,
|
|
48
53
|
options?: AnimateOptions | SpringName,
|
|
49
54
|
): Animation {
|
|
50
|
-
const { keyframes, base, overrides, stagger, relative } = prepare(
|
|
55
|
+
const { keyframes, base, overrides, stagger, relative, haptic } = prepare(
|
|
56
|
+
animation,
|
|
57
|
+
options,
|
|
58
|
+
)
|
|
59
|
+
const feedback = resolveHaptic(haptic)
|
|
51
60
|
|
|
52
61
|
if (!isList(target)) {
|
|
53
62
|
return new ViewAnimation(
|
|
54
63
|
toView(target),
|
|
55
64
|
plan(keyframes, base, overrides, 0, relative),
|
|
65
|
+
feedback,
|
|
56
66
|
)
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -69,11 +79,83 @@ export function animate(
|
|
|
69
79
|
staggerDelay(stagger, index, views.length),
|
|
70
80
|
relative,
|
|
71
81
|
),
|
|
82
|
+
feedback,
|
|
72
83
|
),
|
|
73
84
|
),
|
|
74
85
|
)
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/** Starts at the current translation and slows down from a velocity in DIPs per second. */
|
|
89
|
+
export function decay(
|
|
90
|
+
target: Target | readonly Target[],
|
|
91
|
+
velocity: DecayVelocity,
|
|
92
|
+
options: DecayOptions = {},
|
|
93
|
+
): Animation {
|
|
94
|
+
const friction = options.friction ?? DEFAULT_FRICTION
|
|
95
|
+
if (!Number.isFinite(friction) || friction <= 0 || friction >= 1) {
|
|
96
|
+
throw new RangeError('Decay friction must be between 0 and 1 exclusively.')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stops = (values: readonly number[] | undefined) => {
|
|
100
|
+
if (values === undefined) return undefined
|
|
101
|
+
if (
|
|
102
|
+
!Array.isArray(values) ||
|
|
103
|
+
values.length === 0 ||
|
|
104
|
+
![...values].every(Number.isFinite)
|
|
105
|
+
) {
|
|
106
|
+
throw new RangeError('Decay snap needs a nonempty array of finite positions.')
|
|
107
|
+
}
|
|
108
|
+
return [...values]
|
|
109
|
+
}
|
|
110
|
+
const snap = options.snap
|
|
111
|
+
if (
|
|
112
|
+
snap !== undefined &&
|
|
113
|
+
!Array.isArray(snap) &&
|
|
114
|
+
(snap === null ||
|
|
115
|
+
typeof snap !== 'object' ||
|
|
116
|
+
Object.keys(snap).some((key) => key !== 'x' && key !== 'y'))
|
|
117
|
+
) {
|
|
118
|
+
throw new TypeError('Decay snap must be an array or an object with x and y stops.')
|
|
119
|
+
}
|
|
120
|
+
const shared = Array.isArray(snap)
|
|
121
|
+
const perAxis = snap as { x?: readonly number[]; y?: readonly number[] } | undefined
|
|
122
|
+
const snapped = shared ? stops(snap) : undefined
|
|
123
|
+
const snapX = shared ? snapped : stops(perAxis?.x)
|
|
124
|
+
const snapY = shared ? snapped : stops(perAxis?.y)
|
|
125
|
+
|
|
126
|
+
const plans = Object.entries(velocity).flatMap<Plan>(([property, value]) => {
|
|
127
|
+
if (property !== 'x' && property !== 'y') {
|
|
128
|
+
throw new Error(`Can't decay "${property}". Use x or y.`)
|
|
129
|
+
}
|
|
130
|
+
if (value === undefined) return []
|
|
131
|
+
if (!Number.isFinite(value)) {
|
|
132
|
+
throw new RangeError(`Invalid decay velocity for "${property}".`)
|
|
133
|
+
}
|
|
134
|
+
// Validate every axis before handing any plans to the engine.
|
|
135
|
+
solveDecay(0, value, friction, precision[property])
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
property,
|
|
139
|
+
keyframes: [null, null],
|
|
140
|
+
transition: {
|
|
141
|
+
curve: {
|
|
142
|
+
type: 'decay',
|
|
143
|
+
friction,
|
|
144
|
+
velocity: value,
|
|
145
|
+
snap: property === 'x' ? snapX : snapY,
|
|
146
|
+
},
|
|
147
|
+
delay: 0,
|
|
148
|
+
repeat: 0,
|
|
149
|
+
reverse: false,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
})
|
|
154
|
+
const views = toViews(target)
|
|
155
|
+
if (plans.length === 0) return group([])
|
|
156
|
+
return group(views.map((view) => new ViewAnimation(view, plans)))
|
|
157
|
+
}
|
|
158
|
+
|
|
77
159
|
/** Stops all or selected properties at their current values. `scale` stops both axes. */
|
|
78
160
|
export function stop(
|
|
79
161
|
target: Target | readonly Target[],
|
|
@@ -133,33 +215,43 @@ function prepare(
|
|
|
133
215
|
: { ...own.overrides, ...user.overrides },
|
|
134
216
|
stagger: user.stagger ?? own.stagger,
|
|
135
217
|
relative: user.relative ?? own.relative,
|
|
218
|
+
haptic: user.haptic ?? own.haptic,
|
|
136
219
|
}
|
|
137
220
|
}
|
|
138
221
|
|
|
139
222
|
function normalize(options: AnimateOptions | SpringName | undefined): Options {
|
|
140
223
|
if (options === undefined)
|
|
141
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
base: {},
|
|
226
|
+
overrides: {},
|
|
227
|
+
stagger: undefined,
|
|
228
|
+
relative: undefined,
|
|
229
|
+
haptic: undefined,
|
|
230
|
+
}
|
|
142
231
|
if (typeof options === 'string')
|
|
143
232
|
return {
|
|
144
233
|
base: spring(options),
|
|
145
234
|
overrides: {},
|
|
146
235
|
stagger: undefined,
|
|
147
236
|
relative: undefined,
|
|
237
|
+
haptic: undefined,
|
|
148
238
|
}
|
|
149
239
|
|
|
150
240
|
const base: Record<string, unknown> = {}
|
|
151
241
|
const overrides: Options['overrides'] = {}
|
|
152
242
|
let stagger: Options['stagger']
|
|
153
243
|
let relative: Options['relative']
|
|
244
|
+
let haptic: Options['haptic']
|
|
154
245
|
for (const [key, value] of Object.entries(options)) {
|
|
155
246
|
if (value === undefined) continue
|
|
156
247
|
if (key === 'stagger') stagger = value as Options['stagger']
|
|
157
248
|
else if (key === 'relative') relative = value as Options['relative']
|
|
249
|
+
else if (key === 'haptic') haptic = value as Options['haptic']
|
|
158
250
|
else if (key === 'scale' || isProperty(key)) {
|
|
159
251
|
overrides[key] = typeof value === 'string' ? spring(value as SpringName) : value
|
|
160
252
|
} else base[key] = value
|
|
161
253
|
}
|
|
162
|
-
return { base, overrides, stagger, relative }
|
|
254
|
+
return { base, overrides, stagger, relative, haptic }
|
|
163
255
|
}
|
|
164
256
|
|
|
165
257
|
function plan(
|
package/src/decay.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface DecayMotion {
|
|
2
|
+
duration: number
|
|
3
|
+
to: number
|
|
4
|
+
position(time: number): number
|
|
5
|
+
velocity(time: number): number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Velocity retains this fraction every millisecond, independently of the display's refresh rate. */
|
|
9
|
+
export const DEFAULT_FRICTION = 0.998
|
|
10
|
+
|
|
11
|
+
/** Exponential motion, with velocity in units per second and time in milliseconds. */
|
|
12
|
+
export function solveDecay(
|
|
13
|
+
from: number,
|
|
14
|
+
velocity: number,
|
|
15
|
+
friction: number,
|
|
16
|
+
precision: number,
|
|
17
|
+
): DecayMotion {
|
|
18
|
+
const rate = -Math.log(friction)
|
|
19
|
+
const distance = velocity / (1000 * rate)
|
|
20
|
+
const to = from + distance
|
|
21
|
+
// Stop only when both the remaining distance and speed are imperceptible.
|
|
22
|
+
const duration =
|
|
23
|
+
velocity === 0
|
|
24
|
+
? 0
|
|
25
|
+
: Math.ceil(
|
|
26
|
+
Math.max(
|
|
27
|
+
0,
|
|
28
|
+
Math.log(Math.abs(distance) / precision) / rate,
|
|
29
|
+
Math.log(Math.abs(velocity) / (precision * 10)) / rate,
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if (!Number.isFinite(to) || !Number.isFinite(duration) || duration > 2_147_483_647) {
|
|
34
|
+
throw new RangeError('Decay velocity and friction produce an unplayable motion.')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
duration,
|
|
39
|
+
to,
|
|
40
|
+
position: (time) =>
|
|
41
|
+
time >= duration ? to : from - distance * Math.expm1(-rate * Math.max(0, time)),
|
|
42
|
+
velocity: (time) =>
|
|
43
|
+
time >= duration ? 0 : velocity * Math.exp(-rate * Math.max(0, time)),
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/driver/android.ts
CHANGED
|
@@ -1,9 +1,48 @@
|
|
|
1
1
|
import { Utils, type View } from '@nativescript/core'
|
|
2
2
|
import type { Property } from '../properties.js'
|
|
3
|
-
import {
|
|
3
|
+
import type { Driver } from './index.js'
|
|
4
4
|
|
|
5
5
|
type NativeView = android.view.View
|
|
6
6
|
|
|
7
|
+
interface Handle {
|
|
8
|
+
cancel(): void
|
|
9
|
+
elapsed(): number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Own only layers we create; keep app-configured hardware/software layers and paints intact. */
|
|
13
|
+
function fadeLayer(view: View, nativeView: NativeView): (() => void) | undefined {
|
|
14
|
+
const { View, ViewGroup } = android.view
|
|
15
|
+
if (
|
|
16
|
+
!view.isLoaded ||
|
|
17
|
+
view.nativeViewProtected !== nativeView ||
|
|
18
|
+
!(nativeView instanceof ViewGroup) ||
|
|
19
|
+
nativeView.getChildCount() === 0 ||
|
|
20
|
+
!nativeView.isHardwareAccelerated() ||
|
|
21
|
+
nativeView.getLayerType() !== View.LAYER_TYPE_NONE
|
|
22
|
+
)
|
|
23
|
+
return undefined
|
|
24
|
+
|
|
25
|
+
// Do not cache an entire oversized scrolling/content tree into a GPU texture.
|
|
26
|
+
const { widthPixels, heightPixels } = nativeView.getResources().getDisplayMetrics()
|
|
27
|
+
const width = nativeView.getWidth()
|
|
28
|
+
const height = nativeView.getHeight()
|
|
29
|
+
if (width <= 0 || height <= 0 || width > widthPixels || height > heightPixels)
|
|
30
|
+
return undefined
|
|
31
|
+
|
|
32
|
+
nativeView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
|
33
|
+
let active = true
|
|
34
|
+
const restore = () => {
|
|
35
|
+
if (!active) return
|
|
36
|
+
active = false
|
|
37
|
+
view.off('unloaded', restore)
|
|
38
|
+
if (nativeView.getLayerType() === View.LAYER_TYPE_HARDWARE) {
|
|
39
|
+
nativeView.setLayerType(View.LAYER_TYPE_NONE, null)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
view.on('unloaded', restore)
|
|
43
|
+
return restore
|
|
44
|
+
}
|
|
45
|
+
|
|
7
46
|
const accessors: Record<
|
|
8
47
|
Property,
|
|
9
48
|
{ get(view: NativeView): number; set(view: NativeView, value: number): void }
|
|
@@ -42,6 +81,12 @@ export function createAndroidDriver(): Driver {
|
|
|
42
81
|
const nativeView = view.nativeViewProtected as NativeView
|
|
43
82
|
const scale = unit(track.property)
|
|
44
83
|
|
|
84
|
+
if (track.property === 'rotateX' || track.property === 'rotateY') {
|
|
85
|
+
nativeView.setCameraDistance(
|
|
86
|
+
(view.perspective || 300) * Utils.layout.getDisplayDensity(),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
45
90
|
const keyframes = Array.create(android.animation.Keyframe, track.values.length)
|
|
46
91
|
track.values.forEach((value, i) => {
|
|
47
92
|
keyframes[i] = android.animation.Keyframe.ofFloat(track.times[i]!, value * scale)
|
|
@@ -56,6 +101,14 @@ export function createAndroidDriver(): Driver {
|
|
|
56
101
|
nativeView,
|
|
57
102
|
holders,
|
|
58
103
|
)
|
|
104
|
+
let cancelled = false
|
|
105
|
+
let ended = false
|
|
106
|
+
let started = false
|
|
107
|
+
let restoreLayer: (() => void) | undefined
|
|
108
|
+
const releaseLayer = () => {
|
|
109
|
+
restoreLayer?.()
|
|
110
|
+
restoreLayer = undefined
|
|
111
|
+
}
|
|
59
112
|
animator.setDuration(Math.max(track.duration, 1))
|
|
60
113
|
animator.setStartDelay(track.delay)
|
|
61
114
|
animator.setInterpolator(interpolator)
|
|
@@ -73,10 +126,25 @@ export function createAndroidDriver(): Driver {
|
|
|
73
126
|
}
|
|
74
127
|
animator.addListener(
|
|
75
128
|
new android.animation.Animator.AnimatorListener({
|
|
76
|
-
onAnimationStart() {
|
|
129
|
+
onAnimationStart() {
|
|
130
|
+
started = true
|
|
131
|
+
if (
|
|
132
|
+
!cancelled &&
|
|
133
|
+
!ended &&
|
|
134
|
+
track.property === 'opacity' &&
|
|
135
|
+
track.values.some((value) => value !== track.values[0])
|
|
136
|
+
)
|
|
137
|
+
restoreLayer = fadeLayer(view, nativeView)
|
|
138
|
+
},
|
|
77
139
|
onAnimationRepeat() {},
|
|
78
|
-
onAnimationCancel() {
|
|
140
|
+
onAnimationCancel() {
|
|
141
|
+
cancelled = true
|
|
142
|
+
releaseLayer()
|
|
143
|
+
},
|
|
79
144
|
onAnimationEnd() {
|
|
145
|
+
if (ended) return
|
|
146
|
+
ended = true
|
|
147
|
+
releaseLayer()
|
|
80
148
|
onEnd()
|
|
81
149
|
},
|
|
82
150
|
}),
|
|
@@ -84,18 +152,56 @@ export function createAndroidDriver(): Driver {
|
|
|
84
152
|
|
|
85
153
|
accessors[track.property].set(nativeView, track.values[0]! * scale)
|
|
86
154
|
animator.start()
|
|
87
|
-
return
|
|
155
|
+
return {
|
|
156
|
+
elapsed: () => (started ? track.delay + animator.getCurrentPlayTime() : 0),
|
|
157
|
+
cancel() {
|
|
158
|
+
// Cancel before native callbacks: Android can send onStart while cancelling a delay.
|
|
159
|
+
cancelled = true
|
|
160
|
+
releaseLayer()
|
|
161
|
+
animator.cancel()
|
|
162
|
+
},
|
|
163
|
+
} satisfies Handle
|
|
88
164
|
},
|
|
89
165
|
|
|
90
166
|
stop(_view, handle) {
|
|
91
|
-
;(handle as
|
|
167
|
+
;(handle as Handle).cancel()
|
|
92
168
|
},
|
|
93
169
|
|
|
170
|
+
elapsed: (handle) => (handle as Handle).elapsed(),
|
|
171
|
+
|
|
94
172
|
presented(view, property) {
|
|
95
173
|
const nativeView = view.nativeViewProtected as NativeView | undefined
|
|
96
174
|
return nativeView ? accessors[property].get(nativeView) / unit(property) : undefined
|
|
97
175
|
},
|
|
98
176
|
|
|
99
|
-
after,
|
|
177
|
+
after(ms, callback) {
|
|
178
|
+
// A native marker follows animator duration scale, including changes during playback.
|
|
179
|
+
// Only completion crosses the bridge; there is no JavaScript update listener.
|
|
180
|
+
const values = Array.create('float', 2)
|
|
181
|
+
values[0] = 0
|
|
182
|
+
values[1] = 1
|
|
183
|
+
const marker = android.animation.ValueAnimator.ofFloat(values)
|
|
184
|
+
marker.setDuration(ms)
|
|
185
|
+
let active = true
|
|
186
|
+
marker.addListener(
|
|
187
|
+
new android.animation.Animator.AnimatorListener({
|
|
188
|
+
onAnimationStart() {},
|
|
189
|
+
onAnimationRepeat() {},
|
|
190
|
+
onAnimationCancel() {
|
|
191
|
+
active = false
|
|
192
|
+
},
|
|
193
|
+
onAnimationEnd() {
|
|
194
|
+
if (!active) return
|
|
195
|
+
active = false
|
|
196
|
+
callback()
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
marker.start()
|
|
201
|
+
return () => {
|
|
202
|
+
active = false
|
|
203
|
+
marker.cancel()
|
|
204
|
+
}
|
|
205
|
+
},
|
|
100
206
|
}
|
|
101
207
|
}
|
package/src/driver/index.ts
CHANGED
|
@@ -10,9 +10,11 @@ export interface Driver {
|
|
|
10
10
|
/** Starts the track at `startedAt + track.delay` and calls `onEnd` once, however it ends. */
|
|
11
11
|
play(view: View, track: Track, startedAt: number, onEnd: () => void): unknown
|
|
12
12
|
stop(view: View, handle: unknown): void
|
|
13
|
+
/** Track time, including its delay, when the native clock differs from `now()`. */
|
|
14
|
+
elapsed?(handle: unknown): number
|
|
13
15
|
/** The value on screen, when the platform knows it better than the track does. */
|
|
14
16
|
presented(view: View, property: Property): number | undefined
|
|
15
|
-
/** Calls back after `ms`
|
|
17
|
+
/** Calls back after `ms` of animation time, respecting platform speed settings. */
|
|
16
18
|
after(ms: number, callback: () => void): () => void
|
|
17
19
|
}
|
|
18
20
|
|
package/src/driver/ios.ts
CHANGED
|
@@ -58,6 +58,17 @@ export function createIOSDriver(): Driver {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const animation = CAKeyframeAnimation.animationWithKeyPath(keyPaths[track.property])
|
|
61
|
+
// iOS 15+. The app must opt in through CADisableMinimumFrameDurationOnPhone.
|
|
62
|
+
if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
|
|
63
|
+
const nativeView = view.nativeViewProtected as UIView
|
|
64
|
+
const screen = nativeView.window?.screen ?? UIScreen.mainScreen
|
|
65
|
+
const maximum = Math.min(120, screen.maximumFramesPerSecond)
|
|
66
|
+
animation.preferredFrameRateRange = CAFrameRateRangeMake(
|
|
67
|
+
Math.min(80, maximum),
|
|
68
|
+
maximum,
|
|
69
|
+
maximum,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
61
72
|
animation.values = NSArray.arrayWithArray(
|
|
62
73
|
track.values.map((value) => (rotation ? radians(value) : value)),
|
|
63
74
|
)
|
package/src/engine.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { View } from '@nativescript/core'
|
|
2
2
|
import { getDriver } from './driver/index.js'
|
|
3
|
+
import { playHaptics, type HapticOptions } from './haptics.js'
|
|
3
4
|
import { readValue, writeValue, type Property } from './properties.js'
|
|
4
5
|
import { createTrack, type Track } from './track.js'
|
|
5
6
|
import type { ResolvedTransition } from './transition.js'
|
|
@@ -7,11 +8,11 @@ import type { ResolvedTransition } from './transition.js'
|
|
|
7
8
|
export interface Animation extends PromiseLike<boolean> {
|
|
8
9
|
/**
|
|
9
10
|
* Resolves with `true` once the animation played to the end, or with `false`
|
|
10
|
-
* if it was stopped or
|
|
11
|
+
* if it was stopped, superseded or skipped.
|
|
11
12
|
* Never rejects.
|
|
12
13
|
*/
|
|
13
14
|
readonly finished: Promise<boolean>
|
|
14
|
-
/** Stops
|
|
15
|
+
/** Stops playback. Each effect documents whether it preserves or restores its visual state. */
|
|
15
16
|
stop(): void
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -56,10 +57,12 @@ export class ViewAnimation implements Animation {
|
|
|
56
57
|
private cancelWait: (() => void) | undefined
|
|
57
58
|
private arrive!: () => void
|
|
58
59
|
private cancelArrival: (() => void) | undefined
|
|
60
|
+
private haptics: ReturnType<typeof playHaptics>
|
|
59
61
|
|
|
60
62
|
constructor(
|
|
61
63
|
readonly view: View,
|
|
62
64
|
plans: readonly Plan[],
|
|
65
|
+
private readonly haptic?: HapticOptions,
|
|
63
66
|
) {
|
|
64
67
|
this.finished = new Promise((resolve) => (this.resolve = resolve))
|
|
65
68
|
setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
|
|
@@ -77,6 +80,7 @@ export class ViewAnimation implements Animation {
|
|
|
77
80
|
stop() {
|
|
78
81
|
if (this.settled) return
|
|
79
82
|
this.cancelWait?.()
|
|
83
|
+
this.haptics?.cancel()
|
|
80
84
|
const now = getDriver().now()
|
|
81
85
|
for (const entry of this.entries) {
|
|
82
86
|
if (!entry.done) release(entry, now, true)
|
|
@@ -86,7 +90,10 @@ export class ViewAnimation implements Animation {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
ended(finished: boolean) {
|
|
89
|
-
if (!finished)
|
|
93
|
+
if (!finished) {
|
|
94
|
+
this.complete = false
|
|
95
|
+
this.haptics?.cancel()
|
|
96
|
+
}
|
|
90
97
|
if (--this.pending === 0) this.settle()
|
|
91
98
|
}
|
|
92
99
|
|
|
@@ -100,7 +107,7 @@ export class ViewAnimation implements Animation {
|
|
|
100
107
|
let current: number
|
|
101
108
|
let velocity = 0
|
|
102
109
|
if (previous) {
|
|
103
|
-
const elapsed = now
|
|
110
|
+
const elapsed = elapsedOf(previous, now)
|
|
104
111
|
current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
|
|
105
112
|
velocity = previous.track.velocityAt(elapsed)
|
|
106
113
|
release(previous, now, false)
|
|
@@ -137,6 +144,8 @@ export class ViewAnimation implements Animation {
|
|
|
137
144
|
this.pending++
|
|
138
145
|
}
|
|
139
146
|
|
|
147
|
+
this.haptics = playHaptics(this.view, tracks, this.haptic, driver, now)
|
|
148
|
+
|
|
140
149
|
for (const entry of this.entries) {
|
|
141
150
|
entry.handle = driver.play(this.view, entry.track, now, () => {
|
|
142
151
|
if (entry.done) return
|
|
@@ -148,7 +157,7 @@ export class ViewAnimation implements Animation {
|
|
|
148
157
|
|
|
149
158
|
const arrival = Math.max(...this.entries.map((entry) => entry.track.arrival))
|
|
150
159
|
if (this.pending === 0) this.settle()
|
|
151
|
-
else if (Number.isFinite(arrival)) {
|
|
160
|
+
else if (Number.isFinite(arrival) && tracks.some((track) => track.end > arrival)) {
|
|
152
161
|
this.cancelArrival = driver.after(arrival, this.arrive)
|
|
153
162
|
}
|
|
154
163
|
}
|
|
@@ -174,6 +183,9 @@ export class ViewAnimation implements Animation {
|
|
|
174
183
|
if (this.settled) return
|
|
175
184
|
this.settled = true
|
|
176
185
|
this.cancelArrival?.()
|
|
186
|
+
if (this.complete) this.haptics?.finish()
|
|
187
|
+
else this.haptics?.cancel()
|
|
188
|
+
this.haptics = undefined
|
|
177
189
|
this.arrive()
|
|
178
190
|
this.resolve(this.complete)
|
|
179
191
|
}
|
|
@@ -208,9 +220,13 @@ function release(entry: Entry, now: number, commit: boolean) {
|
|
|
208
220
|
forget(entry)
|
|
209
221
|
if (commit) {
|
|
210
222
|
const value =
|
|
211
|
-
driver.presented(view, property) ?? entry.track.valueAt(now
|
|
223
|
+
driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
|
|
212
224
|
writeValue(view, property, value)
|
|
213
225
|
}
|
|
214
226
|
driver.stop(view, entry.handle)
|
|
215
227
|
entry.owner.ended(false)
|
|
216
228
|
}
|
|
229
|
+
|
|
230
|
+
function elapsedOf(entry: Entry, now: number) {
|
|
231
|
+
return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
|
|
232
|
+
}
|
package/src/haptics.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import type { Driver } from './driver/index.js'
|
|
3
|
+
import type { Track } from './track.js'
|
|
4
|
+
|
|
5
|
+
export type HapticStyle = 'light' | 'medium' | 'heavy'
|
|
6
|
+
|
|
7
|
+
export interface HapticOptions {
|
|
8
|
+
style: HapticStyle
|
|
9
|
+
/** Points from 0 to 1 over the finite animation, excluding its initial delay. Defaults to arrival. */
|
|
10
|
+
times?: readonly number[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Haptic = HapticStyle | HapticOptions | false
|
|
14
|
+
|
|
15
|
+
/** Validate before any running animation is interrupted. */
|
|
16
|
+
export function resolveHaptic(haptic: Haptic | undefined): HapticOptions | undefined {
|
|
17
|
+
if (haptic === undefined || haptic === false) return undefined
|
|
18
|
+
const options = typeof haptic === 'string' ? { style: haptic } : haptic
|
|
19
|
+
if (!options || !['light', 'medium', 'heavy'].includes(options.style))
|
|
20
|
+
throw new TypeError('Haptic style must be light, medium or heavy.')
|
|
21
|
+
const times = options.times
|
|
22
|
+
if (
|
|
23
|
+
times !== undefined &&
|
|
24
|
+
(!Array.isArray(times) ||
|
|
25
|
+
times.length === 0 ||
|
|
26
|
+
Array.from(times).some(
|
|
27
|
+
(time, i) =>
|
|
28
|
+
!Number.isFinite(time) ||
|
|
29
|
+
time <= 0 ||
|
|
30
|
+
time > 1 ||
|
|
31
|
+
(i > 0 && time <= times[i - 1]!),
|
|
32
|
+
))
|
|
33
|
+
)
|
|
34
|
+
throw new RangeError(
|
|
35
|
+
'Haptic times must be increasing points greater than 0 and at most 1.',
|
|
36
|
+
)
|
|
37
|
+
return { style: options.style, times: times && [...times] }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PREPARE_AHEAD = 100
|
|
41
|
+
const MAX_LATENESS = 100
|
|
42
|
+
const COMPLETION_TOLERANCE = 20
|
|
43
|
+
|
|
44
|
+
/** Discrete feedback only: animation playback remains entirely native. */
|
|
45
|
+
export function playHaptics(
|
|
46
|
+
view: View,
|
|
47
|
+
tracks: readonly Track[],
|
|
48
|
+
options: HapticOptions | undefined,
|
|
49
|
+
driver: Driver,
|
|
50
|
+
startedAt: number,
|
|
51
|
+
) {
|
|
52
|
+
if (!isIOS || !options || typeof UIImpactFeedbackGenerator === 'undefined') return
|
|
53
|
+
const moving = tracks.filter(
|
|
54
|
+
(track) =>
|
|
55
|
+
track.duration > 0 && track.values.some((value) => value !== track.values[0]),
|
|
56
|
+
)
|
|
57
|
+
if (!moving.length) return
|
|
58
|
+
const end = Math.max(...tracks.map((track) => track.end))
|
|
59
|
+
if (!Number.isFinite(end)) return
|
|
60
|
+
const delay = Math.min(...moving.map((track) => track.delay))
|
|
61
|
+
const offsets = options.times
|
|
62
|
+
? options.times.map((time) => delay + (end - delay) * time)
|
|
63
|
+
: [Math.max(...tracks.map((track) => track.arrival))]
|
|
64
|
+
const styles = {
|
|
65
|
+
light: 0,
|
|
66
|
+
medium: 1,
|
|
67
|
+
heavy: 2,
|
|
68
|
+
} satisfies Record<HapticStyle, UIImpactFeedbackStyle>
|
|
69
|
+
let generator: UIImpactFeedbackGenerator | undefined =
|
|
70
|
+
UIImpactFeedbackGenerator.alloc().initWithStyle(styles[options.style])
|
|
71
|
+
const native = view.nativeViewProtected as UIView
|
|
72
|
+
let next = 0
|
|
73
|
+
let cancelPrepare: (() => void) | undefined
|
|
74
|
+
let cancelImpact: (() => void) | undefined
|
|
75
|
+
|
|
76
|
+
const active = () =>
|
|
77
|
+
view.isLoaded &&
|
|
78
|
+
view.nativeViewProtected === native &&
|
|
79
|
+
!!native.window &&
|
|
80
|
+
UIApplication.sharedApplication.applicationState === 0 // UIApplicationState.Active
|
|
81
|
+
|
|
82
|
+
const cancel = () => {
|
|
83
|
+
cancelPrepare?.()
|
|
84
|
+
cancelImpact?.()
|
|
85
|
+
cancelPrepare = cancelImpact = undefined
|
|
86
|
+
generator = undefined
|
|
87
|
+
view.off('unloaded', cancel)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const fire = (tolerance = 0) => {
|
|
91
|
+
const elapsed = driver.now() - startedAt
|
|
92
|
+
// A busy UI thread can delay timers. Drop stale impacts instead of replaying
|
|
93
|
+
// several old taps at once when it becomes responsive again.
|
|
94
|
+
while (next < offsets.length && offsets[next]! <= elapsed + tolerance) {
|
|
95
|
+
const offset = offsets[next++]!
|
|
96
|
+
if (active() && elapsed - offset <= MAX_LATENESS) generator?.impactOccurred()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const schedule = () => {
|
|
101
|
+
if (!generator || next === offsets.length) return cancel()
|
|
102
|
+
const remaining = startedAt + offsets[next]! - driver.now()
|
|
103
|
+
if (active()) generator.prepare()
|
|
104
|
+
if (remaining > PREPARE_AHEAD) {
|
|
105
|
+
cancelPrepare = driver.after(remaining - PREPARE_AHEAD, () => {
|
|
106
|
+
if (active()) generator?.prepare()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
cancelImpact = driver.after(Math.max(0, remaining), () => {
|
|
110
|
+
fire()
|
|
111
|
+
schedule()
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
view.on('unloaded', cancel)
|
|
116
|
+
schedule()
|
|
117
|
+
return {
|
|
118
|
+
cancel,
|
|
119
|
+
finish() {
|
|
120
|
+
// Native completion may beat the timer at the final keyframe by a frame.
|
|
121
|
+
fire(COMPLETION_TOLERANCE)
|
|
122
|
+
cancel()
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
export { animate, stop } from './animate.js'
|
|
1
|
+
export { animate, decay, stop } from './animate.js'
|
|
2
2
|
export { sequence } from './sequence.js'
|
|
3
3
|
export type { Step } from './sequence.js'
|
|
4
4
|
export { press } from './press.js'
|
|
5
|
+
export type { Haptic, HapticOptions, HapticStyle } from './haptics.js'
|
|
6
|
+
export { confetti, particles } from './particles.js'
|
|
7
|
+
export type { ConfettiOptions, ParticleOptions } from './particles.js'
|
|
8
|
+
export { shimmer } from './shimmer.js'
|
|
9
|
+
export type { ShimmerOptions } from './shimmer.js'
|
|
10
|
+
export { loader } from './loader.js'
|
|
11
|
+
export type { LoaderOptions, LoaderStyle } from './loader.js'
|
|
12
|
+
export { reveal, conceal } from './reveal.js'
|
|
13
|
+
export type { RevealOptions, ConcealOptions } from './reveal.js'
|
|
14
|
+
export { animateText } from './text.js'
|
|
15
|
+
export type { TextOptions } from './text.js'
|
|
16
|
+
export { symbolEffect } from './symbol-effect.js'
|
|
17
|
+
export type {
|
|
18
|
+
SymbolEffect,
|
|
19
|
+
SymbolEffectName,
|
|
20
|
+
SymbolEffectOptions,
|
|
21
|
+
} from './symbol-effect.js'
|
|
5
22
|
export { definePreset, presets } from './presets.js'
|
|
6
23
|
export type { Preset, PresetName, Presets } from './presets.js'
|
|
7
24
|
export { springs } from './transition.js'
|