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

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 (201) hide show
  1. package/README.md +134 -4
  2. package/dist/animate.d.ts +7 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +58 -13
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.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 +3 -0
  15. package/dist/driver/android-color.d.ts.map +1 -1
  16. package/dist/driver/android-color.js +20 -18
  17. package/dist/driver/android-color.js.map +1 -1
  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 +30 -36
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +9 -0
  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 +2 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -1
  34. package/dist/driver/ios-color.js +19 -8
  35. package/dist/driver/ios-color.js.map +1 -1
  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 +47 -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 +76 -40
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +64 -3
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +243 -19
  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 +12 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +5 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/items.d.ts +20 -0
  64. package/dist/items.d.ts.map +1 -0
  65. package/dist/items.js +109 -0
  66. package/dist/items.js.map +1 -0
  67. package/dist/keyboard.d.ts +17 -0
  68. package/dist/keyboard.d.ts.map +1 -0
  69. package/dist/keyboard.js +116 -0
  70. package/dist/keyboard.js.map +1 -0
  71. package/dist/label.d.ts +7 -0
  72. package/dist/label.d.ts.map +1 -0
  73. package/dist/label.js +60 -0
  74. package/dist/label.js.map +1 -0
  75. package/dist/liquid.d.ts.map +1 -1
  76. package/dist/liquid.js +2 -1
  77. package/dist/liquid.js.map +1 -1
  78. package/dist/loader.d.ts.map +1 -1
  79. package/dist/loader.js +2 -1
  80. package/dist/loader.js.map +1 -1
  81. package/dist/particles.d.ts.map +1 -1
  82. package/dist/particles.js +4 -3
  83. package/dist/particles.js.map +1 -1
  84. package/dist/path.d.ts +12 -0
  85. package/dist/path.d.ts.map +1 -0
  86. package/dist/path.js +90 -0
  87. package/dist/path.js.map +1 -0
  88. package/dist/playback-group.d.ts +6 -0
  89. package/dist/playback-group.d.ts.map +1 -0
  90. package/dist/playback-group.js +141 -0
  91. package/dist/playback-group.js.map +1 -0
  92. package/dist/playback.d.ts +25 -0
  93. package/dist/playback.d.ts.map +1 -0
  94. package/dist/playback.js +35 -0
  95. package/dist/playback.js.map +1 -0
  96. package/dist/press.d.ts.map +1 -1
  97. package/dist/press.js +3 -3
  98. package/dist/press.js.map +1 -1
  99. package/dist/properties.d.ts +2 -1
  100. package/dist/properties.d.ts.map +1 -1
  101. package/dist/properties.js +14 -1
  102. package/dist/properties.js.map +1 -1
  103. package/dist/reveal.d.ts.map +1 -1
  104. package/dist/reveal.js +3 -2
  105. package/dist/reveal.js.map +1 -1
  106. package/dist/roll-diff.d.ts +22 -0
  107. package/dist/roll-diff.d.ts.map +1 -0
  108. package/dist/roll-diff.js +36 -0
  109. package/dist/roll-diff.js.map +1 -0
  110. package/dist/roll.d.ts +29 -0
  111. package/dist/roll.d.ts.map +1 -0
  112. package/dist/roll.js +442 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/scroll-linked.d.ts +10 -0
  115. package/dist/scroll-linked.d.ts.map +1 -0
  116. package/dist/scroll-linked.js +58 -0
  117. package/dist/scroll-linked.js.map +1 -0
  118. package/dist/sequence.d.ts +10 -5
  119. package/dist/sequence.d.ts.map +1 -1
  120. package/dist/sequence.js +208 -14
  121. package/dist/sequence.js.map +1 -1
  122. package/dist/shimmer.d.ts.map +1 -1
  123. package/dist/shimmer.js +2 -1
  124. package/dist/shimmer.js.map +1 -1
  125. package/dist/svg-path.d.ts +14 -0
  126. package/dist/svg-path.d.ts.map +1 -0
  127. package/dist/svg-path.js +281 -0
  128. package/dist/svg-path.js.map +1 -0
  129. package/dist/symbol-effect.d.ts.map +1 -1
  130. package/dist/symbol-effect.js +4 -3
  131. package/dist/symbol-effect.js.map +1 -1
  132. package/dist/text-layout.d.ts +8 -0
  133. package/dist/text-layout.d.ts.map +1 -1
  134. package/dist/text-layout.js +8 -2
  135. package/dist/text-layout.js.map +1 -1
  136. package/dist/text.d.ts +1 -1
  137. package/dist/text.d.ts.map +1 -1
  138. package/dist/text.js +60 -64
  139. package/dist/text.js.map +1 -1
  140. package/dist/track.d.ts +2 -0
  141. package/dist/track.d.ts.map +1 -1
  142. package/dist/track.js +72 -16
  143. package/dist/track.js.map +1 -1
  144. package/dist/transition.d.ts.map +1 -1
  145. package/dist/transition.js +4 -1
  146. package/dist/transition.js.map +1 -1
  147. package/dist/types.d.ts +22 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/vue/index.d.ts +89 -0
  150. package/dist/vue/index.d.ts.map +1 -0
  151. package/dist/vue/index.js +134 -0
  152. package/dist/vue/index.js.map +1 -0
  153. package/package.json +15 -3
  154. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  155. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  156. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  157. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  158. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  159. package/platforms/android/proguard-rules.pro +7 -0
  160. package/src/android-plugin.d.ts +64 -0
  161. package/src/animate.ts +96 -33
  162. package/src/config.ts +46 -0
  163. package/src/decoration.ts +180 -0
  164. package/src/driver/android-color.ts +22 -20
  165. package/src/driver/android-decoration.ts +182 -0
  166. package/src/driver/android-playback.ts +157 -0
  167. package/src/driver/android.ts +57 -43
  168. package/src/driver/index.ts +15 -0
  169. package/src/driver/ios-color.ts +19 -10
  170. package/src/driver/ios-decoration.ts +332 -0
  171. package/src/driver/ios-playback.ts +51 -0
  172. package/src/driver/ios-shadow.ts +50 -0
  173. package/src/driver/ios.ts +87 -46
  174. package/src/engine.ts +267 -22
  175. package/src/haptics.ts +25 -2
  176. package/src/index.ts +12 -1
  177. package/src/items.ts +140 -0
  178. package/src/keyboard.ts +132 -0
  179. package/src/label.ts +76 -0
  180. package/src/liquid.ts +2 -1
  181. package/src/loader.ts +2 -1
  182. package/src/particles.ts +4 -4
  183. package/src/path.ts +124 -0
  184. package/src/playback-group.ts +143 -0
  185. package/src/playback.ts +62 -0
  186. package/src/press.ts +3 -5
  187. package/src/properties.ts +18 -2
  188. package/src/reveal.ts +3 -3
  189. package/src/roll-diff.ts +57 -0
  190. package/src/roll.ts +515 -0
  191. package/src/scroll-linked.ts +73 -0
  192. package/src/sequence.ts +204 -14
  193. package/src/shimmer.ts +2 -1
  194. package/src/svg-path.ts +355 -0
  195. package/src/symbol-effect.ts +4 -3
  196. package/src/text-layout.ts +14 -2
  197. package/src/text.ts +62 -81
  198. package/src/track.ts +98 -25
  199. package/src/transition.ts +4 -1
  200. package/src/types.ts +23 -1
  201. package/src/vue/index.ts +189 -0
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { getDriver } from './driver/index.js'
2
+
3
+ export interface JoltConfig {
4
+ /** Playback speed for every engine animation. `0.2` plays everything five times slower. */
5
+ timeScale?: number
6
+ /**
7
+ * When transforms, paths, decorations and decay jump to their end instead of animating.
8
+ * `user` follows the platform setting. Opacity and colors still animate.
9
+ */
10
+ reducedMotion?: 'user' | 'always' | 'never'
11
+ }
12
+
13
+ const settings: Required<JoltConfig> = { timeScale: 1, reducedMotion: 'user' }
14
+ const preferences: readonly JoltConfig['reducedMotion'][] = ['user', 'always', 'never']
15
+
16
+ /**
17
+ * Global settings, applied to animations that start after the call.
18
+ *
19
+ * ```ts
20
+ * configure({ timeScale: 0.2 }) // tune a spring in slow motion
21
+ * configure({ reducedMotion: 'always' }) // check the reduced layout
22
+ * ```
23
+ */
24
+ export function configure(options: JoltConfig): void {
25
+ if (options === null || typeof options !== 'object')
26
+ throw new TypeError('configure() takes an options object.')
27
+ const { timeScale, reducedMotion } = options
28
+ if (timeScale !== undefined && (!Number.isFinite(timeScale) || timeScale <= 0))
29
+ throw new RangeError(`Time scale must be a positive finite number, got ${timeScale}.`)
30
+ if (reducedMotion !== undefined && !preferences.includes(reducedMotion))
31
+ throw new TypeError(
32
+ `Reduced motion must be 'user', 'always' or 'never', got ${String(reducedMotion)}.`,
33
+ )
34
+ if (timeScale !== undefined) settings.timeScale = timeScale
35
+ if (reducedMotion !== undefined) settings.reducedMotion = reducedMotion
36
+ }
37
+
38
+ export function timeScale(): number {
39
+ return settings.timeScale
40
+ }
41
+
42
+ /** Whether motion is reduced right now. `user` asks the platform each time. */
43
+ export function reducedMotion(): boolean {
44
+ if (settings.reducedMotion === 'user') return getDriver().reducedMotion?.() ?? false
45
+ return settings.reducedMotion === 'always'
46
+ }
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  import { type View } from '@nativescript/core'
2
2
  import { readColor, type ColorProperty } from '../color.js'
