@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.10

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 (302) hide show
  1. package/README.md +367 -18
  2. package/dist/animate.d.ts +19 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +186 -14
  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/config.d.ts +31 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +73 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/decay.d.ts +11 -0
  15. package/dist/decay.d.ts.map +1 -0
  16. package/dist/decay.js +22 -0
  17. package/dist/decay.js.map +1 -0
  18. package/dist/decoration.d.ts +18 -0
  19. package/dist/decoration.d.ts.map +1 -0
  20. package/dist/decoration.js +155 -0
  21. package/dist/decoration.js.map +1 -0
  22. package/dist/drag-input.d.ts +15 -0
  23. package/dist/drag-input.d.ts.map +1 -0
  24. package/dist/drag-input.js +66 -0
  25. package/dist/drag-input.js.map +1 -0
  26. package/dist/drag-physics.d.ts +20 -0
  27. package/dist/drag-physics.d.ts.map +1 -0
  28. package/dist/drag-physics.js +75 -0
  29. package/dist/drag-physics.js.map +1 -0
  30. package/dist/drag.d.ts +58 -0
  31. package/dist/drag.d.ts.map +1 -0
  32. package/dist/drag.js +324 -0
  33. package/dist/drag.js.map +1 -0
  34. package/dist/driver/android-color.d.ts +20 -0
  35. package/dist/driver/android-color.d.ts.map +1 -0
  36. package/dist/driver/android-color.js +136 -0
  37. package/dist/driver/android-color.js.map +1 -0
  38. package/dist/driver/android-decoration.d.ts +22 -0
  39. package/dist/driver/android-decoration.d.ts.map +1 -0
  40. package/dist/driver/android-decoration.js +139 -0
  41. package/dist/driver/android-decoration.js.map +1 -0
  42. package/dist/driver/android-playback.d.ts +13 -0
  43. package/dist/driver/android-playback.d.ts.map +1 -0
  44. package/dist/driver/android-playback.js +141 -0
  45. package/dist/driver/android-playback.js.map +1 -0
  46. package/dist/driver/android.d.ts +1 -1
  47. package/dist/driver/android.d.ts.map +1 -1
  48. package/dist/driver/android.js +165 -13
  49. package/dist/driver/android.js.map +1 -1
  50. package/dist/driver/index.d.ts +25 -2
  51. package/dist/driver/index.d.ts.map +1 -1
  52. package/dist/driver/index.js.map +1 -1
  53. package/dist/driver/ios-color.d.ts +16 -0
  54. package/dist/driver/ios-color.d.ts.map +1 -0
  55. package/dist/driver/ios-color.js +392 -0
  56. package/dist/driver/ios-color.js.map +1 -0
  57. package/dist/driver/ios-decoration.d.ts +17 -0
  58. package/dist/driver/ios-decoration.d.ts.map +1 -0
  59. package/dist/driver/ios-decoration.js +271 -0
  60. package/dist/driver/ios-decoration.js.map +1 -0
  61. package/dist/driver/ios-layout.d.ts +9 -0
  62. package/dist/driver/ios-layout.d.ts.map +1 -0
  63. package/dist/driver/ios-layout.js +52 -0
  64. package/dist/driver/ios-layout.js.map +1 -0
  65. package/dist/driver/ios-playback.d.ts +10 -0
  66. package/dist/driver/ios-playback.d.ts.map +1 -0
  67. package/dist/driver/ios-playback.js +47 -0
  68. package/dist/driver/ios-playback.js.map +1 -0
  69. package/dist/driver/ios-shadow.d.ts +6 -0
  70. package/dist/driver/ios-shadow.d.ts.map +1 -0
  71. package/dist/driver/ios-shadow.js +52 -0
  72. package/dist/driver/ios-shadow.js.map +1 -0
  73. package/dist/driver/ios.d.ts.map +1 -1
  74. package/dist/driver/ios.js +163 -28
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +75 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +293 -23
  79. package/dist/engine.js.map +1 -1
  80. package/dist/haptics.d.ts +21 -0
  81. package/dist/haptics.d.ts.map +1 -0
  82. package/dist/haptics.js +120 -0
  83. package/dist/haptics.js.map +1 -0
  84. package/dist/image-effect-native.d.ts +26 -0
  85. package/dist/image-effect-native.d.ts.map +1 -0
  86. package/dist/image-effect-native.js +73 -0
  87. package/dist/image-effect-native.js.map +1 -0
  88. package/dist/image-effect.d.ts +50 -0
  89. package/dist/image-effect.d.ts.map +1 -0
  90. package/dist/image-effect.js +290 -0
  91. package/dist/image-effect.js.map +1 -0
  92. package/dist/image-owner.d.ts +3 -0
  93. package/dist/image-owner.d.ts.map +1 -0
  94. package/dist/image-owner.js +11 -0
  95. package/dist/image-owner.js.map +1 -0
  96. package/dist/index.d.ts +43 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +20 -1
  99. package/dist/index.js.map +1 -1
  100. package/dist/interaction-surface.d.ts +12 -0
  101. package/dist/interaction-surface.d.ts.map +1 -0
  102. package/dist/interaction-surface.js +36 -0
  103. package/dist/interaction-surface.js.map +1 -0
  104. package/dist/items.d.ts +20 -0
  105. package/dist/items.d.ts.map +1 -0
  106. package/dist/items.js +109 -0
  107. package/dist/items.js.map +1 -0
  108. package/dist/keyboard.d.ts +41 -0
  109. package/dist/keyboard.d.ts.map +1 -0
  110. package/dist/keyboard.js +256 -0
  111. package/dist/keyboard.js.map +1 -0
  112. package/dist/label.d.ts +7 -0
  113. package/dist/label.d.ts.map +1 -0
  114. package/dist/label.js +60 -0
  115. package/dist/label.js.map +1 -0
  116. package/dist/layout.d.ts +13 -0
  117. package/dist/layout.d.ts.map +1 -0
  118. package/dist/layout.js +348 -0
  119. package/dist/layout.js.map +1 -0
  120. package/dist/liquid-grid.d.ts +3 -0
  121. package/dist/liquid-grid.d.ts.map +1 -0
  122. package/dist/liquid-grid.js +24 -0
  123. package/dist/liquid-grid.js.map +1 -0
  124. package/dist/liquid.d.ts +18 -0
  125. package/dist/liquid.d.ts.map +1 -0
  126. package/dist/liquid.js +234 -0
  127. package/dist/liquid.js.map +1 -0
  128. package/dist/loader.d.ts +19 -0
  129. package/dist/loader.d.ts.map +1 -0
  130. package/dist/loader.js +173 -0
  131. package/dist/loader.js.map +1 -0
  132. package/dist/motion-scope.d.ts +39 -0
  133. package/dist/motion-scope.d.ts.map +1 -0
  134. package/dist/motion-scope.js +238 -0
  135. package/dist/motion-scope.js.map +1 -0
  136. package/dist/motion-states.d.ts +36 -0
  137. package/dist/motion-states.d.ts.map +1 -0
  138. package/dist/motion-states.js +165 -0
  139. package/dist/motion-states.js.map +1 -0
  140. package/dist/particles.d.ts +39 -0
  141. package/dist/particles.d.ts.map +1 -0
  142. package/dist/particles.js +265 -0
  143. package/dist/particles.js.map +1 -0
  144. package/dist/path.d.ts +12 -0
  145. package/dist/path.d.ts.map +1 -0
  146. package/dist/path.js +90 -0
  147. package/dist/path.js.map +1 -0
  148. package/dist/playback-group.d.ts +6 -0
  149. package/dist/playback-group.d.ts.map +1 -0
  150. package/dist/playback-group.js +141 -0
  151. package/dist/playback-group.js.map +1 -0
  152. package/dist/playback.d.ts +25 -0
  153. package/dist/playback.d.ts.map +1 -0
  154. package/dist/playback.js +35 -0
  155. package/dist/playback.js.map +1 -0
  156. package/dist/press.d.ts.map +1 -1
  157. package/dist/press.js +23 -7
  158. package/dist/press.js.map +1 -1
  159. package/dist/properties.d.ts +6 -3
  160. package/dist/properties.d.ts.map +1 -1
  161. package/dist/properties.js +26 -2
  162. package/dist/properties.js.map +1 -1
  163. package/dist/reveal.d.ts +25 -0
  164. package/dist/reveal.d.ts.map +1 -0
  165. package/dist/reveal.js +294 -0
  166. package/dist/reveal.js.map +1 -0
  167. package/dist/roll-diff.d.ts +22 -0
  168. package/dist/roll-diff.d.ts.map +1 -0
  169. package/dist/roll-diff.js +36 -0
  170. package/dist/roll-diff.js.map +1 -0
  171. package/dist/roll.d.ts +29 -0
  172. package/dist/roll.d.ts.map +1 -0
  173. package/dist/roll.js +442 -0
  174. package/dist/roll.js.map +1 -0
  175. package/dist/scroll-linked.d.ts +10 -0
  176. package/dist/scroll-linked.d.ts.map +1 -0
  177. package/dist/scroll-linked.js +58 -0
  178. package/dist/scroll-linked.js.map +1 -0
  179. package/dist/sequence.d.ts +10 -5
  180. package/dist/sequence.d.ts.map +1 -1
  181. package/dist/sequence.js +208 -14
  182. package/dist/sequence.js.map +1 -1
  183. package/dist/shimmer.d.ts +12 -0
  184. package/dist/shimmer.d.ts.map +1 -0
  185. package/dist/shimmer.js +161 -0
  186. package/dist/shimmer.js.map +1 -0
  187. package/dist/svg-path.d.ts +14 -0
  188. package/dist/svg-path.d.ts.map +1 -0
  189. package/dist/svg-path.js +281 -0
  190. package/dist/svg-path.js.map +1 -0
  191. package/dist/symbol-effect.d.ts +23 -0
  192. package/dist/symbol-effect.d.ts.map +1 -0
  193. package/dist/symbol-effect.js +187 -0
  194. package/dist/symbol-effect.js.map +1 -0
  195. package/dist/text-layout.d.ts +30 -0
  196. package/dist/text-layout.d.ts.map +1 -0
  197. package/dist/text-layout.js +185 -0
  198. package/dist/text-layout.js.map +1 -0
  199. package/dist/text.d.ts +17 -0
  200. package/dist/text.d.ts.map +1 -0
  201. package/dist/text.js +263 -0
  202. package/dist/text.js.map +1 -0
  203. package/dist/track.d.ts +3 -1
  204. package/dist/track.d.ts.map +1 -1
  205. package/dist/track.js +266 -13
  206. package/dist/track.js.map +1 -1
  207. package/dist/transition.d.ts +17 -1
  208. package/dist/transition.d.ts.map +1 -1
  209. package/dist/transition.js +41 -6
  210. package/dist/transition.js.map +1 -1
  211. package/dist/types.d.ts +48 -1
  212. package/dist/types.d.ts.map +1 -1
  213. package/dist/vue/index.d.ts +95 -0
  214. package/dist/vue/index.d.ts.map +1 -0
  215. package/dist/vue/index.js +163 -0
  216. package/dist/vue/index.js.map +1 -0
  217. package/dist/zoom-native.d.ts +30 -0
  218. package/dist/zoom-native.d.ts.map +1 -0
  219. package/dist/zoom-native.js +56 -0
  220. package/dist/zoom-native.js.map +1 -0
  221. package/dist/zoomable.d.ts +48 -0
  222. package/dist/zoomable.d.ts.map +1 -0
  223. package/dist/zoomable.js +249 -0
  224. package/dist/zoomable.js.map +1 -0
  225. package/package.json +29 -5
  226. package/platforms/android/include.gradle +7 -0
  227. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  228. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  229. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  230. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  235. package/platforms/android/proguard-rules.pro +19 -0
  236. package/platforms/ios/build.xcconfig +1 -0
  237. package/platforms/ios/src/JoltImageEffect.h +25 -0
  238. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  239. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  240. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  241. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  242. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  243. package/platforms/ios/src/module.modulemap +6 -0
  244. package/src/android-plugin.d.ts +155 -0
  245. package/src/animate.ts +249 -36
  246. package/src/color.ts +101 -0
  247. package/src/config.ts +84 -0
  248. package/src/decay.ts +45 -0
  249. package/src/decoration.ts +180 -0
  250. package/src/drag-input.ts +74 -0
  251. package/src/drag-physics.ts +110 -0
  252. package/src/drag.ts +395 -0
  253. package/src/driver/android-color.ts +170 -0
  254. package/src/driver/android-decoration.ts +182 -0
  255. package/src/driver/android-playback.ts +157 -0
  256. package/src/driver/android.ts +208 -20
  257. package/src/driver/index.ts +37 -2
  258. package/src/driver/ios-color.ts +441 -0
  259. package/src/driver/ios-decoration.ts +332 -0
  260. package/src/driver/ios-layout.ts +63 -0
  261. package/src/driver/ios-playback.ts +51 -0
  262. package/src/driver/ios-shadow.ts +50 -0
  263. package/src/driver/ios.ts +193 -33
  264. package/src/engine.ts +321 -27
  265. package/src/haptics.ts +148 -0
  266. package/src/image-effect-native.ts +129 -0
  267. package/src/image-effect.ts +336 -0
  268. package/src/image-owner.ts +12 -0
  269. package/src/index.ts +59 -2
  270. package/src/interaction-surface.ts +41 -0
  271. package/src/ios-plugin.d.ts +81 -0
  272. package/src/items.ts +140 -0
  273. package/src/keyboard.ts +291 -0
  274. package/src/label.ts +76 -0
  275. package/src/layout.ts +408 -0
  276. package/src/liquid-grid.ts +28 -0
  277. package/src/liquid.ts +287 -0
  278. package/src/loader.ts +202 -0
  279. package/src/motion-scope.ts +279 -0
  280. package/src/motion-states.ts +215 -0
  281. package/src/particles.ts +323 -0
  282. package/src/path.ts +124 -0
  283. package/src/playback-group.ts +143 -0
  284. package/src/playback.ts +62 -0
  285. package/src/press.ts +26 -10
  286. package/src/properties.ts +32 -6
  287. package/src/reveal.ts +374 -0
  288. package/src/roll-diff.ts +57 -0
  289. package/src/roll.ts +515 -0
  290. package/src/scroll-linked.ts +73 -0
  291. package/src/sequence.ts +204 -14
  292. package/src/shimmer.ts +180 -0
  293. package/src/svg-path.ts +355 -0
  294. package/src/symbol-effect.ts +226 -0
  295. package/src/text-layout.ts +228 -0
  296. package/src/text.ts +279 -0
  297. package/src/track.ts +347 -12
  298. package/src/transition.ts +63 -6
  299. package/src/types.ts +49 -1
  300. package/src/vue/index.ts +231 -0
  301. package/src/zoom-native.ts +124 -0
  302. package/src/zoomable.ts +330 -0
