@rafaelaugusto/jolt 0.1.0-beta.2 → 0.1.0-beta.4

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 (105) hide show
  1. package/README.md +112 -16
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +89 -3
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/decay.d.ts +11 -0
  11. package/dist/decay.d.ts.map +1 -0
  12. package/dist/decay.js +22 -0
  13. package/dist/decay.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +17 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +134 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android.d.ts +1 -1
  19. package/dist/driver/android.d.ts.map +1 -1
  20. package/dist/driver/android.js +101 -5
  21. package/dist/driver/android.js.map +1 -1
  22. package/dist/driver/index.d.ts +4 -2
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-color.d.ts +14 -0
  26. package/dist/driver/ios-color.d.ts.map +1 -0
  27. package/dist/driver/ios-color.js +381 -0
  28. package/dist/driver/ios-color.js.map +1 -0
  29. package/dist/driver/ios.d.ts.map +1 -1
  30. package/dist/driver/ios.js +9 -1
  31. package/dist/driver/ios.js.map +1 -1
  32. package/dist/engine.d.ts +1 -1
  33. package/dist/engine.d.ts.map +1 -1
  34. package/dist/engine.js +15 -8
  35. package/dist/engine.js.map +1 -1
  36. package/dist/index.d.ts +10 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/liquid-grid.d.ts +3 -0
  41. package/dist/liquid-grid.d.ts.map +1 -0
  42. package/dist/liquid-grid.js +24 -0
  43. package/dist/liquid-grid.js.map +1 -0
  44. package/dist/liquid.d.ts +18 -0
  45. package/dist/liquid.d.ts.map +1 -0
  46. package/dist/liquid.js +229 -0
  47. package/dist/liquid.js.map +1 -0
  48. package/dist/loader.d.ts +19 -0
  49. package/dist/loader.d.ts.map +1 -0
  50. package/dist/loader.js +172 -0
  51. package/dist/loader.js.map +1 -0
  52. package/dist/press.d.ts.map +1 -1
  53. package/dist/press.js +6 -1
  54. package/dist/press.js.map +1 -1
  55. package/dist/properties.d.ts +5 -3
  56. package/dist/properties.d.ts.map +1 -1
  57. package/dist/properties.js +13 -2
  58. package/dist/properties.js.map +1 -1
  59. package/dist/reveal.d.ts +25 -0
  60. package/dist/reveal.d.ts.map +1 -0
  61. package/dist/reveal.js +293 -0
  62. package/dist/reveal.js.map +1 -0
  63. package/dist/text-layout.d.ts +22 -0
  64. package/dist/text-layout.d.ts.map +1 -0
  65. package/dist/text-layout.js +179 -0
  66. package/dist/text-layout.js.map +1 -0
  67. package/dist/text.d.ts +17 -0
  68. package/dist/text.d.ts.map +1 -0
  69. package/dist/text.js +267 -0
  70. package/dist/text.js.map +1 -0
  71. package/dist/track.d.ts +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +63 -5
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts +5 -0
  76. package/dist/transition.d.ts.map +1 -1
  77. package/dist/transition.js +8 -2
  78. package/dist/transition.js.map +1 -1
  79. package/dist/types.d.ts +19 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +11 -2
  82. package/platforms/android/include.gradle +7 -0
  83. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  84. package/platforms/android/proguard-rules.pro +6 -0
  85. package/src/animate.ts +105 -5
  86. package/src/color.ts +101 -0
  87. package/src/decay.ts +45 -0
  88. package/src/driver/android-color.ts +168 -0
  89. package/src/driver/android.ts +120 -10
  90. package/src/driver/index.ts +9 -2
  91. package/src/driver/ios-color.ts +432 -0
  92. package/src/driver/ios.ts +13 -3
  93. package/src/engine.ts +17 -9
  94. package/src/index.ts +10 -1
  95. package/src/liquid-grid.ts +28 -0
  96. package/src/liquid.ts +282 -0
  97. package/src/loader.ts +201 -0
  98. package/src/press.ts +8 -2
  99. package/src/properties.ts +16 -6
  100. package/src/reveal.ts +374 -0
  101. package/src/text-layout.ts +216 -0
  102. package/src/text.ts +298 -0
  103. package/src/track.ts +105 -5
  104. package/src/transition.ts +16 -2
  105. package/src/types.ts +18 -0
