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

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 (184) hide show
  1. package/README.md +122 -5
  2. package/dist/animate.d.ts +4 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +48 -11
  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/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +7 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/index.d.ts +2 -0
  14. package/dist/driver/index.d.ts.map +1 -1
  15. package/dist/driver/index.js.map +1 -1
  16. package/dist/driver/ios-color.js +1 -1
  17. package/dist/driver/ios-color.js.map +1 -1
  18. package/dist/driver/ios-decoration.js +1 -1
  19. package/dist/driver/ios-decoration.js.map +1 -1
  20. package/dist/driver/ios-playback.d.ts +1 -1
  21. package/dist/driver/ios-playback.d.ts.map +1 -1
  22. package/dist/driver/ios-playback.js +14 -0
  23. package/dist/driver/ios-playback.js.map +1 -1
  24. package/dist/driver/ios.d.ts.map +1 -1
  25. package/dist/driver/ios.js +1 -0
  26. package/dist/driver/ios.js.map +1 -1
  27. package/dist/engine.d.ts +16 -1
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +33 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/image-effect-native.d.ts +26 -0
  32. package/dist/image-effect-native.d.ts.map +1 -0
  33. package/dist/image-effect-native.js +73 -0
  34. package/dist/image-effect-native.js.map +1 -0
  35. package/dist/image-effect.d.ts +50 -0
  36. package/dist/image-effect.d.ts.map +1 -0
  37. package/dist/image-effect.js +290 -0
  38. package/dist/image-effect.js.map +1 -0
  39. package/dist/image-owner.d.ts +3 -0
  40. package/dist/image-owner.d.ts.map +1 -0
  41. package/dist/image-owner.js +11 -0
  42. package/dist/image-owner.js.map +1 -0
  43. package/dist/index.d.ts +15 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +7 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/items.d.ts +20 -0
  48. package/dist/items.d.ts.map +1 -0
  49. package/dist/items.js +109 -0
  50. package/dist/items.js.map +1 -0
  51. package/dist/keyboard.d.ts +17 -0
  52. package/dist/keyboard.d.ts.map +1 -0
  53. package/dist/keyboard.js +116 -0
  54. package/dist/keyboard.js.map +1 -0
  55. package/dist/label.d.ts +7 -0
  56. package/dist/label.d.ts.map +1 -0
  57. package/dist/label.js +60 -0
  58. package/dist/label.js.map +1 -0
  59. package/dist/liquid.d.ts.map +1 -1
  60. package/dist/liquid.js +6 -1
  61. package/dist/liquid.js.map +1 -1
  62. package/dist/loader.d.ts.map +1 -1
  63. package/dist/loader.js +2 -1
  64. package/dist/loader.js.map +1 -1
  65. package/dist/particles.d.ts.map +1 -1
  66. package/dist/particles.js +4 -3
  67. package/dist/particles.js.map +1 -1
  68. package/dist/path.d.ts +12 -0
  69. package/dist/path.d.ts.map +1 -0
  70. package/dist/path.js +90 -0
  71. package/dist/path.js.map +1 -0
  72. package/dist/playback-group.d.ts.map +1 -1
  73. package/dist/playback-group.js +29 -1
  74. package/dist/playback-group.js.map +1 -1
  75. package/dist/press.d.ts.map +1 -1
  76. package/dist/press.js +3 -3
  77. package/dist/press.js.map +1 -1
  78. package/dist/reveal.d.ts.map +1 -1
  79. package/dist/reveal.js +3 -2
  80. package/dist/reveal.js.map +1 -1
  81. package/dist/roll-diff.d.ts +22 -0
  82. package/dist/roll-diff.d.ts.map +1 -0
  83. package/dist/roll-diff.js +36 -0
  84. package/dist/roll-diff.js.map +1 -0
  85. package/dist/roll.d.ts +29 -0
  86. package/dist/roll.d.ts.map +1 -0
  87. package/dist/roll.js +442 -0
  88. package/dist/roll.js.map +1 -0
  89. package/dist/scroll-linked.d.ts +10 -0
  90. package/dist/scroll-linked.d.ts.map +1 -0
  91. package/dist/scroll-linked.js +58 -0
  92. package/dist/scroll-linked.js.map +1 -0
  93. package/dist/sequence.d.ts +10 -5
  94. package/dist/sequence.d.ts.map +1 -1
  95. package/dist/sequence.js +202 -14
  96. package/dist/sequence.js.map +1 -1
  97. package/dist/shimmer.d.ts.map +1 -1
  98. package/dist/shimmer.js +2 -1
  99. package/dist/shimmer.js.map +1 -1
  100. package/dist/svg-path.d.ts +14 -0
  101. package/dist/svg-path.d.ts.map +1 -0
  102. package/dist/svg-path.js +281 -0
  103. package/dist/svg-path.js.map +1 -0
  104. package/dist/symbol-effect.d.ts.map +1 -1
  105. package/dist/symbol-effect.js +4 -3
  106. package/dist/symbol-effect.js.map +1 -1
  107. package/dist/text-layout.d.ts +8 -0
  108. package/dist/text-layout.d.ts.map +1 -1
  109. package/dist/text-layout.js +8 -2
  110. package/dist/text-layout.js.map +1 -1
  111. package/dist/text.d.ts +1 -1
  112. package/dist/text.d.ts.map +1 -1
  113. package/dist/text.js +60 -64
  114. package/dist/text.js.map +1 -1
  115. package/dist/track.d.ts +2 -0
  116. package/dist/track.d.ts.map +1 -1
  117. package/dist/track.js +62 -15
  118. package/dist/track.js.map +1 -1
  119. package/dist/types.d.ts +7 -1
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/vue/index.d.ts +89 -0
  122. package/dist/vue/index.d.ts.map +1 -0
  123. package/dist/vue/index.js +134 -0
  124. package/dist/vue/index.js.map +1 -0
  125. package/dist/zoom-native.d.ts +30 -0
  126. package/dist/zoom-native.d.ts.map +1 -0
  127. package/dist/zoom-native.js +56 -0
  128. package/dist/zoom-native.js.map +1 -0
  129. package/dist/zoomable.d.ts +48 -0
  130. package/dist/zoomable.d.ts.map +1 -0
  131. package/dist/zoomable.js +249 -0
  132. package/dist/zoomable.js.map +1 -0
  133. package/package.json +16 -3
  134. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  135. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  136. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  137. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  138. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  139. package/platforms/android/proguard-rules.pro +10 -0
  140. package/platforms/ios/build.xcconfig +1 -0
  141. package/platforms/ios/src/JoltImageEffect.h +25 -0
  142. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  143. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  144. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  145. package/platforms/ios/src/module.modulemap +5 -0
  146. package/src/android-plugin.d.ts +142 -0
  147. package/src/animate.ts +80 -30
  148. package/src/config.ts +46 -0
  149. package/src/driver/android.ts +14 -0
  150. package/src/driver/index.ts +2 -0
  151. package/src/driver/ios-color.ts +1 -1
  152. package/src/driver/ios-decoration.ts +1 -1
  153. package/src/driver/ios-playback.ts +15 -1
  154. package/src/driver/ios.ts +2 -0
  155. package/src/engine.ts +47 -2
  156. package/src/image-effect-native.ts +129 -0
  157. package/src/image-effect.ts +336 -0
  158. package/src/image-owner.ts +12 -0
  159. package/src/index.ts +20 -1
  160. package/src/ios-plugin.d.ts +59 -0
  161. package/src/items.ts +140 -0
  162. package/src/keyboard.ts +132 -0
  163. package/src/label.ts +76 -0
  164. package/src/liquid.ts +6 -1
  165. package/src/loader.ts +2 -1
  166. package/src/particles.ts +4 -4
  167. package/src/path.ts +124 -0
  168. package/src/playback-group.ts +34 -1
  169. package/src/press.ts +3 -5
  170. package/src/reveal.ts +3 -3
  171. package/src/roll-diff.ts +57 -0
  172. package/src/roll.ts +515 -0
  173. package/src/scroll-linked.ts +73 -0
  174. package/src/sequence.ts +199 -14
  175. package/src/shimmer.ts +2 -1
  176. package/src/svg-path.ts +355 -0
  177. package/src/symbol-effect.ts +4 -3
  178. package/src/text-layout.ts +14 -2
  179. package/src/text.ts +62 -81
  180. package/src/track.ts +89 -24
  181. package/src/types.ts +8 -1
  182. package/src/vue/index.ts +189 -0
  183. package/src/zoom-native.ts +124 -0
  184. package/src/zoomable.ts +330 -0