package/src/roll.ts ADDED
@@ -0,0 +1,515 @@
1
+ import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Animation } from './engine.js'
4
+ import { reducedMotion } from './config.js'
5
+ import { completion, forgetCompletion, watchLabel } from './label.js'
6
+ import { diffFromEnd, type Grapheme } from './roll-diff.js'
7
+ import { textLayer, type TextPiece } from './text-layout.js'
8
+ import { createTrack } from './track.js'
9
+ import { resolveTransition } from './transition.js'
10
+ import type { Target } from './types.js'
11
+
12
+ export interface RollOptions {
13
+ /** Turns a number into the text to show. Defaults to `String`. */
14
+ format?: (value: number) => string
15
+ /** How long the spring feels, in milliseconds. Defaults to 600. */
16
+ duration?: number
17
+ /** Spring bounce, from -1 to 1. Defaults to 0.2. */
18
+ bounce?: number
19
+ /** Milliseconds between changed positions, counted from the right. Defaults to zero. */
20
+ stagger?: number
21
+ /** Milliseconds before the first position moves. Defaults to zero. */
22
+ delay?: number
23
+ /** `auto` rolls up to a higher number and down to a lower one. Defaults to `auto`. */
24
+ direction?: 'auto' | 'up' | 'down'
25
+ /** Tabular figures on the native label, so every digit takes the same width. Defaults to true. */
26
+ tabular?: boolean
27
+ }
28
+
29
+ interface Options {
30
+ duration: number
31
+ bounce: number
32
+ stagger: number
33
+ delay: number
34
+ direction: 1 | -1
35
+ tabular: boolean
36
+ }
37
+
38
+ const running = new WeakMap<Label, RollAnimation>()
39
+ const lastValues = new WeakMap<Label, number>()
40
+ const MAX_POSITIONS = 256
41
+ const directions = ['auto', 'up', 'down']
42
+ const paintKeys = () => [
43
+ NSForegroundColorAttributeName,
44
+ NSStrokeColorAttributeName,
45
+ NSUnderlineColorAttributeName,
46
+ NSStrikethroughColorAttributeName,
47
+ NSShadowAttributeName,
48
+ ]
49
+ // CoreText layout features: kNumberSpacingType and kMonospacedNumbersSelector.
50
+ const NUMBER_SPACING = 6
51
+ const MONOSPACED_NUMBERS = 0
52
+
53
+ /**
54
+ * Changes a Label's text and rolls the digits that differ, each one sliding
55
+ * out while its replacement slides in. The label holds the final text from the
56
+ * start; only the drawing moves.
57
+ *
58
+ * ```ts
59
+ * rollTo(balance, 1249.9, { format: (n) => n.toFixed(2) })
60
+ * ```
61
+ */
62
+ export function rollTo(
63
+ target: Target,
64
+ value: number | string,
65
+ options: RollOptions = {},
66
+ ): Animation {
67
+ const config = resolveOptions(options)
68
+ const view = toView(target)
69
+ if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
70
+ const text = typeof value === 'string' ? value : (options.format ?? String)(value)
71
+ if (typeof text !== 'string') throw new TypeError('Roll format must return a string.')
72
+ running.get(view)?.stop()
73
+ const previous = currentText(view)
74
+ const direction = resolveDirection(options.direction, view, value, previous)
75
+ if (typeof value === 'number') lastValues.set(view, value)
76
+ else lastValues.delete(view)
77
+ if (view.formattedText) view.formattedText = undefined as never
78
+ view.text = text
79
+ return new RollAnimation(view, previous, text, { ...config, direction })
80
+ }
81
+
82
+ function resolveOptions(options: RollOptions): Omit<Options, 'direction'> {
83
+ const duration = options.duration ?? 600
84
+ const bounce = options.bounce ?? 0.2
85
+ const stagger = options.stagger ?? 0
86
+ const delay = options.delay ?? 0
87
+ if (!Number.isFinite(duration) || duration <= 0)
88
+ throw new RangeError('Roll duration must be a positive finite number.')
89
+ if (!Number.isFinite(bounce) || bounce < -1 || bounce > 1)
90
+ throw new RangeError('Roll bounce must be between -1 and 1.')
91
+ if (!Number.isFinite(stagger) || stagger < 0)
92
+ throw new RangeError('Roll stagger must be a nonnegative finite number.')
93
+ if (!Number.isFinite(delay) || delay < 0)
94
+ throw new RangeError('Roll delay must be a nonnegative finite number.')
95
+ if (options.direction !== undefined && !directions.includes(options.direction))
96
+ throw new TypeError('Roll direction must be auto, up or down.')
97
+ if (options.format !== undefined && typeof options.format !== 'function')
98
+ throw new TypeError('Roll format must be a function.')
99
+ return { duration, bounce, stagger, delay, tabular: options.tabular ?? true }
100
+ }
101
+
102
+ function resolveDirection(
103
+ option: RollOptions['direction'],
104
+ view: Label,
105
+ value: number | string,
106
+ previous: string,
107
+ ): 1 | -1 {
108
+ if (option === 'up') return 1
109
+ if (option === 'down') return -1
110
+ if (typeof value !== 'number') return 1
111
+ const before = lastValues.get(view) ?? Number(previous)
112
+ return Number.isFinite(before) && value < before ? -1 : 1
113
+ }
114
+
115
+ function currentText(view: Label): string {
116
+ const native = view.nativeViewProtected as unknown
117
+ if (isIOS && native instanceof UILabel)
118
+ return native.attributedText?.string ?? native.text ?? ''
119
+ if (isAndroid && native instanceof android.widget.TextView)
120
+ return native.getText()?.toString() ?? ''
121
+ return view.text ?? ''
122
+ }
123
+
124
+ function springTrack(shift: number, { duration, bounce }: Options) {
125
+ return createTrack(
126
+ 'y',
127
+ [shift, 0],
128
+ resolveTransition({ type: 'spring', duration, bounce }, 'y', 2),
129
+ 0,
130
+ )
131
+ }
132
+
133
+ function fadeTrack({ duration }: Options) {
134
+ return createTrack(
135
+ 'opacity',
136
+ [0, 1],
137
+ resolveTransition(
138
+ { type: 'tween', duration: duration / 2, ease: 'easeOut' },
139
+ 'opacity',
140
+ 2,
141
+ ),
142
+ 0,
143
+ )
144
+ }
145
+
146
+ function pieceAt(pieces: readonly TextPiece[], grapheme: Grapheme) {
147
+ const index = pieces.findIndex(
148
+ ({ location, length }) =>
149
+ grapheme.start >= location && grapheme.start < location + length,
150
+ )
151
+ return index === -1 ? undefined : index
152
+ }
153
+
154
+ function layersOf(layer: CALayer | undefined): CALayer[] {
155
+ const sublayers = layer?.sublayers
156
+ if (!sublayers) return []
157
+ const layers: CALayer[] = []
158
+ for (let i = 0; i < sublayers.count; i++) layers.push(sublayers.objectAtIndex(i))
159
+ return layers
160
+ }
161
+
162
+ function isClear(text: NSAttributedString) {
163
+ if (!text.length) return true
164
+ const color = text.attributeAtIndexEffectiveRange(
165
+ NSForegroundColorAttributeName,
166
+ 0,
167
+ null,
168
+ ) as UIColor | null
169
+ return !color || color.isEqual(UIColor.clearColor)
170
+ }
171
+
172
+ function tabularFont(font: UIFont): UIFont {
173
+ const feature = NSDictionary.dictionaryWithObjectsForKeys(
174
+ [NUMBER_SPACING, MONOSPACED_NUMBERS],
175
+ [UIFontFeatureTypeIdentifierKey, UIFontFeatureSelectorIdentifierKey],
176
+ )
177
+ const descriptor = font.fontDescriptor.fontDescriptorByAddingAttributes(
178
+ NSDictionary.dictionaryWithObjectForKey(
179
+ [feature],
180
+ UIFontDescriptorFeatureSettingsAttribute,
181
+ ),
182
+ )
183
+ return UIFont.fontWithDescriptorSize(descriptor, font.pointSize)
184
+ }
185
+
186
+ class RollAnimation implements Animation {
187
+ readonly finished: Promise<boolean>
188
+ private resolve!: (finished: boolean) => void
189
+ private settled = false
190
+ private queued = false
191
+ private started = false
192
+ private unwatch: (() => void) | undefined
193
+ private native: UILabel | undefined
194
+ private container: CALayer | undefined
195
+ private pieces: CALayer[] = []
196
+ private attributed: NSAttributedString | null = null
197
+ private hidden: NSAttributedString | null = null
198
+ private color: UIColor | undefined
199
+ private shadow: UIColor | null = null
200
+ private delegate: NSObject | undefined
201
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
202
+ private roll: org.rafaelaugusto.jolt.NumberRoll | undefined
203
+
204
+ constructor(
205
+ private readonly view: Label,
206
+ private readonly previous: string,
207
+ private readonly next: string,
208
+ private readonly options: Options,
209
+ ) {
210
+ this.finished = new Promise((resolve) => {
211
+ this.resolve = resolve
212
+ })
213
+ running.set(view, this)
214
+ const { changes, removed } = diffFromEnd(previous, next)
215
+ if (!changes.length && !removed.length) {
216
+ this.end(true)
217
+ return
218
+ }
219
+ if (!isIOS && !isAndroid) {
220
+ this.end(false)
221
+ return
222
+ }
223
+ if (reducedMotion()) {
224
+ this.end(true)
225
+ return
226
+ }
227
+ view.on('loaded', this.schedule)
228
+ view.on('unloaded', this.stop)
229
+ if (isIOS) view.on('layoutChanged', this.layoutChanged)
230
+ this.unwatch = watchLabel(view, this.changed)
231
+ this.schedule()
232
+ }
233
+
234
+ then<A = boolean, B = never>(
235
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
236
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
237
+ ): Promise<A | B> {
238
+ return this.finished.then(onfulfilled, onrejected)
239
+ }
240
+ /** Cancels the roll. The label keeps its final text. */
241
+ stop = () => this.end(false)
242
+ private changed = () => this.end(false)
243
+ private motionChanged = () => {
244
+ if (reducedMotion()) this.end(true)
245
+ }
246
+ private layoutChanged = () => {
247
+ if (!this.container) return this.schedule()
248
+ const native = this.view.nativeViewProtected
249
+ if (native !== this.native || !this.native?.window) return this.stop()
250
+ this.container.frame = this.native.bounds
251
+ }
252
+ private schedule = () => {
253
+ if (this.settled || this.queued) return
254
+ this.queued = true
255
+ void Promise.resolve().then(() => {
256
+ this.queued = false
257
+ if (this.settled || this.started || !this.view.isLoaded) return
258
+ try {
259
+ this.start()
260
+ } catch {
261
+ this.end(false)
262
+ }
263
+ })
264
+ }
265
+ private start() {
266
+ if (reducedMotion()) return this.end(true)
267
+ if (this.view.visibility !== 'visible') return this.end(false)
268
+ if (isIOS) this.startIOS()
269
+ else this.startAndroid()
270
+ }
271
+ private startIOS() {
272
+ const native = this.view.nativeViewProtected as UILabel | undefined
273
+ if (!(native instanceof UILabel) || !native.window) return
274
+ if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
275
+ if (this.options.tabular) native.font = tabularFont(native.font)
276
+ const attributed = native.attributedText
277
+ let before: ReturnType<typeof textLayer>
278
+ try {
279
+ if (attributed) {
280
+ const copy =
281
+ NSMutableAttributedString.alloc().initWithAttributedString(attributed)
282
+ copy.replaceCharactersInRangeWithString(
283
+ { location: 0, length: copy.length },
284
+ this.previous,
285
+ )
286
+ native.attributedText = copy
287
+ } else native.text = this.previous
288
+ before = textLayer(native, 'letter')
289
+ } finally {
290
+ if (attributed) native.attributedText = attributed
291
+ else native.text = this.next
292
+ }
293
+ const after = textLayer(native, 'letter')
294
+ if (!before || !after) return this.end(false)
295
+ this.started = true
296
+
297
+ const { changes, removed } = diffFromEnd(this.previous, this.next)
298
+ const entering = new Map<number, number>()
299
+ const exiting = new Map<number, number>()
300
+ const assign = (
301
+ map: Map<number, number>,
302
+ pieces: readonly TextPiece[],
303
+ grapheme: Grapheme,
304
+ step: number,
305
+ ) => {
306
+ const index = pieceAt(pieces, grapheme)
307
+ if (index !== undefined && !map.has(index)) map.set(index, step)
308
+ }
309
+ changes.forEach(({ next, previous }, step) => {
310
+ assign(entering, after.pieces, next, step)
311
+ if (previous) assign(exiting, before.pieces, previous, step)
312
+ })
313
+ removed.forEach((grapheme, index) =>
314
+ assign(exiting, before.pieces, grapheme, changes.length + index),
315
+ )
316
+ if (!entering.size && !exiting.size) return this.end(true)
317
+ if (entering.size + exiting.size > MAX_POSITIONS) return this.end(false)
318
+ const last = Math.max(...entering.values(), ...exiting.values())
319
+
320
+ const { delay, stagger, direction } = this.options
321
+ const shift = native.font.lineHeight
322
+ const spring = springTrack(shift, this.options)
323
+ const fade = fadeTrack(this.options)
324
+ const container = CALayer.new()
325
+ container.name = 'jolt:roll'
326
+ container.frame = native.bounds
327
+ container.masksToBounds = true
328
+ const oldPieces = layersOf(before.layer)
329
+ const newPieces = layersOf(after.layer)
330
+ const start = CACurrentMediaTime()
331
+ this.native = native
332
+ this.container = container
333
+ CATransaction.begin()
334
+ CATransaction.setDisableActions(true)
335
+ try {
336
+ this.hide(native)
337
+ oldPieces.forEach((piece, index) => {
338
+ if (!exiting.has(index)) piece.removeFromSuperlayer()
339
+ })
340
+ if (before.layer) container.addSublayer(before.layer)
341
+ if (after.layer) container.addSublayer(after.layer)
342
+ native.layer.addSublayer(container)
343
+ const roll = (piece: CALayer, step: number, incoming: boolean) => {
344
+ if (!incoming) {
345
+ piece.opacity = 0
346
+ piece.transform = CATransform3DMakeTranslation(0, -direction * shift, 0)
347
+ }
348
+ const move = CAKeyframeAnimation.animationWithKeyPath('transform.translation.y')
349
+ move.values = NSArray.arrayWithArray(
350
+ spring.values.map((value) => direction * (incoming ? value : value - shift)),
351
+ )
352
+ move.keyTimes = NSArray.arrayWithArray(spring.times as number[])
353
+ move.duration = spring.duration / 1000
354
+ const opacity = CAKeyframeAnimation.animationWithKeyPath('opacity')
355
+ opacity.values = NSArray.arrayWithArray(
356
+ fade.values.map((value) => (incoming ? value : 1 - value)),
357
+ )
358
+ opacity.keyTimes = NSArray.arrayWithArray(fade.times as number[])
359
+ opacity.duration = fade.duration / 1000
360
+ const group = CAAnimationGroup.new()
361
+ group.animations = NSArray.arrayWithArray([opacity, move])
362
+ group.duration = Math.max(spring.duration, fade.duration) / 1000
363
+ group.beginTime = start + (delay + step * stagger) / 1000
364
+ group.fillMode = kCAFillModeBackwards
365
+ if (step === last && !this.delegate)
366
+ this.delegate = group.delegate = completion((finished) => this.end(finished))
367
+ piece.addAnimationForKey(group, 'jolt:roll')
368
+ this.pieces.push(piece)
369
+ }
370
+ for (const [index, step] of exiting) roll(oldPieces[index]!, step, false)
371
+ for (const [index, step] of entering) roll(newPieces[index]!, step, true)
372
+ this.observer = Application.ios.addNotificationObserver(
373
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
374
+ this.motionChanged,
375
+ )
376
+ } finally {
377
+ CATransaction.commit()
378
+ }
379
+ }
380
+ private hide(native: UILabel) {
381
+ this.color = native.textColor
382
+ // UILabel synthesizes an attributed string for plain text, so this is the usual path.
383
+ const attributed = native.attributedText
384
+ if (attributed) {
385
+ const hidden =
386
+ NSMutableAttributedString.alloc().initWithAttributedString(attributed)
387
+ const full = { location: 0, length: hidden.length }
388
+ const shadow = NSShadow.new()
389
+ shadow.shadowColor = UIColor.clearColor
390
+ for (const key of paintKeys())
391
+ hidden.addAttributeValueRange(
392
+ key,
393
+ key === NSShadowAttributeName ? shadow : UIColor.clearColor,
394
+ full,
395
+ )
396
+ this.attributed = attributed
397
+ this.hidden = hidden
398
+ native.attributedText = hidden
399
+ } else native.textColor = UIColor.clearColor
400
+ this.shadow = native.shadowColor
401
+ native.shadowColor = null
402
+ }
403
+ private show(native: UILabel) {
404
+ // UIKit hands back a normalized copy of the attributed text and keeps the
405
+ // clear color for text the app sets later, so "still ours" means the text
406
+ // is still clear. A color the app painted meanwhile wins.
407
+ const current = native.attributedText
408
+ if (!current || isClear(current)) {
409
+ if (current && this.attributed && current.string === this.hidden?.string) {
410
+ const restored =
411
+ NSMutableAttributedString.alloc().initWithAttributedString(current)
412
+ const full = { location: 0, length: restored.length }
413
+ for (const key of paintKeys()) {
414
+ restored.removeAttributeRange(key, full)
415
+ this.attributed.enumerateAttributeInRangeOptionsUsingBlock(
416
+ key,
417
+ full,
418
+ 0 as NSAttributedStringEnumerationOptions,
419
+ (value, range) => {
420
+ if (value != null) restored.addAttributeValueRange(key, value, range)
421
+ },
422
+ )
423
+ }
424
+ native.attributedText = restored
425
+ }
426
+ if (this.color && native.textColor?.isEqual(UIColor.clearColor))
427
+ native.textColor = this.color
428
+ }
429
+ if (!native.shadowColor) native.shadowColor = this.shadow
430
+ }
431
+ private startAndroid() {
432
+ const native = this.view.nativeViewProtected as android.widget.TextView | undefined
433
+ if (!(native instanceof android.widget.TextView)) return this.end(false)
434
+ const { changes } = diffFromEnd(this.previous, this.next)
435
+ if (!changes.length) return this.end(true)
436
+ if (changes.length > MAX_POSITIONS) return this.end(false)
437
+ this.started = true
438
+ const density = Utils.layout.getDisplayDensity()
439
+ const shift = native.getLineHeight()
440
+ const spring = springTrack(shift / density, this.options)
441
+ const fade = fadeTrack(this.options)
442
+ const starts = Array.create('int', changes.length)
443
+ const ends = Array.create('int', changes.length)
444
+ const previous = Array.create(java.lang.String, changes.length)
445
+ changes.forEach(({ next, previous: old }, index) => {
446
+ starts[index] = next.start
447
+ ends[index] = next.end
448
+ previous[index] = old?.text ?? ''
449
+ })
450
+ const times = Array.create('float', spring.times.length)
451
+ const values = Array.create('float', spring.values.length)
452
+ spring.times.forEach((time, index) => (times[index] = time))
453
+ spring.values.forEach((value, index) => (values[index] = value * density))
454
+ const { delay, stagger, direction, tabular } = this.options
455
+ let roll: org.rafaelaugusto.jolt.NumberRoll
456
+ try {
457
+ roll = new org.rafaelaugusto.jolt.NumberRoll(
458
+ native,
459
+ starts,
460
+ ends,
461
+ previous,
462
+ direction,
463
+ times,
464
+ values,
465
+ spring.duration,
466
+ Math.round(delay),
467
+ Math.round(stagger),
468
+ shift,
469
+ fade.duration,
470
+ tabular,
471
+ new org.rafaelaugusto.jolt.NumberRoll.Callback({
472
+ onEnd: (finished) => this.end(finished),
473
+ }),
474
+ )
475
+ } catch {
476
+ // A JS-only update cannot install the native plugin. The label shows the final text.
477
+ return this.end(false)
478
+ }
479
+ if (!roll.isSupported()) return this.end(false)
480
+ this.roll = roll
481
+ roll.start()
482
+ }
483
+ private end(finished: boolean) {
484
+ if (this.settled) return
485
+ this.settled = true
486
+ const { view, native, container } = this
487
+ view.off('loaded', this.schedule)
488
+ view.off('unloaded', this.stop)
489
+ view.off('layoutChanged', this.layoutChanged)
490
+ this.unwatch?.()
491
+ if (running.get(view) === this) running.delete(view)
492
+ if (this.observer)
493
+ Application.ios.removeNotificationObserver(
494
+ this.observer,
495
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
496
+ )
497
+ if (this.delegate) forgetCompletion(this.delegate)
498
+ if (native && container) {
499
+ CATransaction.begin()
500
+ CATransaction.setDisableActions(true)
501
+ try {
502
+ this.show(native)
503
+ container.removeFromSuperlayer()
504
+ for (const piece of this.pieces) piece.removeAnimationForKey('jolt:roll')
505
+ } finally {
506
+ CATransaction.commit()
507
+ }
508
+ }
509
+ this.roll?.cancel()
510
+ this.pieces = []
511
+ this.native = this.container = this.delegate = this.observer = this.roll = undefined
512
+ this.attributed = this.hidden = null
513
+ this.resolve(finished)
514
+ }
515
+ }
@@ -0,0 +1,73 @@
1
+ import { ScrollView, type EventData, type ScrollEventData } from '@nativescript/core'
2
+ import { animate, toView } from './animate.js'
3
+ import type { Keyframes, Target } from './types.js'
4
+
5
+ export interface ScrollLinkedOptions {
6
+ /** Scroll offsets in DIPs, mapped to progress 0 and 1. Either order is allowed. */
7
+ range: readonly [number, number]
8
+ /** Offset to read. Defaults to the vertical axis. */
9
+ axis?: 'x' | 'y'
10
+ }
11
+
12
+ /** Builds one paused animation and scrubs it synchronously on scroll. */
13
+ export function scrollLinked(
14
+ scrollView: Target,
15
+ target: Target,
16
+ keyframes: Keyframes,
17
+ options: ScrollLinkedOptions,
18
+ ): () => void {
19
+ const source = toView(scrollView)
20
+ if (!(source instanceof ScrollView))
21
+ throw new TypeError('scrollLinked requires a NativeScript ScrollView.')
22
+ const view = toView(target)
23
+ const range = options?.range
24
+ if (
25
+ !Array.isArray(range) ||
26
+ range.length !== 2 ||
27
+ !range.every(Number.isFinite) ||
28
+ !Number.isFinite(range[1]! - range[0]!) ||
29
+ range[0] === range[1]
30
+ )
31
+ throw new RangeError('Scroll range requires two distinct finite offsets in DIPs.')
32
+ const [start, end] = range as [number, number]
33
+ const axis = options.axis ?? 'y'
34
+ if (axis !== 'x' && axis !== 'y') throw new TypeError('Scroll axis must be "x" or "y".')
35
+
36
+ const animation = animate(view, keyframes, { duration: 1000, ease: 'linear' })
37
+ animation.pause()
38
+ let disposed = false
39
+ let previous: number | undefined
40
+
41
+ const update = (offset: number) => {
42
+ if (disposed || !Number.isFinite(offset)) return
43
+ const progress = Math.max(0, Math.min(1, (offset - start) / (end - start)))
44
+ if (progress === previous) return
45
+ animation.progress = progress
46
+ previous = progress
47
+ }
48
+ const current = () =>
49
+ update(axis === 'x' ? source.horizontalOffset : source.verticalOffset)
50
+ const onScroll = (event: EventData) => {
51
+ const scroll = event as ScrollEventData
52
+ update(axis === 'x' ? scroll.scrollX : scroll.scrollY)
53
+ }
54
+ const cleanup = () => {
55
+ if (disposed) return
56
+ disposed = true
57
+ source.off(ScrollView.scrollEvent, onScroll)
58
+ source.off('loaded', current)
59
+ source.off('unloaded', cleanup)
60
+ view.off('loaded', current)
61
+ view.off('unloaded', cleanup)
62
+ animation.stop()
63
+ }
64
+
65
+ source.on(ScrollView.scrollEvent, onScroll)
66
+ source.on('loaded', current)
67
+ source.on('unloaded', cleanup)
68
+ view.on('loaded', current)
69
+ view.on('unloaded', cleanup)
70
+ void animation.finished.then(cleanup)
71
+ current()
72
+ return cleanup
73
+ }