package/src/text.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { stopIOSColorText } from './driver/ios-color.js'
2
+ import { Application, Label, isIOS } from '@nativescript/core'
3
+ import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
4
+ import { toView } from './animate.js'
5
+ import type { Animation } from './engine.js'
6
+ import { textLayer } from './text-layout.js'
7
+ import type { Target } from './types.js'
8
+
9
+ export interface TextOptions {
10
+ /** Composed letters (including emoji), or words. Defaults to 'letter'. */
11
+ by?: 'letter' | 'word'
12
+ /** Milliseconds per piece. Defaults to 400. */
13
+ duration?: number
14
+ /** Milliseconds between pieces. Defaults to 35. */
15
+ stagger?: number
16
+ /** Milliseconds before the first piece. Defaults to zero. */
17
+ delay?: number
18
+ /** Initial vertical offset in DIPs. Defaults to 12. */
19
+ y?: number
20
+ }
21
+ type Options = Required<TextOptions>
22
+ const running = new WeakMap<Label, TextAnimation>()
23
+ const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
24
+ let Delegate: typeof NSObject | undefined
25
+ const styleEvents = [
26
+ 'fontInternalChange',
27
+ 'colorChange',
28
+ 'textAlignmentChange',
29
+ 'textDecorationChange',
30
+ 'textTransformChange',
31
+ 'letterSpacingChange',
32
+ 'lineHeightChange',
33
+ 'textShadowChange',
34
+ 'textStrokeChange',
35
+ 'whiteSpaceChange',
36
+ 'textOverflowChange',
37
+ 'maxLinesChange',
38
+ 'paddingLeftChange',
39
+ 'paddingRightChange',
40
+ 'paddingTopChange',
41
+ 'paddingBottomChange',
42
+ 'visibilityChange',
43
+ 'directionChange',
44
+ 'borderLeftWidthChange',
45
+ 'borderRightWidthChange',
46
+ 'borderTopWidthChange',
47
+ 'borderBottomWidthChange',
48
+ ]
49
+
50
+ /** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
51
+ export function animateText(target: Target, options: TextOptions = {}): Animation {
52
+ const config: Options = {
53
+ by: options.by ?? 'letter',
54
+ duration: options.duration ?? 400,
55
+ stagger: options.stagger ?? 35,
56
+ delay: options.delay ?? 0,
57
+ y: options.y ?? 12,
58
+ }
59
+ if (config.by !== 'letter' && config.by !== 'word')
60
+ throw new TypeError('Text grouping must be letter or word.')
61
+ for (const key of ['duration', 'stagger', 'delay'] as const) {
62
+ if (!Number.isFinite(config[key]) || config[key] < 0)
63
+ throw new RangeError(`Text ${key} must be a nonnegative finite number.`)
64
+ }
65
+ if (!Number.isFinite(config.y)) throw new RangeError('Text y must be finite.')
66
+ const view = toView(target)
67
+ if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
68
+ running.get(view)?.stop()
69
+ return new TextAnimation(view, config)
70
+ }
71
+
72
+ function completion(
73
+ callback: (finished: boolean) => void,
74
+ ): NSObject & CAAnimationDelegate {
75
+ if (!Delegate) {
76
+ const base = NSObject as typeof NSObject & {
77
+ extend(
78
+ methods: object,
79
+ options: { protocols: unknown[]; name: string },
80
+ ): typeof NSObject
81
+ }
82
+ Delegate = base.extend(
83
+ {
84
+ animationDidStopFinished(
85
+ this: NSObject,
86
+ _animation: CAAnimation,
87
+ finished: boolean,
88
+ ) {
89
+ callbacks.get(this)?.(finished)
90
+ },
91
+ },
92
+ { protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
93
+ )
94
+ }
95
+ const delegate = Delegate.new()
96
+ callbacks.set(delegate, callback)
97
+ return delegate
98
+ }
99
+
100
+ class TextAnimation implements Animation {
101
+ readonly finished: Promise<boolean>
102
+ private resolve!: (finished: boolean) => void
103
+ private readonly formatted: Label['formattedText']
104
+ private settled = false
105
+ private queued = false
106
+ private native: UILabel | undefined
107
+ private bounds: CGRect | undefined
108
+ private layer: CALayer | undefined
109
+ private originalText: string | null = null
110
+ private originalAttributed: NSAttributedString | null = null
111
+ private accessibility: string | null = null
112
+ private temporaryAccessibility: string | undefined
113
+ private delegate: NSObject | undefined
114
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
115
+
116
+ constructor(
117
+ private readonly view: Label,
118
+ private readonly options: Options,
119
+ ) {
120
+ this.finished = new Promise((resolve) => {
121
+ this.resolve = resolve
122
+ })
123
+ if (isIOS) stopIOSColorText(view)
124
+ this.formatted = view.formattedText
125
+ running.set(view, this)
126
+ if (!isIOS) {
127
+ this.end(false)
128
+ return
129
+ }
130
+ if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
131
+ this.end(true)
132
+ return
133
+ }
134
+ view.on('loaded', this.schedule)
135
+ view.on('layoutChanged', this.layoutChanged)
136
+ view.on('unloaded', this.stop)
137
+ view.on('textChange', this.changed)
138
+ view.on('formattedTextChange', this.changed)
139
+ this.formatted?.on('propertyChange', this.changed)
140
+ for (const event of styleEvents) view.style.on(event, this.changed)
141
+ this.schedule()
142
+ }
143
+
144
+ then<A = boolean, B = never>(
145
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
146
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
147
+ ): Promise<A | B> {
148
+ return this.finished.then(onfulfilled, onrejected)
149
+ }
150
+ /** Cancels the entrance and restores the full text. */
151
+ stop = () => this.end(false)
152
+ private changed = () => this.end(false, true)
153
+ private motionChanged = () => {
154
+ if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
155
+ }
156
+ private layoutChanged = () => {
157
+ const native = this.view.nativeViewProtected as UILabel | undefined
158
+ const a = this.bounds
159
+ const b = native?.bounds
160
+ if (
161
+ this.layer &&
162
+ (native !== this.native ||
163
+ !native?.window ||
164
+ !b ||
165
+ !a ||
166
+ a.origin.x !== b.origin.x ||
167
+ a.origin.y !== b.origin.y ||
168
+ a.size.width !== b.size.width ||
169
+ a.size.height !== b.size.height)
170
+ )
171
+ return this.stop()
172
+ this.schedule()
173
+ }
174
+ private schedule = () => {
175
+ if (this.settled || this.queued) return
176
+ this.queued = true
177
+ void Promise.resolve().then(() => {
178
+ this.queued = false
179
+ if (this.settled || this.layer || !this.view.isLoaded) return
180
+ try {
181
+ this.start()
182
+ } catch {
183
+ this.end(false)
184
+ }
185
+ })
186
+ }
187
+ private start() {
188
+ if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
189
+ return this.end(true)
190
+ if (this.view.visibility !== 'visible') return this.end(false)
191
+ const native = this.view.nativeViewProtected as UILabel | undefined
192
+ if (!(native instanceof UILabel) || !native.window) return
193
+ if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
194
+ const prepared = textLayer(native, this.options.by)
195
+ if (!prepared) return this.end(false)
196
+ if (!prepared.layer?.sublayers?.count) return this.end(true)
197
+ if (
198
+ !Number.isFinite(
199
+ this.options.delay +
200
+ this.options.duration +
201
+ (prepared.layer.sublayers.count - 1) * this.options.stagger,
202
+ )
203
+ )
204
+ return this.end(false)
205
+ this.native = native
206
+ this.bounds = native.bounds
207
+ this.layer = prepared.layer
208
+ this.originalText = native.text
209
+ this.originalAttributed = native.attributedText
210
+ this.accessibility = native.accessibilityLabel
211
+ this.temporaryAccessibility = this.accessibility ?? prepared.text
212
+ const { duration, delay, stagger, y } = this.options
213
+ const start = CACurrentMediaTime()
214
+ CATransaction.begin()
215
+ CATransaction.setDisableActions(true)
216
+ try {
217
+ native.text = ''
218
+ native.accessibilityLabel = this.temporaryAccessibility
219
+ native.layer.addSublayer(this.layer)
220
+ const pieces = this.layer.sublayers!
221
+ for (let i = 0; i < pieces.count; i++) {
222
+ const piece = pieces.objectAtIndex(i)
223
+ const fade = CABasicAnimation.animationWithKeyPath('opacity')
224
+ fade.fromValue = 0
225
+ fade.toValue = 1
226
+ const move = CABasicAnimation.animationWithKeyPath('transform.translation.y')
227
+ move.fromValue = y
228
+ move.toValue = 0
229
+ fade.duration = move.duration = duration / 1000
230
+ const group = CAAnimationGroup.new()
231
+ group.animations = NSArray.arrayWithArray([fade, move])
232
+ group.duration = duration / 1000
233
+ group.beginTime = start + (delay + i * stagger) / 1000
234
+ group.fillMode = kCAFillModeBackwards
235
+ group.timingFunction = CAMediaTimingFunction.functionWithName(
236
+ kCAMediaTimingFunctionEaseOut,
237
+ )
238
+ if (i === pieces.count - 1)
239
+ this.delegate = group.delegate = completion((finished) => this.end(finished))
240
+ piece.addAnimationForKey(group, 'jolt:text')
241
+ }
242
+ this.observer = Application.ios.addNotificationObserver(
243
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
244
+ this.motionChanged,
245
+ )
246
+ } finally {
247
+ CATransaction.commit()
248
+ }
249
+ }
250
+ private end(finished: boolean, refresh = false) {
251
+ if (this.settled) return
252
+ this.settled = true
253
+ const { view, native, layer } = this
254
+ view.off('loaded', this.schedule)
255
+ view.off('layoutChanged', this.layoutChanged)
256
+ view.off('unloaded', this.stop)
257
+ view.off('textChange', this.changed)
258
+ view.off('formattedTextChange', this.changed)
259
+ this.formatted?.off('propertyChange', this.changed)
260
+ for (const event of styleEvents) view.style.off(event, this.changed)
261
+ if (running.get(view) === this) running.delete(view)
262
+ if (this.observer)
263
+ Application.ios.removeNotificationObserver(
264
+ this.observer,
265
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
266
+ )
267
+ if (this.delegate) callbacks.delete(this.delegate)
268
+ if (native && layer) {
269
+ CATransaction.begin()
270
+ CATransaction.setDisableActions(true)
271
+ try {
272
+ // Restore only the native text we suppressed; app changes win.
273
+ if (!native.text && !native.attributedText?.length) {
274
+ if (refresh && view.nativeViewProtected === native) {
275
+ const property = view.formattedText ? formattedTextProperty : textProperty
276
+ ;(view as unknown as Record<symbol, (value: unknown) => void>)[
277
+ property.setNative
278
+ ]!(view.formattedText ?? view.text)
279
+ } else if (this.originalAttributed)
280
+ native.attributedText = this.originalAttributed
281
+ else native.text = this.originalText
282
+ }
283
+ if (native.accessibilityLabel === this.temporaryAccessibility)
284
+ native.accessibilityLabel = this.accessibility
285
+ layer.removeFromSuperlayer()
286
+ const pieces = layer.sublayers
287
+ if (pieces)
288
+ for (let i = 0; i < pieces.count; i++)
289
+ pieces.objectAtIndex(i).removeAnimationForKey('jolt:text')
290
+ } finally {
291
+ CATransaction.commit()
292
+ }
293
+ }
294
+ this.native = this.layer = this.delegate = this.observer = undefined
295
+ this.originalAttributed = null
296
+ this.resolve(finished)
297
+ }
298
+ }
package/src/track.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { linear } from './easing.js'
2
+ import { solveDecay } from './decay.js'
2
3
  import { precision, type Property } from './properties.js'