3
+ import { androidPlayback, type AndroidPlayback } from './android-playback.js'
4
+ import type { PlaybackCommand } from '../playback.js'
3
5
  import type { Track } from '../track.js'
4
6
 
5
7
  interface NativeTarget {
@@ -8,12 +10,18 @@ interface NativeTarget {
8
10
  isSupported(): boolean
9
11
  }
10
12
  interface ColorHandle {
13
+ control(elapsed: number, paused: boolean, command?: PlaybackCommand): void
11
14
  cancel(): void
12
15
  elapsed(): number
13
16
  target: NativeTarget
14
17
  }
15
18
  const playing = new WeakMap<View, Map<ColorProperty, ColorHandle>>()
16
19
 
20
+ export function syncAndroidColor(view: View) {
21
+ for (const handle of playing.get(view)?.values() ?? [])
22
+ handle.target.setColor(handle.target.getColor())
23
+ }
24
+
17
25
  export function presentedAndroidColor(view: View, property: ColorProperty): number {
18
26
  const active = playing.get(view)?.get(property)
19
27
  if (active) return active.target.getColor() >>> 0
@@ -62,6 +70,7 @@ export function playAndroidColor(
62
70
  onEnd(false)
63
71
  return {
64
72
  cancel() {},
73
+ control() {},
65
74
  elapsed: () => 0,
66
75
  target: {
67
76
  getColor: () => readColor(view, property),
@@ -98,7 +107,6 @@ export function playAndroidColor(
98
107
  )
99
108
  }
100
109
  let stopped = false,
101
- started = false,
102
110
  queued = false
103
111
  const cleanup = () => {
104
112
  if (stopped) return
@@ -111,10 +119,11 @@ export function playAndroidColor(
111
119
  for (const name of changes) view.style.off(name, invalidate)
112
120
  if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
113
121
  }
122
+ let player: AndroidPlayback | undefined
114
123
  const cancel = () => {
115
124
  if (stopped) return
116
125
  cleanup()
117
- animator.cancel()
126
+ player?.cancel()
118
127
  onEnd(false)
119
128
  }
120
129
  const invalidate = () => {
@@ -127,30 +136,18 @@ export function playAndroidColor(
127
136
  }
128
137
  const handle: ColorHandle = {
129
138
  target,
130
- elapsed: () => (started ? track.delay + animator.getCurrentPlayTime() : 0),
139
+ elapsed: () => player?.elapsed() ?? 0,
140
+ control(time, paused, command) {
141
+ player?.control(time, paused, command)
142
+ },
131
143
  cancel() {
132
144
  cleanup()
133
- animator.cancel()
145
+ player?.cancel()
134
146
  },
135
147
  }
136
148
  let entries = playing.get(view)
137
149
  if (!entries) playing.set(view, (entries = new Map()))
138
150
  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
151
  view.on('unloaded', cancel)
155
152
  if (property === 'color') {
156
153
  view.on('textChange', invalidate)
@@ -159,7 +156,12 @@ export function playAndroidColor(
159
156
  for (const name of changes) view.style.on(name, invalidate)
160
157
  if (target.isSupported()) {
161
158
  target.setColor(track.values[0]! | 0)
162
- animator.start()
159
+ player = androidPlayback(animator, track, () => {
160
+ if (!stopped) {
161
+ cleanup()
162
+ onEnd()
163
+ }
164
+ })
163
165
  } else {
164
166
  cleanup()
165
167
  onEnd(false)
@@ -0,0 +1,182 @@
1
+ import { Utils, type View } from '@nativescript/core'
2
+ import {
3
+ isWritingDecoration,
4
+ radiusKeys,
5
+ widthKeys,
6
+ validateDecorationTarget,
7
+ type DecorationProperty,
8
+ } from '../decoration.js'
9
+ import { androidPlayback, type AndroidPlayback } from './android-playback.js'
10
+ import type { PlaybackCommand } from '../playback.js'
11
+ import type { Track } from '../track.js'
12
+ interface NativeTarget {
13
+ getValue(): number
14
+ setValue(value: number): void
15
+ isSupported(): boolean
16
+ acquire(): void
17
+ release(): void
18
+ }
19
+ interface Handle {
20
+ control(elapsed: number, paused: boolean, command?: PlaybackCommand): void
21
+ cancel(): void
22
+ elapsed(): number
23
+ target: NativeTarget
24
+ }
25
+ const playing = new WeakMap<View, Map<DecorationProperty, Handle>>()
26
+ const ids = { borderRadius: 0, borderWidth: 1, elevation: 2, translationZ: 3 } as const
27
+ export function syncAndroidDecoration(view: View) {
28
+ for (const handle of playing.get(view)?.values() ?? [])
29
+ handle.target.setValue(handle.target.getValue())
30
+ }
31
+ export const presentedAndroidDecoration = (view: View, property: DecorationProperty) => {
32
+ const target = playing.get(view)?.get(property)?.target
33
+ return target ? target.getValue() / Utils.layout.getDisplayDensity() : undefined
34
+ }
35
+ export function playAndroidDecoration(
36
+ view: View,
37
+ track: Track,
38
+ onEnd: (finished?: boolean) => void,
39
+ ): Handle {
40
+ const property = track.property as keyof typeof ids
41
+ const density = Utils.layout.getDisplayDensity()
42
+ const bridge = globalThis as unknown as {
43
+ org: {
44
+ rafaelaugusto: {
45
+ jolt: {
46
+ DecorationTarget: new (
47
+ view: android.view.View,
48
+ property: number,
49
+ value: number,
50
+ borderColor: number,
51
+ ) => NativeTarget
52
+ }
53
+ }
54
+ }
55
+ }
56
+ let target: NativeTarget
57
+ try {
58
+ target = new bridge.org.rafaelaugusto.jolt.DecorationTarget(
59
+ view.nativeViewProtected,
60
+ ids[property],
61
+ track.values[0]! * density,
62
+ view.borderTopColor?.argb ?? 0,
63
+ )
64
+ } catch {
65
+ onEnd(false)
66
+ return {
67
+ cancel() {},
68
+ control() {},
69
+ elapsed: () => 0,
70
+ target: {
71
+ getValue: () => track.to * density,
72
+ setValue() {},
73
+ isSupported: () => false,
74
+ acquire() {},
75
+ release() {},
76
+ },
77
+ }
78
+ }
79
+ const frames = Array.create(android.animation.Keyframe, track.values.length)
80
+ track.values.forEach((value, index) => {
81
+ frames[index] = android.animation.Keyframe.ofFloat(
82
+ track.times[index]!,
83
+ value * density,
84
+ )
85
+ })
86
+ const holders = Array.create(android.animation.PropertyValuesHolder, 1)
87
+ holders[0] = android.animation.PropertyValuesHolder.ofKeyframe('value', frames)
88
+ const animator = android.animation.ObjectAnimator.ofPropertyValuesHolder(
89
+ target as unknown as java.lang.Object,
90
+ holders,
91
+ )
92
+ animator.setDuration(Math.max(track.duration, 1))
93
+ animator.setStartDelay(track.delay)
94
+ animator.setInterpolator(new android.view.animation.LinearInterpolator())
95
+ if (track.repeat > 0) {
96
+ animator.setRepeatCount(
97
+ Number.isFinite(track.repeat)
98
+ ? track.repeat
99
+ : android.animation.ValueAnimator.INFINITE,
100
+ )
101
+ animator.setRepeatMode(
102
+ track.reverse
103
+ ? android.animation.ValueAnimator.REVERSE
104
+ : android.animation.ValueAnimator.RESTART,
105
+ )
106
+ }
107
+ let stopped = false
108
+ const changes = [
109
+ ...radiusKeys,
110
+ ...widthKeys,
111
+ 'clipPath',
112
+ 'cornerShape',
113
+ 'boxShadow',
114
+ 'androidElevation',
115
+ ].map((name) => `${name}Change`)
116
+ const cleanup = () => {
117
+ if (stopped) return
118
+ stopped = true
119
+ view.off('unloaded', cancel)
120
+ for (const event of changes) view.style.off(event, external)
121
+ target.release()
122
+ if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
123
+ }
124
+ let player: AndroidPlayback | undefined
125
+ const cancel = () => {
126
+ if (!stopped) {
127
+ cleanup()
128
+ player?.cancel()
129
+ onEnd(false)
130
+ }
131
+ }
132
+ const external = (event: { eventName: string }) => {
133
+ if (isWritingDecoration(view)) return
134
+ const own =
135
+ property === 'borderRadius'
136
+ ? radiusKeys
137
+ : property === 'borderWidth'
138
+ ? widthKeys
139
+ : property === 'elevation'
140
+ ? ['androidElevation']
141
+ : []
142
+ void Promise.resolve().then(() => {
143
+ if (stopped) return
144
+ if (own.some((name) => event.eventName === `${name}Change`)) return cancel()
145
+ try {
146
+ validateDecorationTarget(view, property)
147
+ } catch {
148
+ cancel()
149
+ }
150
+ })
151
+ }
152
+ const handle: Handle = {
153
+ target,
154
+ elapsed: () => player?.elapsed() ?? 0,
155
+ control(time, paused, command) {
156
+ player?.control(time, paused, command)
157
+ },
158
+ cancel() {
159
+ cleanup()
160
+ player?.cancel()
161
+ },
162
+ }
163
+ const entries = playing.get(view) ?? new Map<DecorationProperty, Handle>()
164
+ playing.set(view, entries)
165
+ entries.set(property, handle)
166
+ view.on('unloaded', cancel)
167
+ for (const event of changes) view.style.on(event, external)
168
+ if (target.isSupported()) {
169
+ target.acquire()
170
+ target.setValue(track.values[0]! * density)
171
+ player = androidPlayback(animator, track, () => {
172
+ if (!stopped) {
173
+ cleanup()
174
+ onEnd()
175
+ }
176
+ })
177
+ } else {
178
+ cleanup()
179
+ onEnd(false)
180
+ }
181
+ return handle
182
+ }
@@ -0,0 +1,157 @@
1
+ import type { Track } from '../track.js'
2
+ import type { PlaybackClock, PlaybackCommand } from '../playback.js'
3
+
4
+ export interface AndroidPlayback {
5
+ cancel(): void
6
+ elapsed(): number
7
+ control(elapsed: number, paused: boolean, command?: PlaybackCommand): void
8
+ }
9
+
10
+ /** Reuse native keyframes when seeking, including rewinding into a start delay. */
11
+ export function androidPlayback(
12
+ animator: android.animation.ValueAnimator,
13
+ track: Track,
14
+ onEnd: () => void,
15
+ onStart?: () => void,
16
+ onRelease?: () => void,
17
+ ): AndroidPlayback {
18
+ let suppressed = false
19
+ let stopped = false
20
+ let started = false
21
+ let acquired = false
22
+ let origin = 0
23
+ let delay = track.delay
24
+ let held: number | undefined
25
+ animator.addListener(
26
+ new android.animation.Animator.AnimatorListener({
27
+ onAnimationStart() {
28
+ if (!suppressed && !stopped) {
29
+ started = true
30
+ if (!acquired) {
31
+ acquired = true
32
+ onStart?.()
33
+ }
34
+ }
35
+ },
36
+ onAnimationRepeat() {},
37
+ onAnimationCancel() {
38
+ if (!suppressed && !stopped) onRelease?.()
39
+ },
40
+ onAnimationEnd() {
41
+ if (suppressed || stopped || held !== undefined) return
42
+ stopped = true
43
+ onRelease?.()
44
+ onEnd()
45
+ },
46
+ }),
47
+ )
48
+ const elapsed = () =>
49
+ held ?? (started ? origin + delay + animator.getCurrentPlayTime() : origin)
50
+ const control = (
51
+ position: number,
52
+ paused: boolean,
53
+ command: PlaybackCommand = 'seek',
54
+ ) => {
55
+ if (stopped) return
56
+ if (command === 'pause') {
57
+ held = position
58
+ animator.pause()
59
+ return
60
+ }
61
+ if (command === 'play') {
62
+ held = undefined
63
+ animator.resume()
64
+ return
65
+ }
66
+ suppressed = true
67
+ animator.cancel()
68
+ started = false
69
+ origin = Math.min(position, track.delay)
70
+ delay = Math.max(0, track.delay - position)
71
+ animator.setStartDelay(delay)
72
+ held = paused ? position : undefined
73
+ suppressed = false
74
+ animator.start()
75
+ if (paused) animator.pause()
76
+ if (position >= track.delay)
77
+ animator.setCurrentPlayTime(Math.max(0, position - track.delay))
78
+ }
79
+ animator.start()
80
+ return {
81
+ elapsed,
82
+ control,
83
+ cancel() {
84
+ if (stopped) return
85
+ stopped = true
86
+ onRelease?.()
87
+ animator.cancel()
88
+ },
89
+ }
90
+ }
91
+
92
+ /** Share one native clock among active animations. Pause it when every owner
93
+ * is paused, and dispose it when the last owner settles. No frame callbacks. */
94
+ export function androidClockFactory() {
95
+ let animator: android.animation.ValueAnimator | undefined
96
+ let owners = 0,
97
+ running = 0,
98
+ accumulated = 0
99
+ const now = () => accumulated + (animator?.getCurrentPlayTime() ?? 0)
100
+ const start = () => {
101
+ if (animator) return
102
+ const values = Array.create('float', 2)
103
+ values[0] = 0
104
+ values[1] = 1
105
+ animator = android.animation.ValueAnimator.ofFloat(values)
106
+ animator.setDuration(60_000)
107
+ animator.setRepeatCount(android.animation.ValueAnimator.INFINITE)
108
+ animator.start()
109
+ }
110
+ const stop = () => {
111
+ accumulated = now()
112
+ animator?.cancel()
113
+ animator = undefined
114
+ }
115
+ return (_duration: number): PlaybackClock => {
116
+ start()
117
+ owners++
118
+ running++
119
+ let offset = 0,
120
+ anchor = now(),
121
+ paused = false,
122
+ stopped = false
123
+ const time = () => offset + (paused || stopped ? 0 : now() - anchor)
124
+ const pause = () => {
125
+ if (paused || stopped) return
126
+ offset = time()
127
+ paused = true
128
+ // getCurrentPlayTime keeps counting while a ValueAnimator is paused and
129
+ // corrects itself only on its next frame. Start a fresh marker on resume
130
+ // so command-time reads cannot jump forward and then backward.
131
+ if (--running === 0) stop()
132
+ }
133
+ return {
134
+ time,
135
+ pause,
136
+ play() {
137
+ if (!paused || stopped) return
138
+ if (running++ === 0) start()
139
+ anchor = now()
140
+ paused = false
141
+ },
142
+ seek(ms) {
143
+ offset = ms
144
+ anchor = now()
145
+ },
146
+ cancel() {
147
+ if (stopped) return
148
+ pause()
149
+ stopped = true
150
+ if (--owners === 0) {
151
+ stop()
152
+ accumulated = 0
153
+ }
154
+ },
155
+ }
156
+ }
157
+ }