@rafaelaugusto/jolt 0.1.0-beta.3 → 0.1.0-beta.5

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 (132) hide show
  1. package/README.md +78 -13
  2. package/dist/animate.d.ts +3 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +35 -5
  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/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +20 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +136 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +28 -35
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +8 -1
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +16 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -0
  34. package/dist/driver/ios-color.js +392 -0
  35. package/dist/driver/ios-color.js.map +1 -0
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +33 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +78 -35
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +48 -2
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +217 -21
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/liquid-grid.d.ts +3 -0
  64. package/dist/liquid-grid.d.ts.map +1 -0
  65. package/dist/liquid-grid.js +24 -0
  66. package/dist/liquid-grid.js.map +1 -0
  67. package/dist/liquid.d.ts +18 -0
  68. package/dist/liquid.d.ts.map +1 -0
  69. package/dist/liquid.js +229 -0
  70. package/dist/liquid.js.map +1 -0
  71. package/dist/playback-group.d.ts +6 -0
  72. package/dist/playback-group.d.ts.map +1 -0
  73. package/dist/playback-group.js +113 -0
  74. package/dist/playback-group.js.map +1 -0
  75. package/dist/playback.d.ts +25 -0
  76. package/dist/playback.d.ts.map +1 -0
  77. package/dist/playback.js +35 -0
  78. package/dist/playback.js.map +1 -0
  79. package/dist/press.d.ts.map +1 -1
  80. package/dist/press.js +6 -1
  81. package/dist/press.js.map +1 -1
  82. package/dist/properties.d.ts +5 -2
  83. package/dist/properties.d.ts.map +1 -1
  84. package/dist/properties.js +25 -1
  85. package/dist/properties.js.map +1 -1
  86. package/dist/sequence.d.ts.map +1 -1
  87. package/dist/sequence.js +6 -0
  88. package/dist/sequence.js.map +1 -1
  89. package/dist/text.d.ts.map +1 -1
  90. package/dist/text.js +3 -0
  91. package/dist/text.js.map +1 -1
  92. package/dist/track.d.ts +1 -1
  93. package/dist/track.d.ts.map +1 -1
  94. package/dist/track.js +48 -3
  95. package/dist/track.js.map +1 -1
  96. package/dist/transition.d.ts.map +1 -1
  97. package/dist/transition.js +11 -2
  98. package/dist/transition.js.map +1 -1
  99. package/dist/types.d.ts +20 -0
  100. package/dist/types.d.ts.map +1 -1
  101. package/package.json +11 -2
  102. package/platforms/android/include.gradle +7 -0
  103. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  104. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  105. package/platforms/android/proguard-rules.pro +7 -0
  106. package/src/animate.ts +48 -9
  107. package/src/color.ts +101 -0
  108. package/src/decoration.ts +180 -0
  109. package/src/driver/android-color.ts +170 -0
  110. package/src/driver/android-decoration.ts +182 -0
  111. package/src/driver/android-playback.ts +157 -0
  112. package/src/driver/android.ts +50 -46
  113. package/src/driver/index.ts +19 -1
  114. package/src/driver/ios-color.ts +441 -0
  115. package/src/driver/ios-decoration.ts +332 -0
  116. package/src/driver/ios-playback.ts +37 -0
  117. package/src/driver/ios-shadow.ts +50 -0
  118. package/src/driver/ios.ts +91 -42
  119. package/src/engine.ts +227 -23
  120. package/src/haptics.ts +25 -2
  121. package/src/index.ts +4 -0
  122. package/src/liquid-grid.ts +28 -0
  123. package/src/liquid.ts +282 -0
  124. package/src/playback-group.ts +110 -0
  125. package/src/playback.ts +62 -0
  126. package/src/press.ts +8 -2
  127. package/src/properties.ts +31 -5
  128. package/src/sequence.ts +5 -0
  129. package/src/text.ts +2 -0
  130. package/src/track.ts +72 -3
  131. package/src/transition.ts +13 -2
  132. package/src/types.ts +20 -0