3
- import { solveSpring } from './spring.js'
4
+ import { solveSpring, springPhysics } from './spring.js'
4
5
  import type { Curve, ResolvedTransition } from './transition.js'
6
+ import { colorMixer, isColorProperty, type ColorProperty } from './color.js'
5
7
 
6
8
  /**
7
9
  * One property animated over time. The curve is computed here and sampled,
@@ -37,18 +39,23 @@ interface Motion {
37
39
  }
38
40
 
39
41
  const SAMPLES_PER_SECOND = 60
42
+ const MAX_DECAY_SAMPLES = 6000
40
43
  const ARRIVAL_TOLERANCE = 0.05
41
44
 
42
45
  export function createTrack(
43
46
  property: Property,
44
47
  keyframes: readonly number[],
45
- { curve, delay, repeat, reverse }: ResolvedTransition,
48
+ transition: ResolvedTransition,
46
49
  velocity: number,
47
50
  ): Track {
51
+ if (isColorProperty(property)) return colorTrack(property, keyframes, transition)
52
+ const { curve, delay, repeat, reverse } = transition
48
53
  const motion =
49
54
  curve.type === 'spring'
50
55
  ? spring(property, keyframes, curve, velocity)
51
- : tween(keyframes, curve)
56
+ : curve.type === 'decay'
57
+ ? decay(property, keyframes[0]!, curve)
58
+ : tween(keyframes, curve)
52
59
  const { duration } = motion
53
60
  const first = motion.values[0]!
54
61
  const last = motion.values.at(-1)!
@@ -75,7 +82,11 @@ export function createTrack(
75
82
  values: motion.values,
76
83
  to: !finite || endsBackwards ? first : last,
77
84
  end,
78
- arrival: curve.type === 'spring' && repeat === 0 ? delay + arrival(motion) : end,
85
+ arrival:
86
+ (curve.type === 'spring' || (curve.type === 'decay' && curve.snap !== undefined)) &&
87
+ repeat === 0
88
+ ? delay + arrival(motion)
89
+ : end,
79
90
  valueAt: (elapsed) => motion.position(localTime(elapsed)[0]),
80
91
  velocityAt: (elapsed) => {
81
92
  if (elapsed <= delay || elapsed >= end) return 0
@@ -85,6 +96,89 @@ export function createTrack(
85
96
  }
86
97
  }
87
98
 
99
+ function colorTrack(
100
+ property: ColorProperty,
101
+ colors: readonly number[],
102
+ transition: ResolvedTransition,
103
+ ): Track {
104
+ const progress = createTrack(
105
+ 'opacity',
106
+ colors.map((_, i) => i),
107
+ transition,
108
+ 0,
109
+ )
110
+ const mixes = colors.slice(1).map((color, i) => colorMixer(colors[i]!, color))
111
+ const colorAt = (position: number) => {
112
+ const p = Math.max(0, Math.min(colors.length - 1, position))
113
+ const segment = Math.min(mixes.length - 1, Math.floor(p))
114
+ return mixes[segment]!(p - segment)
115
+ }
116
+ // Linear timing still needs intermediate colors: native RGB interpolation is
117
+ // only the short bridge between these perceptually spaced samples.
118
+ const steps = Math.max(
119
+ 1,
120
+ Math.min(
121
+ MAX_DECAY_SAMPLES,
122
+ Math.ceil((progress.duration / 1000) * SAMPLES_PER_SECOND),
123
+ ),
124
+ )
125
+ const times = [
126
+ ...new Set([
127
+ ...progress.times,
128
+ ...Array.from({ length: steps + 1 }, (_, i) => i / steps),
129
+ ]),
130
+ ].sort((a, b) => a - b)
131
+ return {
132
+ ...progress,
133
+ property,
134
+ times,
135
+ values: times.map((time) =>
136
+ colorAt(
137
+ time === 0
138
+ ? progress.values[0]!
139
+ : time === 1
140
+ ? progress.values.at(-1)!
141
+ : progress.valueAt(progress.delay + time * progress.duration),
142
+ ),
143
+ ),
144
+ to: colorAt(progress.to),
145
+ valueAt: (elapsed) => colorAt(progress.valueAt(elapsed)),
146
+ // Packed colors have no scalar velocity to carry into another spring.
147
+ velocityAt: () => 0,
148
+ }
149
+ }
150
+
151
+ function decay(
152
+ property: Property,
153
+ from: number,
154
+ curve: Extract<Curve, { type: 'decay' }>,
155
+ ): Motion {
156
+ const motion = solveDecay(from, curve.velocity, curve.friction, precision[property])
157
+ if (curve.snap) {
158
+ const to = curve.snap.reduce((closest, stop) =>
159
+ Math.abs(stop - motion.to) < Math.abs(closest - motion.to) ? stop : closest,
160
+ )
161
+ return spring(
162
+ property,
163
+ [from, to],
164
+ { type: 'spring', physics: springPhysics({}), velocity: curve.velocity },
165
+ 0,
166
+ )
167
+ }
168
+
169
+ const steps = Math.max(
170
+ 1,
171
+ Math.min(MAX_DECAY_SAMPLES, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND)),
172
+ )
173
+ const times: number[] = []
174
+ const values: number[] = []
175
+ for (let i = 0; i <= steps; i++) {
176
+ times.push(i / steps)
177
+ values.push(motion.position((i / steps) * motion.duration))
178
+ }
179
+ return { ...motion, times, values }
180
+ }
181
+
88
182
  function arrival({ times, values, duration }: Motion) {
89
183
  const target = values.at(-1)!
90
184
  const tolerance =
@@ -146,7 +240,13 @@ function tween(
146
240
  const steps =
147
241
  easing === linear || from === to
148
242
  ? 1
149
- : Math.max(1, Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND))
243
+ : Math.max(
244
+ 1,
245
+ Math.min(
246
+ MAX_DECAY_SAMPLES,
247
+ Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND),
248
+ ),
249
+ )
150
250
  for (let step = 1; step <= steps; step++) {
151
251
  times.push(start + (span * step) / steps)
152
252
  values.push(step === steps ? to : from + (to - from) * easing(step / steps))
package/src/transition.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveEasing, type Easing, type EasingFunction } from './easing.js'
2
2
  import type { Property } from './properties.js'
3
+ import { isColorProperty } from './color.js'
3
4
  import { springPhysics, type SpringPhysics } from './spring.js'
4
5
 
5
6
  export interface Timing {
@@ -52,6 +53,12 @@ export type Curve =
52
53
  times: readonly number[] | undefined
53
54
  }
54
55
  | { type: 'spring'; physics: SpringPhysics; velocity: number | undefined }
56
+ | {
57
+ type: 'decay'
58
+ friction: number
59
+ velocity: number
60
+ snap: readonly number[] | undefined
61
+ }
55
62
 
56
63
  export interface ResolvedTransition {
57
64
  curve: Curve
@@ -96,7 +103,10 @@ export function resolveTransition(
96
103
  transition.type ??
97
104
  (springKeys.some((key) => (transition as Spring)[key] !== undefined)
98
105
  ? 'spring'
99
- : hasCurve(transition) || property === 'opacity' || keyframes > 2
106
+ : hasCurve(transition) ||
107
+ property === 'opacity' ||
108
+ isColorProperty(property) ||
109
+ keyframes > 2
100
110
  ? 'tween'
101
111
  : 'spring')
102
112
 
@@ -122,7 +132,11 @@ export function resolveTransition(
122
132
 
123
133
  const tween = transition as Tween
124
134
  const duration = tween.duration ?? DEFAULT_DURATION
125
- if (duration < 0 || Number.isNaN(duration)) {
135
+ if (
136
+ duration < 0 ||
137
+ Number.isNaN(duration) ||
138
+ (isColorProperty(property) && !Number.isFinite(duration))
139
+ ) {
126
140
  throw new RangeError(`Invalid duration: ${duration}.`)
127
141
  }
128
142
 
package/src/types.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import type { View } from '@nativescript/core'
2
2
  import type { Haptic } from './haptics.js'
3
3
  import type { SpringName, Transition } from './transition.js'
4
+ import type { ColorKeyframes } from './color.js'
4
5
 
5
6
  /** A value to animate to, or keyframes. `null` stands for the value the view has when the animation starts. */
