@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
@@ -0,0 +1,102 @@
1
+ package org.rafaelaugusto.jolt;
2
+
3
+ import android.graphics.PorterDuff;
4
+ import android.graphics.drawable.ColorDrawable;
5
+ import android.graphics.drawable.Drawable;
6
+ import android.view.View;
7
+ import android.widget.TextView;
8
+ import java.lang.ref.WeakReference;
9
+ import java.lang.reflect.Field;
10
+ import java.lang.reflect.Method;
11
+
12
+ /** ObjectAnimator target. All frame updates stay in native code. */
13
+ public final class ColorTarget {
14
+ private static final String BORDER = "org.nativescript.widgets.BorderDrawable";
15
+ private static final Field[] FIELDS = fields();
16
+ private static final String SHADOW = "org.nativescript.widgets.BoxShadowDrawable";
17
+ private static final Method UNWRAP = unwrapMethod();
18
+ private final WeakReference<View> view;
19
+ private final int property;
20
+ private int color;
21
+
22
+ // 0 = background, 1 = uniform border, 2 = text.
23
+ public ColorTarget(View view, int property, int initialColor) {
24
+ this.view = new WeakReference<>(view);
25
+ this.property = property;
26
+ this.color = initialColor;
27
+ }
28
+
29
+ private static Field[] fields() {
30
+ try {
31
+ Class<?> type = Class.forName(BORDER);
32
+ String[] names = {"backgroundColor", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"};
33
+ Field[] result = new Field[names.length];
34
+ for (int i = 0; i < names.length; i++) {
35
+ result[i] = type.getDeclaredField(names[i]);
36
+ result[i].setAccessible(true);
37
+ }
38
+ return result;
39
+ } catch (ReflectiveOperationException | SecurityException exception) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ private static Method unwrapMethod() {
45
+ try {
46
+ return Class.forName(SHADOW).getMethod("getWrappedDrawable");
47
+ } catch (ReflectiveOperationException exception) {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ private Drawable background(View nativeView) {
53
+ Drawable drawable = nativeView.getBackground();
54
+ // Avoid reflective method lookups and exceptions in the frame loop.
55
+ if (drawable != null && drawable.getClass().getName().equals(SHADOW) && UNWRAP != null) {
56
+ try {
57
+ return (Drawable) UNWRAP.invoke(drawable);
58
+ } catch (ReflectiveOperationException exception) {
59
+ return drawable;
60
+ }
61
+ }
62
+ return drawable;
63
+ }
64
+
65
+ public boolean isSupported() {
66
+ View nativeView = view.get();
67
+ if (nativeView == null) return false;
68
+ if (property == 2) return nativeView instanceof TextView;
69
+ Drawable drawable = background(nativeView);
70
+ if (drawable == null) return true;
71
+ if (drawable.getClass().getName().equals(SHADOW)) return false;
72
+ return !drawable.getClass().getName().equals(BORDER) || FIELDS != null;
73
+ }
74
+
75
+ public int getColor() { return color; }
76
+
77
+ public void setColor(int value) {
78
+ View nativeView = view.get();
79
+ if (nativeView == null) return;
80
+ color = value;
81
+ if (property == 2) {
82
+ ((TextView) nativeView).setTextColor(value);
83
+ return;
84
+ }
85
+ // Resolve on every frame: a Core style update may replace the drawable.
86
+ Drawable drawable = background(nativeView);
87
+ if (drawable == null || drawable.getClass().getName().equals(SHADOW)) return;
88
+ if (drawable.getClass().getName().equals(BORDER)) {
89
+ if (FIELDS == null) return;
90
+ try {
91
+ if (property == 0) FIELDS[0].setInt(drawable, value);
92
+ else for (int i = 1; i < FIELDS.length; i++) FIELDS[i].setInt(drawable, value);
93
+ drawable.invalidateSelf();
94
+ } catch (IllegalAccessException exception) {
95
+ // Unsupported Core versions keep the final model color.
96
+ }
97
+ } else if (property == 0) {
98
+ if (drawable instanceof ColorDrawable) ((ColorDrawable) drawable).setColor(value);
99
+ else drawable.setColorFilter(value, PorterDuff.Mode.SRC_IN);
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,6 @@
1
+ -keep class org.rafaelaugusto.jolt.ColorTarget { *; }
2
+ -keep class org.nativescript.widgets.BorderDrawable { *; }
3
+ -keepnames class org.nativescript.widgets.BoxShadowDrawable
4
+ -keepclassmembers class org.nativescript.widgets.BoxShadowDrawable {
5
+ public android.graphics.drawable.Drawable getWrappedDrawable();
6
+ }
package/src/animate.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { View } from '@nativescript/core'
2
+ import {
3
+ isColorProperty,
4
+ parseColor,
5
+ validateColorTarget,
6
+ type ColorKeyframes,
7
+ } from './color.js'
8
+ import { DEFAULT_FRICTION, solveDecay } from './decay.js'
2
9
  import {
3
10
  arrivalOf,
4
11
  setArrival,
@@ -9,7 +16,7 @@ import {
9
16
  } from './engine.js'
10
17
  import { getPreset, type PresetName } from './presets.js'
11
18
  import { resolveHaptic, type Haptic } from './haptics.js'
12
- import { isProperty, type Property } from './properties.js'
19
+ import { isProperty, precision, type Property } from './properties.js'
13
20
  import {
14
21
  hasCurve,
15
22
  mergeTransitions,
@@ -20,6 +27,8 @@ import {
20
27
  } from './transition.js'
21
28
  import type {
22
29
  AnimateOptions,
30
+ DecayOptions,
31
+ DecayVelocity,
23
32
  Keyframes,
24
33
  KeyframeValue,
25
34
  Stagger,
@@ -56,14 +65,20 @@ export function animate(
56
65
  const feedback = resolveHaptic(haptic)
57
66
 
58
67
  if (!isList(target)) {
68
+ const view = toView(target)
69
+ for (const property of Object.keys(keyframes))
70
+ if (isColorProperty(property)) validateColorTarget(view, property)
59
71
  return new ViewAnimation(
60
- toView(target),
72
+ view,
61
73
  plan(keyframes, base, overrides, 0, relative),
62
74
  feedback,
63
75
  )
64
76
  }
65
77
 
66
78
  const views = target.map(toView)
79
+ for (const view of views)
80
+ for (const property of Object.keys(keyframes))
81
+ if (isColorProperty(property)) validateColorTarget(view, property)
67
82
  return group(
68
83
  views.map(
69
84
  (view, index) =>
@@ -82,6 +97,77 @@ export function animate(
82
97
  )
83
98
  }
84
99
 
100
+ /** Starts at the current translation and slows down from a velocity in DIPs per second. */
101
+ export function decay(
102
+ target: Target | readonly Target[],
103
+ velocity: DecayVelocity,
104
+ options: DecayOptions = {},
105
+ ): Animation {
106
+ const friction = options.friction ?? DEFAULT_FRICTION
107
+ if (!Number.isFinite(friction) || friction <= 0 || friction >= 1) {
108
+ throw new RangeError('Decay friction must be between 0 and 1 exclusively.')
109
+ }
110
+
111
+ const stops = (values: readonly number[] | undefined) => {
112
+ if (values === undefined) return undefined
113
+ if (
114
+ !Array.isArray(values) ||
115
+ values.length === 0 ||
116
+ ![...values].every(Number.isFinite)
117
+ ) {
118
+ throw new RangeError('Decay snap needs a nonempty array of finite positions.')
119
+ }
120
+ return [...values]
121
+ }
122
+ const snap = options.snap
123
+ if (
124
+ snap !== undefined &&
125
+ !Array.isArray(snap) &&
126
+ (snap === null ||
127
+ typeof snap !== 'object' ||
128
+ Object.keys(snap).some((key) => key !== 'x' && key !== 'y'))
129
+ ) {
130
+ throw new TypeError('Decay snap must be an array or an object with x and y stops.')
131
+ }
132
+ const shared = Array.isArray(snap)
133
+ const perAxis = snap as { x?: readonly number[]; y?: readonly number[] } | undefined
134
+ const snapped = shared ? stops(snap) : undefined
135
+ const snapX = shared ? snapped : stops(perAxis?.x)
136
+ const snapY = shared ? snapped : stops(perAxis?.y)
137
+
138
+ const plans = Object.entries(velocity).flatMap<Plan>(([property, value]) => {
139
+ if (property !== 'x' && property !== 'y') {
140
+ throw new Error(`Can't decay "${property}". Use x or y.`)
141
+ }
142
+ if (value === undefined) return []
143
+ if (!Number.isFinite(value)) {
144
+ throw new RangeError(`Invalid decay velocity for "${property}".`)
145
+ }
146
+ // Validate every axis before handing any plans to the engine.
147
+ solveDecay(0, value, friction, precision[property])
148
+ return [
149
+ {
150
+ property,
151
+ keyframes: [null, null],
152
+ transition: {
153
+ curve: {
154
+ type: 'decay',
155
+ friction,
156
+ velocity: value,
157
+ snap: property === 'x' ? snapX : snapY,
158
+ },
159
+ delay: 0,
160
+ repeat: 0,
161
+ reverse: false,
162
+ },
163
+ },
164
+ ]
165
+ })
166
+ const views = toViews(target)
167
+ if (plans.length === 0) return group([])
168
+ return group(views.map((view) => new ViewAnimation(view, plans)))
169
+ }
170
+
85
171
  /** Stops all or selected properties at their current values. `scale` stops both axes. */
86
172
  export function stop(
87
173
  target: Target | readonly Target[],
@@ -208,7 +294,10 @@ export function startingValues(
208
294
  const { keyframes, relative } = prepare(animation, options)
209
295
  if (!keyframes) throw new Error(`Unknown preset "${animation}".`)
210
296
  return new Map(
211
- expand(keyframes).map(([property, [first]]) => [property, relative ? null : first!]),
297
+ expand(keyframes).map(([property, [first]]) => [
298
+ property,
299
+ relative && !isColorProperty(property) ? null : first!,
300
+ ]),
212
301
  )
213
302
  }
214
303
 
@@ -216,7 +305,7 @@ function expand(keyframes: Keyframes): [Property, (number | null)[]][] {
216
305
  const expanded: [Property, (number | null)[]][] = []
217
306
  for (const [key, value] of Object.entries(keyframes) as [
218
307
  keyof Keyframes,
219
- KeyframeValue | undefined,
308
+ KeyframeValue | ColorKeyframes | undefined,
220
309
  ][]) {
221
310
  if (value === undefined) continue
222
311
  if (key !== 'scale' && !isProperty(key)) throw new Error(`Can't animate "${key}".`)
@@ -230,7 +319,18 @@ function expand(keyframes: Keyframes): [Property, (number | null)[]][] {
230
319
  return expanded
231
320
  }
232
321
 
233
- function toKeyframes(property: Property, value: KeyframeValue): (number | null)[] {
322
+ function toKeyframes(
323
+ property: Property,
324
+ value: KeyframeValue | ColorKeyframes,
325
+ ): (number | null)[] {
326
+ if (isColorProperty(property)) {
327
+ const frames = Array.isArray(value) ? [...value] : [null, value]
328
+ if (!frames.length) throw new TypeError(`Empty color keyframes for "${property}".`)
329
+ if (frames.length === 1) frames.unshift(null)
330
+ return frames.map((frame) => (frame === null ? null : parseColor(frame)))
331
+ }
332
+ if (typeof value !== 'number' && !Array.isArray(value))
333
+ throw new TypeError(`Invalid numeric keyframes for "${property}".`)
234
334
  const frames = typeof value === 'number' ? [null, value] : [...value]
235
335
  if (frames.length === 1) frames.unshift(null)
236
336
  if (
package/src/color.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { Button, Color, Label, TextBase, isIOS, type View } from '@nativescript/core'
2
+
3
+ export type ColorProperty = 'backgroundColor' | 'borderColor' | 'color'
4
+ export type ColorValue = string | Color
5
+ export type ColorKeyframes = ColorValue | readonly (ColorValue | null)[]
6
+ export const isColorProperty = (property: string): property is ColorProperty =>
7
+ property === 'backgroundColor' || property === 'borderColor' || property === 'color'
8
+
9
+ export function parseColor(value: unknown): number {
10
+ if (value instanceof Color) return value.argb >>> 0
11
+ if (typeof value !== 'string' || !Color.isValid(value))
12
+ throw new TypeError(`Invalid animation color: ${String(value)}.`)
13
+ return new Color(value).argb >>> 0
14
+ }
15
+
16
+ export function readColor(view: View, property: ColorProperty): number {
17
+ const value = property === 'borderColor' ? view.borderTopColor : view[property]
18
+ return value instanceof Color
19
+ ? value.argb >>> 0
20
+ : property === 'backgroundColor'
21
+ ? 0
22
+ : 0xff000000
23
+ }
24
+
25
+ export function validateColorTarget(view: View, property: ColorProperty) {
26
+ if (
27
+ property === 'color' &&
28
+ !(isIOS ? view instanceof Label || view instanceof Button : view instanceof TextBase)
29
+ )
30
+ throw new TypeError(
31
+ 'Text color animation requires a Label or Button on iOS, or a TextBase on Android.',
32
+ )
33
+ if (property === 'borderColor') {
34
+ const colors = [
35
+ view.borderTopColor,
36
+ view.borderRightColor,
37
+ view.borderBottomColor,
38
+ view.borderLeftColor,
39
+ ]
40
+ if (
41
+ colors.some(
42
+ (color) => (color?.argb ?? 0xff000000) !== (colors[0]?.argb ?? 0xff000000),
43
+ )
44
+ )
45
+ throw new TypeError(
46
+ 'borderColor animation requires the same starting color on every edge.',
47
+ )
48
+ }
49
+ }
50
+
51
+ const clamp = (value: number) => Math.max(0, Math.min(1, value))
52
+ const linear = (channel: number) =>
53
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
54
+ const encoded = (channel: number) =>
55
+ channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055
56
+
57
+ function lab(value: number) {
58
+ const r = linear(((value >>> 16) & 255) / 255)
59
+ const g = linear(((value >>> 8) & 255) / 255)
60
+ const b = linear((value & 255) / 255)
61
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
62
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
63
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
64
+ return [
65
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
66
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
67
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
68
+ ] as const
69
+ }
70
+
71
+ /** Premultiplied OKLab interpolation, clipped to the sRGB output gamut. */
72
+ export function colorMixer(from: number, to: number): (progress: number) => number {
73
+ const a = lab(from),
74
+ b = lab(to)
75
+ const alphaA = (from >>> 24) / 255,
76
+ alphaB = (to >>> 24) / 255
77
+ return (progress) => {
78
+ const p = clamp(progress)
79
+ if (p === 0) return from
80
+ if (p === 1) return to
81
+ const alpha = alphaA + (alphaB - alphaA) * p
82
+ if (alpha <= 0) return 0
83
+ const channel = (i: number) => (a[i]! * alphaA * (1 - p) + b[i]! * alphaB * p) / alpha
84
+ const L = channel(0),
85
+ A = channel(1),
86
+ B = channel(2)
87
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
88
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
89
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
90
+ const r = Math.round(
91
+ clamp(encoded(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)) * 255,
92
+ )
93
+ const g = Math.round(
94
+ clamp(encoded(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)) * 255,
95
+ )
96
+ const blue = Math.round(
97
+ clamp(encoded(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)) * 255,
98
+ )
99
+ return ((Math.round(alpha * 255) << 24) | (r << 16) | (g << 8) | blue) >>> 0
100
+ }
101
+ }
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
+ }
@@ -0,0 +1,168 @@
1
+ import { type View } from '@nativescript/core'
2
+ import { readColor, type ColorProperty } from '../color.js'
3
+ import type { Track } from '../track.js'
4
+
5
+ interface NativeTarget {
6
+ getColor(): number
7
+ setColor(value: number): void
8
+ isSupported(): boolean
9
+ }
10
+ interface ColorHandle {
11
+ cancel(): void
12
+ elapsed(): number
13
+ target: NativeTarget
14
+ }
15
+ const playing = new WeakMap<View, Map<ColorProperty, ColorHandle>>()
16
+
17
+ export function presentedAndroidColor(view: View, property: ColorProperty): number {
18
+ const active = playing.get(view)?.get(property)
19
+ if (active) return active.target.getColor() >>> 0
20
+ if (property === 'color' && view.nativeViewProtected instanceof android.widget.TextView)
21
+ return view.nativeViewProtected.getCurrentTextColor() >>> 0
22
+ return readColor(view, property)
23
+ }
24
+
25
+ export function playAndroidColor(
26
+ view: View,
27
+ track: Track,
28
+ onEnd: (finished?: boolean) => void,
29
+ ): ColorHandle {
30
+ const property = track.property as ColorProperty
31
+ const changes =
32
+ property === 'borderColor'
33
+ ? [
34
+ 'borderTopColorChange',
35
+ 'borderRightColorChange',
36
+ 'borderBottomColorChange',
37
+ 'borderLeftColorChange',
38
+ ]
39
+ : [`${property}Change`]
40
+ const bridge = globalThis as unknown as {
41
+ org: {
42
+ rafaelaugusto: {
43
+ jolt: {
44
+ ColorTarget: new (
45
+ view: android.view.View,
46
+ property: number,
47
+ color: number,
48
+ ) => NativeTarget
49
+ }
50
+ }
51
+ }
52
+ }
53
+ let target: NativeTarget
54
+ try {
55
+ target = new bridge.org.rafaelaugusto.jolt.ColorTarget(
56
+ view.nativeViewProtected,
57
+ property === 'backgroundColor' ? 0 : property === 'borderColor' ? 1 : 2,
58
+ track.values[0]! | 0,
59
+ )
60
+ } catch {
61
+ // A JS-only update cannot install the native plugin. Keep the final model.
62
+ onEnd(false)
63
+ return {
64
+ cancel() {},
65
+ elapsed: () => 0,
66
+ target: {
67
+ getColor: () => readColor(view, property),
68
+ setColor() {},
69
+ isSupported: () => false,
70
+ },
71
+ }
72
+ }
73
+ const frames = Array.create(android.animation.Keyframe, track.values.length)
74
+ track.values.forEach((value, index) => {
75
+ frames[index] = android.animation.Keyframe.ofInt(track.times[index]!, value | 0)
76
+ })
77
+ const holder = android.animation.PropertyValuesHolder.ofKeyframe('color', frames)
78
+ holder.setEvaluator(new android.animation.ArgbEvaluator())
79
+ const holders = Array.create(android.animation.PropertyValuesHolder, 1)
80
+ holders[0] = holder
81
+ const animator = android.animation.ObjectAnimator.ofPropertyValuesHolder(
82
+ target as unknown as java.lang.Object,
83
+ holders,
84
+ )
85
+ animator.setDuration(Math.max(track.duration, 1))
86
+ animator.setStartDelay(track.delay)
87
+ animator.setInterpolator(new android.view.animation.LinearInterpolator())
88
+ if (track.repeat > 0) {
89
+ animator.setRepeatCount(
90
+ Number.isFinite(track.repeat)
91
+ ? track.repeat
92
+ : android.animation.ValueAnimator.INFINITE,
93
+ )
94
+ animator.setRepeatMode(
95
+ track.reverse
96
+ ? android.animation.ValueAnimator.REVERSE
97
+ : android.animation.ValueAnimator.RESTART,
98
+ )
99
+ }
100
+ let stopped = false,
101
+ started = false,
102
+ queued = false
103
+ const cleanup = () => {
104
+ if (stopped) return
105
+ stopped = true
106
+ view.off('unloaded', cancel)
107
+ if (property === 'color') {
108
+ view.off('textChange', invalidate)
109
+ view.off('formattedTextChange', invalidate)
110
+ }
111
+ for (const name of changes) view.style.off(name, invalidate)
112
+ if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
113
+ }
114
+ const cancel = () => {
115
+ if (stopped) return
116
+ cleanup()
117
+ animator.cancel()
118
+ onEnd(false)
119
+ }
120
+ const invalidate = () => {
121
+ if (stopped || queued) return
122
+ queued = true
123
+ void Promise.resolve().then(() => {
124
+ queued = false
125
+ if (!stopped) cancel()
126
+ })
127
+ }
128
+ const handle: ColorHandle = {
129
+ target,
130
+ elapsed: () => (started ? track.delay + animator.getCurrentPlayTime() : 0),
131
+ cancel() {
132
+ cleanup()
133
+ animator.cancel()
134
+ },
135
+ }
136
+ let entries = playing.get(view)
137
+ if (!entries) playing.set(view, (entries = new Map()))
138
+ entries.set(property, handle)
139
+ animator.addListener(
140
+ new android.animation.Animator.AnimatorListener({
141
+ onAnimationStart() {
142
+ started = true
143
+ },
144
+ onAnimationRepeat() {},
145
+ onAnimationCancel() {},
146
+ onAnimationEnd() {
147
+ if (!stopped) {
148
+ cleanup()
149
+ onEnd()
150
+ }
151
+ },
152
+ }),
153
+ )
154
+ view.on('unloaded', cancel)
155
+ if (property === 'color') {
156
+ view.on('textChange', invalidate)
157
+ view.on('formattedTextChange', invalidate)
158
+ }
159
+ for (const name of changes) view.style.on(name, invalidate)
160
+ if (target.isSupported()) {
161
+ target.setColor(track.values[0]! | 0)
162
+ animator.start()
163
+ } else {
164
+ cleanup()
165
+ onEnd(false)
166
+ }
167
+ return handle
168
+ }