@@ -0,0 +1,154 @@
1
+ package org.rafaelaugusto.jolt;
2
+
3
+ import android.animation.StateListAnimator;
4
+ import android.graphics.drawable.Drawable;
5
+ import android.graphics.drawable.ColorDrawable;
6
+ import android.view.View;
7
+ import java.lang.ref.WeakReference;
8
+ import java.lang.reflect.Constructor;
9
+ import java.lang.reflect.Field;
10
+ import java.util.WeakHashMap;
11
+
12
+ /** ObjectAnimator's float target. No JavaScript enters the native frame loop. */
13
+ public final class DecorationTarget {
14
+ private static final String BORDER = "org.nativescript.widgets.BorderDrawable";
15
+ private static final Field[][] FIELDS = fields();
16
+ private static final Constructor<?> CONSTRUCTOR = constructor();
17
+ private static final WeakHashMap<View, BackgroundLease> BACKGROUNDS = new WeakHashMap<>();
18
+ private static final WeakHashMap<View, Lease> LEASES = new WeakHashMap<>();
19
+ private final WeakReference<View> view;
20
+ private final int property;
21
+ private final int borderColor;
22
+ private float value;
23
+ private boolean acquired;
24
+
25
+ // 0 = uniform radius, 1 = uniform width, 2 = elevation, 3 = translationZ.
26
+ public DecorationTarget(View view, int property, float value, int borderColor) {
27
+ this.view = new WeakReference<>(view);
28
+ this.property = property;
29
+ this.value = value;
30
+ this.borderColor = borderColor;
31
+ }
32
+ private static Field[][] fields() {
33
+ try {
34
+ Class<?> type = Class.forName(BORDER);
35
+ String[][] names = {
36
+ {"borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"},
37
+ {"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"}
38
+ };
39
+ Field[][] result = new Field[2][4];
40
+ for (int i = 0; i < 2; i++) for (int j = 0; j < 4; j++) {
41
+ result[i][j] = type.getDeclaredField(names[i][j]);
42
+ result[i][j].setAccessible(true);
43
+ }
44
+ return result;
45
+ } catch (ReflectiveOperationException | SecurityException exception) { return null; }
46
+ }
47
+ private static Constructor<?> constructor() {
48
+ try { return Class.forName(BORDER).getConstructor(float.class); }
49
+ catch (ReflectiveOperationException | SecurityException exception) { return null; }
50
+ }
51
+ public boolean isSupported() {
52
+ View nativeView = view.get();
53
+ if (nativeView == null) return false;
54
+ if (property >= 2) return true;
55
+ Drawable drawable = ColorTarget.background(nativeView);
56
+ return FIELDS != null && ((drawable != null && drawable.getClass().getName().equals(BORDER)) ||
57
+ ((drawable == null || drawable.getClass() == ColorDrawable.class) && CONSTRUCTOR != null && ColorTarget.FIELDS != null));
58
+ }
59
+ public float getValue() { return value; }
60
+ public void setValue(float next) {
61
+ View nativeView = view.get();
62
+ if (nativeView == null) return;
63
+ value = next;
64
+ if (property >= 2) suspendStateAnimator(nativeView);
65
+ if (property == 2) { nativeView.setElevation(next); return; }
66
+ if (property == 3) { nativeView.setTranslationZ(next); return; }
67
+ ensureBackground(nativeView);
68
+ Drawable drawable = ColorTarget.background(nativeView);
69
+ if (drawable == null || !drawable.getClass().getName().equals(BORDER) || FIELDS == null) return;
70
+ try {
71
+ // Core rebuilds the paths from these fields in draw() and getOutline().
72
+ // Keep its bitmap, gradient, color and all other state on the same drawable.
73
+ for (Field field : FIELDS[property]) field.setFloat(drawable, next);
74
+ drawable.invalidateSelf();
75
+ nativeView.invalidateOutline();
76
+ } catch (IllegalAccessException exception) { /* Keep Core's final model. */ }
77
+ }
78
+ public void acquire() {
79
+ View nativeView = view.get();
80
+ if (nativeView == null || acquired) return;
81
+ acquired = true;
82
+ if (property < 2) {
83
+ BackgroundLease lease = BACKGROUNDS.get(nativeView);
84
+ if (lease == null) { lease = new BackgroundLease(); BACKGROUNDS.put(nativeView, lease); }
85
+ lease.count++;
86
+ ensureBackground(nativeView);
87
+ return;
88
+ }
89
+ Lease lease = LEASES.get(nativeView);
90
+ if (lease == null) {
91
+ lease = new Lease(nativeView.getStateListAnimator());
92
+ LEASES.put(nativeView, lease);
93
+ nativeView.setStateListAnimator(null);
94
+ }
95
+ lease.count++;
96
+ suspendStateAnimator(nativeView);
97
+ }
98
+ public void release() {
99
+ View nativeView = view.get();
100
+ if (!acquired || nativeView == null) return;
101
+ acquired = false;
102
+ if (property < 2) {
103
+ BackgroundLease lease = BACKGROUNDS.get(nativeView);
104
+ if (lease != null && --lease.count == 0) {
105
+ BACKGROUNDS.remove(nativeView);
106
+ if (lease.drawable != null && nativeView.getBackground() == lease.drawable) nativeView.setBackground(lease.original);
107
+ }
108
+ return;
109
+ }
110
+ suspendStateAnimator(nativeView);
111
+ Lease lease = LEASES.get(nativeView);
112
+ if (lease != null && --lease.count == 0) {
113
+ LEASES.remove(nativeView);
114
+ if (nativeView.getStateListAnimator() == null) {
115
+ float elevation = nativeView.getElevation(), z = nativeView.getTranslationZ();
116
+ nativeView.setStateListAnimator(lease.animator);
117
+ if (lease.animator != null) lease.animator.jumpToCurrentState();
118
+ nativeView.setElevation(elevation);
119
+ nativeView.setTranslationZ(z);
120
+ }
121
+ }
122
+ }
123
+ private void ensureBackground(View nativeView) {
124
+ BackgroundLease lease = BACKGROUNDS.get(nativeView);
125
+ Drawable original = nativeView.getBackground();
126
+ if (lease == null || (original != null && original.getClass() != ColorDrawable.class) || CONSTRUCTOR == null || ColorTarget.FIELDS == null) return;
127
+ try {
128
+ Drawable drawable = (Drawable) CONSTRUCTOR.newInstance(nativeView.getResources().getDisplayMetrics().density);
129
+ ColorTarget.FIELDS[0].setInt(drawable, original == null ? 0 : ((ColorDrawable) original).getColor());
130
+ for (int i = 1; i < ColorTarget.FIELDS.length; i++) ColorTarget.FIELDS[i].setInt(drawable, borderColor);
131
+ lease.original = original;
132
+ lease.drawable = drawable;
133
+ nativeView.setBackground(drawable);
134
+ } catch (ReflectiveOperationException exception) { /* Unsupported Core. */ }
135
+ }
136
+ private static final class BackgroundLease {
137
+ Drawable original, drawable;
138
+ int count;
139
+ }
140
+ private static void suspendStateAnimator(View view) {
141
+ Lease lease = LEASES.get(view);
142
+ StateListAnimator current = view.getStateListAnimator();
143
+ if (lease != null && current != null) {
144
+ // A Core model write may have installed an updated state animator.
145
+ lease.animator = current;
146
+ view.setStateListAnimator(null);
147
+ }
148
+ }
149
+ private static final class Lease {
150
+ StateListAnimator animator;
151
+ int count;
152
+ Lease(StateListAnimator animator) { this.animator = animator; }
153
+ }
154
+ }
@@ -0,0 +1,7 @@
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
+ }
7
+ -keep class org.rafaelaugusto.jolt.DecorationTarget { *; }
package/src/animate.ts CHANGED
@@ -1,4 +1,17 @@
1
+ import {
2
+ isDecorationProperty,
3
+ validateDecorationTarget,
4
+ validateDecorationFrames,
5
+ } from './decoration.js'
6
+ import { playbackGroup } from './playback-group.js'
7
+ import type { PlaybackAnimation } from './playback.js'
1
8
  import { View } from '@nativescript/core'