@@ -1,4 +1,10 @@
1
- import { arrivalOf, setArrival, type ViewAnimation } from './engine.js'
1
+ import {
2
+ arrivalOf,
3
+ arrivalTimingOf,
4
+ setArrival,
5
+ setArrivalTiming,
6
+ type ViewAnimation,
7
+ } from './engine.js'
2
8
  import { getDriver } from './driver/index.js'
3
9
  import { createClock, type PlaybackClock } from './playback.js'
4
10
  import { validateProgress, type PlaybackAnimation } from './playback.js'
@@ -104,6 +110,33 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
104
110
  then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
105
111
  }
106
112
  setArrival(animation, Promise.all(children.map(arrivalOf)))
113
+ const listeners = new Set<() => void>()
114
+ const timings = children.map((child) => arrivalTimingOf(child)!)
115
+ const notify = () => {
116
+ for (const callback of [...listeners]) callback()
117
+ }
118
+ const unsubscribe = timings.map((timing) => timing.subscribe(notify))
119
+ setArrivalTiming(animation, {
120
+ read() {
121
+ const states = timings.map((timing) => timing.read())
122
+ return {
123
+ arrived: settled || states.every((state) => state.arrived),
124
+ paused: paused || states.some((state) => state.paused && !state.arrived),
125
+ remaining: states.some((state) => !state.arrived && state.remaining === undefined)
126
+ ? undefined
127
+ : Math.max(0, ...states.map((state) => (state.arrived ? 0 : state.remaining!))),
128
+ }
129
+ },
130
+ subscribe(callback) {
131
+ listeners.add(callback)
132
+ return () => listeners.delete(callback)
133
+ },
134
+ })
135
+ void finished.then(() => {
136
+ unsubscribe.forEach((off) => off())
137
+ notify()
138
+ listeners.clear()
139
+ })
107
140
  ensureClock()