6
7
  export type KeyframeValue = number | readonly (number | null)[]
7
8
 
8
9
  export interface Keyframes {
10
+ backgroundColor?: ColorKeyframes
11
+ /** Uniform starting edge colors; widths and radii may differ. */
12
+ borderColor?: ColorKeyframes
13
+ color?: ColorKeyframes
9
14
  opacity?: KeyframeValue
10
15
  /** Same as `translateX`, in DIPs. */
11
16
  x?: KeyframeValue
@@ -25,6 +30,19 @@ export interface Keyframes {
25
30
  export type Target =
26
31
  View | { readonly nativeView: View } | { readonly nativeElement: View }
27
32
 
33
+ /** Translation velocities in DIPs per second. Omitted axes keep their current motion. */
34
+ export interface DecayVelocity {
35
+ x?: number
36
+ y?: number
37
+ }
38
+
39
+ export interface DecayOptions {
40
+ /** Velocity multiplier per millisecond, between 0 and 1 exclusively. Defaults to 0.998. */
41
+ friction?: number
42
+ /** Spring to the closest stop to the projected landing, shared or set per axis. */
43
+ snap?: readonly number[] | { x?: readonly number[]; y?: readonly number[] }
44
+ }
45
+
28
46
  export interface Stagger {
29
47
  /** Milliseconds between one target and the next. */
30
48
  each: number