9
+ import {
10
+ isColorProperty,
11
+ parseColor,
12
+ validateColorTarget,
13
+ type ColorKeyframes,
14
+ } from './color.js'
2
15
  import { DEFAULT_FRICTION, solveDecay } from './decay.js'
3
16
  import {
4
17
  arrivalOf,
@@ -51,7 +64,7 @@ export function animate(
51
64
  target: Target | readonly Target[],
52
65
  animation: Keyframes | PresetName,
53
66
  options?: AnimateOptions | SpringName,
54
- ): Animation {
67
+ ): PlaybackAnimation {
55
68
  const { keyframes, base, overrides, stagger, relative, haptic } = prepare(
56
69
  animation,
57
70
  options,
@@ -59,15 +72,23 @@ export function animate(
59
72
  const feedback = resolveHaptic(haptic)
60
73
 
61
74
  if (!isList(target)) {
75
+ const view = toView(target)
76
+ for (const property of Object.keys(keyframes))
77
+ if (isColorProperty(property)) validateColorTarget(view, property)
78
+ else if (isDecorationProperty(property)) validateDecorationTarget(view, property)
62
79
  return new ViewAnimation(
63
- toView(target),
80
+ view,
64
81
  plan(keyframes, base, overrides, 0, relative),
65
82
  feedback,
66
83
  )
67
84
  }
68
85
 
69
86
  const views = target.map(toView)
70
- return group(
87
+ for (const view of views)
88
+ for (const property of Object.keys(keyframes))
89
+ if (isColorProperty(property)) validateColorTarget(view, property)
90
+ else if (isDecorationProperty(property)) validateDecorationTarget(view, property)
91
+ return playbackGroup(
71
92
  views.map(
72
93
  (view, index) =>
73
94
  new ViewAnimation(
@@ -80,6 +101,7 @@ export function animate(
80
101
  relative,
81
102
  ),
82
103
  feedback,
104
+ true,
83
105
  ),
84
106
  ),
85
107
  )
@@ -90,7 +112,7 @@ export function decay(
90
112
  target: Target | readonly Target[],
91
113
  velocity: DecayVelocity,
92
114
  options: DecayOptions = {},
93
- ): Animation {
115
+ ): PlaybackAnimation {
94
116
  const friction = options.friction ?? DEFAULT_FRICTION
95
117
  if (!Number.isFinite(friction) || friction <= 0 || friction >= 1) {
96
118
  throw new RangeError('Decay friction must be between 0 and 1 exclusively.')
@@ -152,8 +174,10 @@ export function decay(
152
174
  ]
153
175
  })
154
176
  const views = toViews(target)
155
- if (plans.length === 0) return group([])
156
- return group(views.map((view) => new ViewAnimation(view, plans)))
177
+ if (plans.length === 0) return playbackGroup([])
178
+ return playbackGroup(
179
+ views.map((view) => new ViewAnimation(view, plans, undefined, true)),
180
+ )
157
181
  }
158
182
 
159
183
  /** Stops all or selected properties at their current values. `scale` stops both axes. */
@@ -282,7 +306,10 @@ export function startingValues(
282
306
  const { keyframes, relative } = prepare(animation, options)
283
307
  if (!keyframes) throw new Error(`Unknown preset "${animation}".`)
284
308
  return new Map(
285
- expand(keyframes).map(([property, [first]]) => [property, relative ? null : first!]),
309
+ expand(keyframes).map(([property, [first]]) => [
310
+ property,
311
+ relative && !isColorProperty(property) ? null : first!,
312
+ ]),
286
313
  )
287
314
  }
288
315
 
@@ -290,7 +317,7 @@ function expand(keyframes: Keyframes): [Property, (number | null)[]][] {
290
317
  const expanded: [Property, (number | null)[]][] = []
291
318
  for (const [key, value] of Object.entries(keyframes) as [
292
319
  keyof Keyframes,
293
- KeyframeValue | undefined,
320
+ KeyframeValue | ColorKeyframes | undefined,
294
321
  ][]) {
295
322
  if (value === undefined) continue
296
323
  if (key !== 'scale' && !isProperty(key)) throw new Error(`Can't animate "${key}".`)
@@ -304,7 +331,18 @@ function expand(keyframes: Keyframes): [Property, (number | null)[]][] {
304
331
  return expanded
305
332
  }
306
333
 
307
- function toKeyframes(property: Property, value: KeyframeValue): (number | null)[] {
334
+ function toKeyframes(
335
+ property: Property,
336
+ value: KeyframeValue | ColorKeyframes,
337
+ ): (number | null)[] {
338
+ if (isColorProperty(property)) {
339
+ const frames = Array.isArray(value) ? [...value] : [null, value]
340
+ if (!frames.length) throw new TypeError(`Empty color keyframes for "${property}".`)
341
+ if (frames.length === 1) frames.unshift(null)
342
+ return frames.map((frame) => (frame === null ? null : parseColor(frame)))
343
+ }
344
+ if (typeof value !== 'number' && !Array.isArray(value))
345
+ throw new TypeError(`Invalid numeric keyframes for "${property}".`)
308
346
  const frames = typeof value === 'number' ? [null, value] : [...value]
309
347
  if (frames.length === 1) frames.unshift(null)
310
348
  if (
@@ -313,6 +351,7 @@ function toKeyframes(property: Property, value: KeyframeValue): (number | null)[
313
351
  ) {
314
352
  throw new Error(`Invalid keyframes for "${property}": ${JSON.stringify(value)}.`)
315
353
  }
354
+ if (isDecorationProperty(property)) validateDecorationFrames(property, frames)
316
355
  return frames
317
356
  }
318
357
 
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
+ }
@@ -0,0 +1,180 @@
1
+ import { preserveZeroShadow } from './driver/ios-shadow.js'
2
+ import { Color, Utils, isAndroid, type View } from '@nativescript/core'
3
+
4
+ type Shadow = Exclude<View['boxShadow'], string>[number]
5
+ export type DecorationProperty =
6
+ | 'borderRadius'
7
+ | 'borderWidth'
8
+ | 'shadowOpacity'
9
+ | 'shadowRadius'
10
+ | 'shadowOffsetX'
11
+ | 'shadowOffsetY'
12
+ | 'elevation'
13
+ | 'translationZ'
14
+
15
+ export const decorationProperties: readonly DecorationProperty[] = [
16
+ 'borderRadius',
17
+ 'borderWidth',
18
+ 'shadowOpacity',
19
+ 'shadowRadius',
20
+ 'shadowOffsetX',
21
+ 'shadowOffsetY',
22
+ 'elevation',
23
+ 'translationZ',
24
+ ]
25
+ export const radiusKeys = [
26
+ 'borderTopLeftRadius',
27
+ 'borderTopRightRadius',
28
+ 'borderBottomRightRadius',
29
+ 'borderBottomLeftRadius',
30
+ ] as const
31
+ export const widthKeys = [
32
+ 'borderTopWidth',
33
+ 'borderRightWidth',
34
+ 'borderBottomWidth',
35
+ 'borderLeftWidth',
36
+ ] as const
37
+ const writing = new WeakSet<View>()
38
+ export const isWritingDecoration = (view: View) => writing.has(view)
39
+ export const isDecorationProperty = (key: string): key is DecorationProperty =>
40
+ decorationProperties.includes(key as DecorationProperty)
41
+
42
+ /** Core lengths may be numbers, px strings or parsed { value, unit } lengths. */
43
+ export function dip(value: unknown): number {
44
+ if (value === undefined || value === null) return 0
45
+ if (typeof value === 'number' && Number.isFinite(value)) return value
46
+ if (typeof value === 'string') {
47
+ const match = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(dip|px)?$/.exec(value.trim())
48
+ if (match)
49
+ return Number(match[1]) / (match[2] === 'px' ? Utils.layout.getDisplayDensity() : 1)
50
+ }
51
+ if (typeof value === 'object' && 'value' in value && 'unit' in value) {
52
+ const length = value as { value: number; unit: string }
53
+ if (Number.isFinite(length.value) && (length.unit === 'dip' || length.unit === 'px'))
54
+ return length.value / (length.unit === 'px' ? Utils.layout.getDisplayDensity() : 1)
55
+ }
56
+ throw new TypeError('Decoration animations require finite DIP or px lengths.')
57
+ }
58
+
59
+ export function singleShadow(view: View): Shadow {
60
+ const shadows = view.boxShadow
61
+ if (!Array.isArray(shadows) || shadows.length !== 1 || shadows[0]!.inset)
62
+ throw new TypeError('Shadow animation requires one existing outer boxShadow.')
63
+ return shadows[0]!
64
+ }
65
+
66
+ export function validateDecorationTarget(view: View, property: DecorationProperty) {
67
+ const shadow = property.startsWith('shadow')
68
+ const z = property === 'elevation' || property === 'translationZ'
69
+ if ((shadow && isAndroid) || (z && !isAndroid))
70
+ throw new TypeError(`"${property}" is only supported on ${z ? 'Android' : 'iOS'}.`)
71
+ if (z) return
72
+ if (view.style.clipPath || view.style.cornerShape === 'squircle')
73
+ throw new TypeError(
74
+ 'Decoration animations require circular corners without a clipPath.',
75
+ )
76
+ for (const keys of [radiusKeys, widthKeys]) {
77
+ const values = keys.map((key) => dip(view[key]))
78
+ if (values.some((value) => value !== values[0]))
79
+ throw new TypeError(
80
+ 'Decoration animations require uniform border widths and radii.',
81
+ )
82
+ }
83
+ const colors = [
84
+ view.borderTopColor,
85
+ view.borderRightColor,
86
+ view.borderBottomColor,
87
+ view.borderLeftColor,
88
+ ].map((color) => color?.argb ?? 0)
89
+ if (colors.some((color) => color !== colors[0]))
90
+ throw new TypeError('Decoration animations require uniform border colors.')
91
+ if (shadow) {
92
+ const value = singleShadow(view)
93
+ for (const length of [
94
+ value.offsetX,
95
+ value.offsetY,
96
+ value.blurRadius,
97
+ value.spreadRadius,
98
+ ])
99
+ dip(length)
100
+ }
101
+ if (
102
+ property === 'borderRadius' &&
103
+ isAndroid &&
104
+ view.boxShadow &&
105
+ (!Array.isArray(view.boxShadow) || view.boxShadow.length)
106
+ )
107
+ throw new TypeError(
108
+ 'Animate Android radius with elevation shadows; CSS boxShadow caches its shape.',
109
+ )
110
+ }
111
+
112
+ export function validateDecorationFrames(
113
+ property: DecorationProperty,
114
+ frames: readonly (number | null)[],
115
+ ) {
116
+ if (
117
+ frames.some((value) => value !== null && clampDecoration(property, value) !== value)
118
+ )
119
+ throw new RangeError(`Invalid keyframes for "${property}".`)
120
+ }
121
+ export function clampDecoration(property: DecorationProperty, value: number): number {
122
+ if (property === 'shadowOpacity') return Math.max(0, Math.min(1, value))
123
+ return property === 'borderRadius' ||
124
+ property === 'borderWidth' ||
125
+ property === 'shadowRadius' ||
126
+ property === 'elevation'
127
+ ? Math.max(0, value)
128
+ : value
129
+ }
130
+
131
+ export function readDecoration(view: View, property: DecorationProperty): number {
132
+ if (property === 'borderRadius') {
133
+ const radius = !isAndroid ? view.nativeViewProtected?.layer?.cornerRadius : undefined
134
+ return typeof radius === 'number' ? radius : dip(view.borderTopLeftRadius)
135
+ }
136
+ if (property === 'borderWidth') return dip(view.borderTopWidth)
137
+ if (property === 'elevation')
138
+ return view.nativeViewProtected?.getElevation
139
+ ? view.nativeViewProtected.getElevation() / Utils.layout.getDisplayDensity()
140
+ : view.androidElevation || 0
141
+ if (property === 'translationZ')
142
+ return (
143
+ view.nativeViewProtected?.getTranslationZ?.() / Utils.layout.getDisplayDensity() ||
144
+ 0
145
+ )
146
+ const shadow = singleShadow(view)
147
+ if (property === 'shadowOpacity')
148
+ return ((shadow.color?.argb ?? 0xff000000) >>> 24) / 255
149
+ return property === 'shadowRadius'
150
+ ? dip(shadow.blurRadius) / 2
151
+ : dip(property === 'shadowOffsetX' ? shadow.offsetX : shadow.offsetY)
152
+ }
153
+
154
+ export function writeDecoration(view: View, property: DecorationProperty, raw: number) {
155
+ const value = clampDecoration(property, raw)
156
+ writing.add(view)
157
+ try {
158
+ if (property === 'borderRadius' || property === 'borderWidth') view[property] = value
159
+ else if (property === 'elevation') view.androidElevation = value
160
+ else if (property === 'translationZ')
161
+ view.nativeViewProtected?.setTranslationZ?.(
162
+ value * Utils.layout.getDisplayDensity(),
163
+ )
164
+ else {
165
+ const shadow = { ...singleShadow(view) }
166
+ if (property === 'shadowOpacity')
167
+ shadow.color = new Color(
168
+ ((Math.round(value * 255) << 24) | ((shadow.color?.argb ?? 0) & 0xffffff)) >>>
169
+ 0,
170
+ )
171
+ else if (property === 'shadowRadius') shadow.blurRadius = value * 2
172
+ else if (property === 'shadowOffsetX') shadow.offsetX = value
173
+ else shadow.offsetY = value
174
+ view.boxShadow = [shadow]
175
+ if (!isAndroid) preserveZeroShadow(view)
176
+ }
177
+ } finally {
178
+ writing.delete(view)
179
+ }
180
+ }