108
141
  finishIfIdle()
109
142
  return animation
package/src/press.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
2
2
  import { isColorProperty } from './color.js'
3
- import { animate, toView } from './animate.js'
4
- import { isProperty, readValue, type Property } from './properties.js'
3
+ import { animate, startingValues, toView } from './animate.js'
4
+ import { readValue } from './properties.js'
5
5
  import type { SpringName, Transition } from './transition.js'
6
6
  import type { AnimateOptions, Keyframes, Target } from './types.js'
7
7
 
@@ -23,9 +23,7 @@ export function press(
23
23
  options?: AnimateOptions | SpringName,
24
24
  ): () => void {
25
25
  const view = toView(target)
26
- const properties = Object.keys(pressed).flatMap((key): Property[] =>
27
- key === 'scale' ? ['scaleX', 'scaleY'] : isProperty(key) ? [key] : [],
28
- )
26
+ const properties = [...startingValues(pressed, options).keys()]
29
27
  let rest: Keyframes | undefined
30
28
 
31
29
  const onTouch = (event: EventData) => {
package/src/reveal.ts CHANGED
@@ -3,6 +3,7 @@ import { toView } from './animate.js'
3
3
  import { resolveEasing } from './easing.js'
4
4
  import type { Animation } from './engine.js'
5
5
  import type { Target } from './types.js'
6
+ import { reducedMotion } from './config.js'
6
7
 
7
8
  export interface RevealOptions {
8
9
  /** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
@@ -243,14 +244,13 @@ class CircularAnimation implements Animation {
243
244
  }
244
245
 
245
246
  private motionChanged = () => {
246
- if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
247
+ if (reducedMotion()) this.end(true)
247
248
  }
248
249
 
249
250
  private update() {
250
251
  if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
251
252
  return this.end(true, true)
252
- if (this.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
253
- return this.end(true)
253
+ if (this.duration === 0 || reducedMotion()) return this.end(true)
254
254
  if (this.waitingForLoad || !this.view.isLoaded) return
255
255
  const native = this.view.nativeViewProtected as MaskedView | undefined
256
256
  if (this.shape) {
@@ -0,0 +1,57 @@
1
+ export interface Grapheme {
2
+ text: string
3
+ /** UTF-16 offsets, the units NSString locations and Java char indices use. */
4
+ start: number
5
+ end: number
6
+ }
7
+
8
+ export interface RollChange {
9
+ /** The grapheme that rolls in, placed in the new text. */
10
+ next: Grapheme
11
+ /** The grapheme it replaces at the same position from the end, when there is one. */
12
+ previous: Grapheme | undefined
13
+ }
14
+
15
+ export interface RollDiff {
16
+ /** Positions that differ, from the last one backwards. */
17
+ changes: RollChange[]
18
+ /** Old graphemes to the left of the new text's first one; they roll out alone. */
19
+ removed: Grapheme[]
20
+ }
21
+
22
+ const segmenter =
23
+ typeof Intl !== 'undefined' && 'Segmenter' in Intl
24
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
25
+ : undefined
26
+
27
+ export function graphemes(text: string): Grapheme[] {
28
+ if (segmenter)
29
+ return [...segmenter.segment(text)].map(({ segment, index }) => ({
30
+ text: segment,
31
+ start: index,
32
+ end: index + segment.length,
33
+ }))
34
+ const pieces: Grapheme[] = []
35
+ let offset = 0
36
+ for (const character of text) {
37
+ pieces.push({ text: character, start: offset, end: offset + character.length })
38
+ offset += character.length
39
+ }
40
+ return pieces
41
+ }
42
+
43
+ /** Aligns both texts on their last grapheme, so the units digit stays put when the count grows. */
44
+ export function diffFromEnd(previous: string, next: string): RollDiff {
45
+ const before = graphemes(previous)
46
+ const after = graphemes(next)
47
+ const changes: RollChange[] = []
48
+ const removed: Grapheme[] = []
49
+ const count = Math.max(before.length, after.length)
50
+ for (let offset = 1; offset <= count; offset++) {
51
+ const old = before[before.length - offset]
52
+ const piece = after[after.length - offset]
53
+ if (!piece) removed.push(old!)
54
+ else if (!old || old.text !== piece.text) changes.push({ next: piece, previous: old })
55
+ }
56
+ return { changes, removed }
57
+ }
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
+ }