@rafaelaugusto/jolt 0.1.0-beta.5 → 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 (150) hide show
  1. package/README.md +110 -3
  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/index.d.ts +11 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/items.d.ts +20 -0
  36. package/dist/items.d.ts.map +1 -0
  37. package/dist/items.js +109 -0
  38. package/dist/items.js.map +1 -0
  39. package/dist/keyboard.d.ts +17 -0
  40. package/dist/keyboard.d.ts.map +1 -0
  41. package/dist/keyboard.js +116 -0
  42. package/dist/keyboard.js.map +1 -0
  43. package/dist/label.d.ts +7 -0
  44. package/dist/label.d.ts.map +1 -0
  45. package/dist/label.js +60 -0
  46. package/dist/label.js.map +1 -0
  47. package/dist/liquid.d.ts.map +1 -1
  48. package/dist/liquid.js +2 -1
  49. package/dist/liquid.js.map +1 -1
  50. package/dist/loader.d.ts.map +1 -1
  51. package/dist/loader.js +2 -1
  52. package/dist/loader.js.map +1 -1
  53. package/dist/particles.d.ts.map +1 -1
  54. package/dist/particles.js +4 -3
  55. package/dist/particles.js.map +1 -1
  56. package/dist/path.d.ts +12 -0
  57. package/dist/path.d.ts.map +1 -0
  58. package/dist/path.js +90 -0
  59. package/dist/path.js.map +1 -0
  60. package/dist/playback-group.d.ts.map +1 -1
  61. package/dist/playback-group.js +29 -1
  62. package/dist/playback-group.js.map +1 -1
  63. package/dist/press.d.ts.map +1 -1
  64. package/dist/press.js +3 -3
  65. package/dist/press.js.map +1 -1
  66. package/dist/reveal.d.ts.map +1 -1
  67. package/dist/reveal.js +3 -2
  68. package/dist/reveal.js.map +1 -1
  69. package/dist/roll-diff.d.ts +22 -0
  70. package/dist/roll-diff.d.ts.map +1 -0
  71. package/dist/roll-diff.js +36 -0
  72. package/dist/roll-diff.js.map +1 -0
  73. package/dist/roll.d.ts +29 -0
  74. package/dist/roll.d.ts.map +1 -0
  75. package/dist/roll.js +442 -0
  76. package/dist/roll.js.map +1 -0
  77. package/dist/scroll-linked.d.ts +10 -0
  78. package/dist/scroll-linked.d.ts.map +1 -0
  79. package/dist/scroll-linked.js +58 -0
  80. package/dist/scroll-linked.js.map +1 -0
  81. package/dist/sequence.d.ts +10 -5
  82. package/dist/sequence.d.ts.map +1 -1
  83. package/dist/sequence.js +202 -14
  84. package/dist/sequence.js.map +1 -1
  85. package/dist/shimmer.d.ts.map +1 -1
  86. package/dist/shimmer.js +2 -1
  87. package/dist/shimmer.js.map +1 -1
  88. package/dist/svg-path.d.ts +14 -0
  89. package/dist/svg-path.d.ts.map +1 -0
  90. package/dist/svg-path.js +281 -0
  91. package/dist/svg-path.js.map +1 -0
  92. package/dist/symbol-effect.d.ts.map +1 -1
  93. package/dist/symbol-effect.js +4 -3
  94. package/dist/symbol-effect.js.map +1 -1
  95. package/dist/text-layout.d.ts +8 -0
  96. package/dist/text-layout.d.ts.map +1 -1
  97. package/dist/text-layout.js +8 -2
  98. package/dist/text-layout.js.map +1 -1
  99. package/dist/text.d.ts +1 -1
  100. package/dist/text.d.ts.map +1 -1
  101. package/dist/text.js +60 -64
  102. package/dist/text.js.map +1 -1
  103. package/dist/track.d.ts +2 -0
  104. package/dist/track.d.ts.map +1 -1
  105. package/dist/track.js +62 -15
  106. package/dist/track.js.map +1 -1
  107. package/dist/types.d.ts +7 -1
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/vue/index.d.ts +89 -0
  110. package/dist/vue/index.d.ts.map +1 -0
  111. package/dist/vue/index.js +134 -0
  112. package/dist/vue/index.js.map +1 -0
  113. package/package.json +15 -3
  114. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  115. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  116. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  117. package/platforms/android/proguard-rules.pro +6 -0
  118. package/src/android-plugin.d.ts +64 -0
  119. package/src/animate.ts +80 -30
  120. package/src/config.ts +46 -0
  121. package/src/driver/android.ts +14 -0
  122. package/src/driver/index.ts +2 -0
  123. package/src/driver/ios-color.ts +1 -1
  124. package/src/driver/ios-decoration.ts +1 -1
  125. package/src/driver/ios-playback.ts +15 -1
  126. package/src/driver/ios.ts +2 -0
  127. package/src/engine.ts +47 -2
  128. package/src/index.ts +11 -1
  129. package/src/items.ts +140 -0
  130. package/src/keyboard.ts +132 -0
  131. package/src/label.ts +76 -0
  132. package/src/liquid.ts +2 -1
  133. package/src/loader.ts +2 -1
  134. package/src/particles.ts +4 -4
  135. package/src/path.ts +124 -0
  136. package/src/playback-group.ts +34 -1
  137. package/src/press.ts +3 -5
  138. package/src/reveal.ts +3 -3
  139. package/src/roll-diff.ts +57 -0
  140. package/src/roll.ts +515 -0
  141. package/src/scroll-linked.ts +73 -0
  142. package/src/sequence.ts +199 -14
  143. package/src/shimmer.ts +2 -1
  144. package/src/svg-path.ts +355 -0
  145. package/src/symbol-effect.ts +4 -3
  146. package/src/text-layout.ts +14 -2
  147. package/src/text.ts +62 -81
  148. package/src/track.ts +89 -24
  149. package/src/types.ts +8 -1
  150. package/src/vue/index.ts +189 -0
@@ -207,5 +207,19 @@ export function createAndroidDriver(): Driver {
207
207
  marker.cancel()
208
208
  }
209
209
  },
210
+
211
+ reducedMotion() {
212
+ // Remove animations sets the animator duration scale to zero.
213
+ if (android.os.Build.VERSION.SDK_INT >= 26)
214
+ return !android.animation.ValueAnimator.areAnimatorsEnabled()
215
+ const { Global } = android.provider.Settings
216
+ return (
217
+ Global.getFloat(
218
+ Utils.android.getApplicationContext().getContentResolver(),
219
+ Global.ANIMATOR_DURATION_SCALE,
220
+ 1,
221
+ ) === 0
222
+ )
223
+ },
210
224
  }
211
225
  }
@@ -34,6 +34,8 @@ export interface Driver {
34
34
  presented(view: View, property: Property): number | undefined
35
35
  /** Calls back after `ms` of animation time, respecting platform speed settings. */
36
36
  after(ms: number, callback: () => void): () => void
37
+ /** The platform's accessibility setting: Reduce Motion on iOS, Remove animations on Android. */
38
+ reducedMotion?(): boolean
37
39
  }
38
40
 
39
41
  export function after(ms: number, callback: () => void) {
@@ -203,7 +203,6 @@ export function playIOSColor(
203
203
  )
204
204
  animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
205
205
  animation.duration = Math.max(track.duration, 1) / 1000
206
- timing.configure(animation, layer)
207
206
  if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
208
207
  const maximum = Math.min(
209
208
  120,
@@ -220,6 +219,7 @@ export function playIOSColor(
220
219
  animation.autoreverses = track.reverse
221
220
  animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
222
221
  }
222
+ timing.configure(animation, layer)
223
223
  CATransaction.begin()
224
224
  if (!timing.paused && track.repeat !== Infinity)
225
225
  CATransaction.setCompletionBlock(() => {
@@ -234,13 +234,13 @@ export function playIOSDecoration(
234
234
  animation.values = NSArray.arrayWithArray(values)
235
235
  animation.keyTimes = NSArray.arrayWithArray(times)
236
236
  animation.duration = Math.max(1, track.duration) / 1000
237
- timing.configure(animation, layer)
238
237
  if (track.repeat > 0) {
239
238
  animation.autoreverses = track.reverse
240
239
  animation.repeatCount = track.reverse
241
240
  ? (track.repeat + 1) / 2
242
241
  : track.repeat + 1
243
242
  }
243
+ timing.configure(animation, layer)
244
244
  if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
245
245
  const max = Math.min(
246
246
  120,
@@ -21,7 +21,7 @@ export function iosPlayback(track: Track, startedAt: number) {
21
21
  anchor = CACurrentMediaTime() * 1000
22
22
  paused = hold
23
23
  },
24
- configure(animation: CAAnimation, layer: CALayer) {
24
+ configure(animation: CAKeyframeAnimation, layer: CALayer) {
25
25
  const now = CACurrentMediaTime()
26
26
  const position = elapsed()
27
27
  animation.speed = paused ? 0 : 1
@@ -32,6 +32,20 @@ export function iosPlayback(track: Track, startedAt: number) {
32
32
  )
33
33
  animation.fillMode = paused ? 'both' : kCAFillModeBackwards
34
34
  animation.removedOnCompletion = !paused && track.repeat !== Infinity
35
+ // With speed 0, CA folds an exact duration boundary back to the first
36
+ // frame. Hold the actual endpoint with a constant copy, also when a
37
+ // shorter property waits at its end while another one is being scrubbed.
38
+ if (paused && Number.isFinite(track.end) && position >= track.end) {
39
+ const values = animation.values!
40
+ const index = track.reverse && track.repeat % 2 === 1 ? 0 : values.count - 1
41
+ const value = values.objectAtIndex(index)
42
+ animation.values = NSArray.arrayWithArray([value, value])
43
+ animation.keyTimes = NSArray.arrayWithArray([0, 1])
44
+ animation.duration = 1
45
+ animation.repeatCount = 0
46
+ animation.autoreverses = false
47
+ animation.timeOffset = 0
48
+ }
35
49
  },
36
50
  }
37
51
  }
package/src/driver/ios.ts CHANGED
@@ -153,5 +153,7 @@ export function createIOSDriver(): Driver {
153
153
  : undefined,
154
154
 
155
155
  after,
156
+
157
+ reducedMotion: () => UIAccessibilityIsReduceMotionEnabled(),
156
158
  }
157
159
  }
package/src/engine.ts CHANGED
@@ -12,12 +12,13 @@ import { readValue, writeValue, type Property } from './properties.js'
12
12
  import { createTrack, type Track } from './track.js'
13
13
  import type { ResolvedTransition } from './transition.js'
14
14
  import { isColorProperty } from './color.js'
15
+ import type { PreparedPath, PathProperty } from './path.js'
15
16
 
16
17
  export interface Animation extends PromiseLike<boolean> {
17
18
  /**
18
19
  * Resolves with `true` once the animation played to the end, or with `false`
19
20
  * if it was stopped, superseded or skipped.
20
- * Never rejects.
21
+ * Playback cancellation never rejects; sequences can reject setup/callback errors.
21
22
  */
22
23
  readonly finished: Promise<boolean>
23
24
  /** Stops playback. Each effect documents whether it preserves or restores its visual state. */
@@ -30,6 +31,7 @@ export interface Plan {
30
31
  keyframes: readonly (number | null)[]
31
32
  transition: ResolvedTransition
32
33
  relative?: boolean
34
+ path?: PreparedPath
33
35
  }
34
36
 
35
37
  interface Entry {
@@ -49,6 +51,21 @@ const revisions = new WeakMap<View, Map<Property, symbol>>()
49
51
  const running = new WeakMap<View, Map<Property, Entry>>()
50
52
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
51
53
 
54
+ /** Internal forecast for starting a timeline step before another one arrives. */
55
+ export interface ArrivalTiming {
56
+ read(): { remaining: number | undefined; paused: boolean; arrived: boolean }
57
+ subscribe(callback: () => void): () => void
58
+ }
59
+ const arrivalTimings = new WeakMap<Animation, ArrivalTiming>()
60
+
61
+ export function arrivalTimingOf(animation: Animation) {
62
+ return arrivalTimings.get(animation)
63
+ }
64
+
65
+ export function setArrivalTiming(animation: Animation, timing: ArrivalTiming) {
66
+ arrivalTimings.set(animation, timing)
67
+ }
68
+
52
69
  /**
53
70
  * Resolves once the animation looks done, which for a spring is well before
54
71
  * `finished`. Sequences move on to the next step then.
@@ -83,6 +100,10 @@ export class ViewAnimation implements PlaybackAnimation {
83
100
  private idleListener: (() => void) | undefined
84
101
  private readyListener: (() => void) | undefined
85
102
  private readonly endless: boolean
103
+ private readonly timingListeners = new Set<() => void>()
104
+ private timingChanged() {
105
+ for (const callback of [...this.timingListeners]) callback()
106
+ }
86
107
 
87
108
  get paused() {
88
109
  return this._paused
@@ -136,6 +157,7 @@ export class ViewAnimation implements PlaybackAnimation {
136
157
  this.cancelArrival?.()
137
158
  this.haptics?.pause()
138
159
  if (this.started) this.controlTracks(this.time(), 'pause')
160
+ this.timingChanged()
139
161
  }
140
162
  play() {
141
163
  if (this.settled || !this._paused) return
@@ -148,6 +170,7 @@ export class ViewAnimation implements PlaybackAnimation {
148
170
  this.haptics?.play()
149
171
  if (this.pending === 0) this.becameIdle()
150
172
  }
173
+ this.timingChanged()
151
174
  }
152
175
  seekTime(time: number) {
153
176
  if (this.settled) return
@@ -173,6 +196,7 @@ export class ViewAnimation implements PlaybackAnimation {
173
196
  this.scheduleArrival()
174
197
  this.haptics?.seek()
175
198
  if (this.pending === 0) this.becameIdle()
199
+ this.timingChanged()
176
200
  }
177
201
  elapsed(entry: Entry, now: number) {
178
202
  return this.controlled
@@ -203,10 +227,12 @@ export class ViewAnimation implements PlaybackAnimation {
203
227
  if (remaining <= 0) {
204
228
  this.arrived = true
205
229
  this.arrive()
230
+ this.timingChanged()
206
231
  } else
207
232
  this.cancelArrival = getDriver().after(remaining, () => {
208
233
  this.arrived = true
209
234
  this.arrive()
235
+ this.timingChanged()
210
236
  })
211
237
  }
212
238
  private becameIdle() {
@@ -248,6 +274,17 @@ export class ViewAnimation implements PlaybackAnimation {
248
274
  )
249
275
  this.finished = new Promise((resolve) => (this.resolve = resolve))
250
276
  setArrival(this, new Promise<void>((resolve) => (this.arrive = resolve)))
277
+ setArrivalTiming(this, {
278
+ read: () => ({
279
+ remaining: this.started ? Math.max(0, this.arrival - this.time()) : undefined,
280
+ paused: this._paused,
281
+ arrived: this.arrived || this.settled,
282
+ }),
283
+ subscribe: (callback) => {
284
+ this.timingListeners.add(callback)
285
+ return () => this.timingListeners.delete(callback)
286
+ },
287
+ })
251
288
  if (view.isLoaded && view.nativeViewProtected) this.start(plans)
252
289
  else this.waitForLoad(plans)
253
290
  }
@@ -285,7 +322,7 @@ export class ViewAnimation implements PlaybackAnimation {
285
322
  const now = driver.now()
286
323
  const entries = entriesOf(this.view)
287
324
 
288
- const tracks = plans.map(({ property, keyframes, transition, relative }) => {
325
+ const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
289
326
  const previous = entries.get(property)
290
327
  let current: number
291
328
  let velocity = 0
@@ -300,6 +337,12 @@ export class ViewAnimation implements PlaybackAnimation {
300
337
  ? driver.presented(this.view, property)
301
338
  : undefined) ?? readValue(this.view, property)
302
339
  }
340
+ if (path)
341
+ return path.track(
342
+ property as PathProperty,
343
+ relative ? current : 0,
344
+ transition.delay,
345
+ )
303
346
  return createTrack(
304
347
  property,
305
348
  keyframes.map((value) => {
@@ -357,6 +400,7 @@ export class ViewAnimation implements PlaybackAnimation {
357
400
  } else if (this._paused) this.controlTracks(0, 'pause')
358
401
  this.scheduleArrival()
359
402
  this.readyListener?.()
403
+ this.timingChanged()
360
404
  if (this.pending === 0) this.becameIdle()
361
405
  }
362
406
 
@@ -388,6 +432,7 @@ export class ViewAnimation implements PlaybackAnimation {
388
432
  this.haptics = undefined
389
433
  this.arrive()
390
434
  this.resolve(this.complete)
435
+ this.timingChanged()
391
436
  }
392
437
  }
393
438
 
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  export { animate, decay, stop } from './animate.js'
2
2
  export { sequence } from './sequence.js'
3
- export type { Step } from './sequence.js'
3
+ export type { Step, SequenceOptions, SequencePosition } from './sequence.js'
4
4
  export { press } from './press.js'
5
+ export { scrollLinked } from './scroll-linked.js'
6
+ export type { ScrollLinkedOptions } from './scroll-linked.js'
7
+ export { animateItems } from './items.js'
8
+ export type { ItemsOptions } from './items.js'
9
+ export { followKeyboard } from './keyboard.js'
10
+ export type { KeyboardOptions } from './keyboard.js'
5
11
  export type { Haptic, HapticOptions, HapticStyle } from './haptics.js'
6
12
  export { confetti, particles } from './particles.js'
7
13
  export type { ConfettiOptions, ParticleOptions } from './particles.js'
@@ -15,12 +21,16 @@ export { reveal, conceal } from './reveal.js'
15
21
  export type { RevealOptions, ConcealOptions } from './reveal.js'
16
22
  export { animateText } from './text.js'
17
23
  export type { TextOptions } from './text.js'
24
+ export { rollTo } from './roll.js'
25
+ export type { RollOptions } from './roll.js'
18
26
  export { symbolEffect } from './symbol-effect.js'
19
27
  export type {
20
28
  SymbolEffect,
21
29
  SymbolEffectName,
22
30
  SymbolEffectOptions,
23
31
  } from './symbol-effect.js'
32
+ export { configure } from './config.js'
33
+ export type { JoltConfig } from './config.js'
24
34
  export { definePreset, presets } from './presets.js'
25
35
  export type { Preset, PresetName, Presets } from './presets.js'
26
36
  export { springs } from './transition.js'
package/src/items.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { ListView, View, type EventData, type ItemEventData } from '@nativescript/core'
2
+ import { animate, endingValues, startingValues, toView } from './animate.js'
3
+ import { getDriver } from './driver/index.js'
4
+ import { stopView } from './engine.js'
5
+ import type { PresetName } from './presets.js'
6
+ import { writeValue } from './properties.js'
7
+ import type { SpringName } from './transition.js'
8
+ import type { AnimateOptions, Keyframes, Target } from './types.js'
9
+
10
+ export type ItemsOptions = AnimateOptions & {
11
+ /** Animate each data item only the first time it shows up. Defaults to false. */
12
+ once?: boolean
13
+ }
14
+
15
+ interface Pending {
16
+ index: number
17
+ section: number | undefined
18
+ item: unknown
19
+ key: string
20
+ }
21
+
22
+ type Row = View & {
23
+ bindingContext?: unknown
24
+ _listViewItemIndex?: number
25
+ _listViewSectionIndex?: number
26
+ }
27
+
28
+ interface Source extends View {
29
+ items?: unknown
30
+ _getDataItem?(index: number): unknown
31
+ _getDataItemInSection?(section: number, index: number): unknown
32
+ }
33
+
34
+ /**
35
+ * Plays an animation on each row of a list as it comes on screen. Rows are
36
+ * recycled, so a row that is reused for another item starts over, or settles
37
+ * at the end values when that item was already shown and `once` is set.
38
+ * Returns a function that removes the effect.
39
+ *
40
+ * ```ts
41
+ * animateItems(listView, 'fadeInUp')
42
+ * animateItems(listView, 'fadeInUp', { once: true })
43
+ * ```
44
+ */
45
+ export function animateItems(
46
+ list: Target,
47
+ animation: Keyframes | PresetName,
48
+ options?: ItemsOptions | SpringName,
49
+ ): () => void {
50
+ const source = toView(list) as Source
51
+ const { once = false, ...rest } = typeof options === 'object' ? options : {}
52
+ if (rest.stagger !== undefined)
53
+ throw new TypeError('animateItems animates one row at a time; stagger has no effect.')
54
+ const transition: AnimateOptions | SpringName | undefined =
55
+ typeof options === 'string' ? options : rest
56
+ const properties = [...startingValues(animation, transition).keys()]
57
+ const ends = endingValues(animation, transition)
58
+
59
+ const rows = new Set<Row>()
60
+ const seenItems = new WeakSet<object>()
61
+ const seenKeys = new Set<string>()
62
+ const pending: Pending[] = []
63
+ let queued = false
64
+ let disposed = false
65
+
66
+ const settle = (row: Row) => {
67
+ stopView(row, properties)
68
+ for (const [property, value] of ends)
69
+ if (value !== null) writeValue(row, property, value)
70
+ getDriver().sync?.(row)
71
+ }
72
+ const show = (row: Row, { item, key }: Pending) => {
73
+ rows.add(row)
74
+ const tracked = typeof item === 'object' && item !== null
75
+ const seen = tracked ? seenItems.has(item) : seenKeys.has(key)
76
+ if (tracked) seenItems.add(item)
77
+ else seenKeys.add(key)
78
+ if (once && seen) settle(row)
79
+ else {
80
+ stopView(row, properties)
81
+ animate(row, animation, transition)
82
+ }
83
+ }
84
+ const flush = () => {
85
+ queued = false
86
+ if (disposed) return
87
+ const current = new Set<Row>()
88
+ const fresh: Row[] = []
89
+ source.eachChildView((child: Row) => {
90
+ current.add(child)
91
+ if (!rows.has(child)) fresh.push(child)
92
+ return true
93
+ })
94
+ for (const row of rows) if (!current.has(row)) rows.delete(row)
95
+ for (const entry of pending.splice(0)) {
96
+ const row =
97
+ fresh.find(
98
+ (candidate) =>
99
+ candidate._listViewItemIndex === entry.index &&
100
+ candidate._listViewSectionIndex === entry.section,
101
+ ) ??
102
+ fresh.find(
103
+ (candidate) =>
104
+ entry.item !== undefined && candidate.bindingContext === entry.item,
105
+ )
106
+ if (!row) continue
107
+ fresh.splice(fresh.indexOf(row), 1)
108
+ show(row, entry)
109
+ }
110
+ }
111
+ const onItemLoading = (event: EventData) => {
112
+ const { index, section, view } = event as ItemEventData
113
+ const item =
114
+ section === undefined
115
+ ? source._getDataItem?.(index)
116
+ : source._getDataItemInSection?.(section, index)
117
+ const entry: Pending = { index, section, item, key: `${section ?? ''}:${index}` }
118
+ if (view instanceof View) {
119
+ show(view, entry)
120
+ return
121
+ }
122
+ pending.push(entry)
123
+ if (queued) return
124
+ queued = true
125
+ void Promise.resolve().then(flush)
126
+ }
127
+ const cleanup = () => {
128
+ if (disposed) return
129
+ disposed = true
130
+ source.off(ListView.itemLoadingEvent, onItemLoading)
131
+ source.off('unloaded', cleanup)
132
+ pending.length = 0
133
+ for (const row of rows) settle(row)
134
+ rows.clear()
135
+ }
136
+
137
+ source.on(ListView.itemLoadingEvent, onItemLoading)
138
+ source.on('unloaded', cleanup)
139
+ return cleanup
140
+ }
@@ -0,0 +1,132 @@
1
+ import { Application, Utils, isAndroid, isIOS, type View } from '@nativescript/core'
2
+ import { stop, toView } from './animate.js'
3
+ import type { Target } from './types.js'
4
+
5
+ export interface KeyboardOptions {
6
+ /** DIPs kept between the view's bottom edge and the keyboard. Defaults to zero. */
7
+ offset?: number
8
+ }
9
+
10
+ const BEGIN_FROM_CURRENT_STATE = 4
11
+
12
+ /**
13
+ * Lifts the view by its overlap with the software keyboard, at the keyboard's
14
+ * own speed and curve, and lets it back down when the keyboard hides.
15
+ * Returns a function that removes the effect.
16
+ *
17
+ * ```ts
18
+ * const release = followKeyboard(composer)
19
+ * followKeyboard(sendButton, { offset: 12 })
20
+ * ```
21
+ */
22
+ export function followKeyboard(
23
+ target: Target,
24
+ options: KeyboardOptions = {},
25
+ ): () => void {
26
+ const view = toView(target)
27
+ const offset = options.offset ?? 0
28
+ if (!Number.isFinite(offset)) throw new RangeError('Keyboard offset must be finite.')
29
+ if (isIOS) return followIOS(view, offset)
30
+ if (isAndroid) return followAndroid(view, offset)
31
+ return () => {}
32
+ }
33
+
34
+ function followIOS(view: View, offset: number) {
35
+ let disposed = false
36
+ let current = 0
37
+ const onKeyboard = (notification: NSNotification) => {
38
+ if (disposed || !view.isLoaded) return
39
+ const native = view.nativeViewProtected as UIView | undefined
40
+ const window = native?.window
41
+ const info = notification.userInfo
42
+ if (!native || !window || !info) return
43
+ const end = info.objectForKey(UIKeyboardFrameEndUserInfoKey)?.CGRectValue as
44
+ CGRect | undefined
45
+ if (!end) return
46
+ const keyboard = window.convertRectFromWindow(end, null)
47
+ const hidden =
48
+ keyboard.size.height <= 0 || keyboard.origin.y >= window.bounds.size.height
49
+ const frame = native.convertRectToView(native.bounds, null)
50
+ const bottom = frame.origin.y + frame.size.height - view.translateY
51
+ const overlap = hidden ? 0 : Math.max(0, bottom + offset - keyboard.origin.y)
52
+ if (overlap === current) return
53
+ current = overlap
54
+ stop(view, ['y'])
55
+ const apply = () => {
56
+ view.translateY = overlap ? -overlap : 0
57
+ }
58
+ const duration = info.objectForKey(UIKeyboardAnimationDurationUserInfoKey)
59
+ ?.doubleValue as number | undefined
60
+ if (!duration) return apply()
61
+ const curve =
62
+ (info.objectForKey(UIKeyboardAnimationCurveUserInfoKey)?.integerValue as
63
+ number | undefined) ?? 0
64
+ // The keyboard's curve is private; shifted into the options it still applies.
65
+ UIView.animateWithDurationDelayOptionsAnimationsCompletion(
66
+ duration,
67
+ 0,
68
+ ((curve << 16) | BEGIN_FROM_CURRENT_STATE) as UIViewAnimationOptions,
69
+ apply,
70
+ () => {},
71
+ )
72
+ }
73
+ const observer = Application.ios.addNotificationObserver(
74
+ UIKeyboardWillChangeFrameNotification,
75
+ onKeyboard,
76
+ )
77
+ const cleanup = () => {
78
+ if (disposed) return
79
+ disposed = true
80
+ Application.ios.removeNotificationObserver(
81
+ observer,
82
+ UIKeyboardWillChangeFrameNotification,
83
+ )
84
+ view.off('unloaded', cleanup)
85
+ if (current) view.translateY = 0
86
+ }
87
+ view.on('unloaded', cleanup)
88
+ return cleanup
89
+ }
90
+
91
+ function followAndroid(view: View, offset: number) {
92
+ if (android.os.Build.VERSION.SDK_INT < 30) return () => {}
93
+ const density = Utils.layout.getDisplayDensity()
94
+ let disposed = false
95
+ let follower: org.rafaelaugusto.jolt.KeyboardFollower | undefined
96
+ const attach = () => {
97
+ if (disposed || follower) return
98
+ const native = view.nativeViewProtected as android.view.View | undefined
99
+ if (!native) return
100
+ try {
101
+ follower = new org.rafaelaugusto.jolt.KeyboardFollower(
102
+ native,
103
+ offset * density,
104
+ new org.rafaelaugusto.jolt.KeyboardFollower.Listener({
105
+ onStart: () => stop(view, ['y']),
106
+ onSettled: (translation) => {
107
+ view.translateY = translation / density
108
+ },
109
+ }),
110
+ )
111
+ } catch {
112
+ // A JS-only update cannot install the native plugin. Leave the platform's resize in place.
113
+ return
114
+ }
115
+ follower.attach()
116
+ }
117
+ const cleanup = () => {
118
+ if (disposed) return
119
+ disposed = true
120
+ view.off('loaded', attach)
121
+ view.off('unloaded', cleanup)
122
+ if (follower) {
123
+ follower.detach()
124
+ follower = undefined
125
+ view.translateY = 0
126
+ }
127
+ }
128
+ view.on('loaded', attach)
129
+ view.on('unloaded', cleanup)
130
+ if (view.isLoaded) attach()
131
+ return cleanup
132
+ }
package/src/label.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Label } from '@nativescript/core'
2
+
3
+ const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
4
+ let Delegate: typeof NSObject | undefined
5
+ const styleEvents = [
6
+ 'fontInternalChange',
7
+ 'colorChange',
8
+ 'textAlignmentChange',
9
+ 'textDecorationChange',
10
+ 'textTransformChange',
11
+ 'letterSpacingChange',
12
+ 'lineHeightChange',
13
+ 'textShadowChange',
14
+ 'textStrokeChange',
15
+ 'whiteSpaceChange',
16
+ 'textOverflowChange',
17
+ 'maxLinesChange',
18
+ 'paddingLeftChange',
19
+ 'paddingRightChange',
20
+ 'paddingTopChange',
21
+ 'paddingBottomChange',
22
+ 'visibilityChange',
23
+ 'directionChange',
24
+ 'borderLeftWidthChange',
25
+ 'borderRightWidthChange',
26
+ 'borderTopWidthChange',
27
+ 'borderBottomWidthChange',
28
+ ]
29
+
30
+ /** Reports text, formatted text and style changes of a Label until the returned function is called. */
31
+ export function watchLabel(view: Label, onChange: () => void): () => void {
32
+ const formatted = view.formattedText
33
+ view.on('textChange', onChange)
34
+ view.on('formattedTextChange', onChange)
35
+ formatted?.on('propertyChange', onChange)
36
+ for (const event of styleEvents) view.style.on(event, onChange)
37
+ return () => {
38
+ view.off('textChange', onChange)
39
+ view.off('formattedTextChange', onChange)
40
+ formatted?.off('propertyChange', onChange)
41
+ for (const event of styleEvents) view.style.off(event, onChange)
42
+ }
43
+ }
44
+
45
+ /** A CAAnimationDelegate that reports when its animation stops. Release it with `forgetCompletion`. */
46
+ export function completion(
47
+ callback: (finished: boolean) => void,
48
+ ): NSObject & CAAnimationDelegate {
49
+ if (!Delegate) {
50
+ const base = NSObject as typeof NSObject & {
51
+ extend(
52
+ methods: object,
53
+ options: { protocols: unknown[]; name: string },
54
+ ): typeof NSObject
55
+ }
56
+ Delegate = base.extend(
57
+ {
58
+ animationDidStopFinished(
59
+ this: NSObject,
60
+ _animation: CAAnimation,
61
+ finished: boolean,
62
+ ) {
63
+ callbacks.get(this)?.(finished)
64
+ },
65
+ },
66
+ { protocols: [CAAnimationDelegate], name: 'JoltLabelAnimationDelegate' },
67
+ )
68
+ }
69
+ const delegate = Delegate.new()
70
+ callbacks.set(delegate, callback)
71
+ return delegate
72
+ }
73
+
74
+ export function forgetCompletion(delegate: NSObject) {
75
+ callbacks.delete(delegate)
76
+ }
package/src/liquid.ts CHANGED
@@ -3,6 +3,7 @@ import { tintColorProperty } from '@nativescript/core/ui/image'
3
3
  import { toView } from './animate.js'
4
4
  import { liquidGrid } from './liquid-grid.js'
5
5
  import type { Target } from './types.js'
6
+ import { reducedMotion } from './config.js'
6
7
 
7
8
  export interface LiquidOptions {
8
9
  /** Amount of internal deformation, from 0 to 1. Defaults to 0.7. */
@@ -134,7 +135,7 @@ class Liquid implements LiquidEffect {
134
135
  !this.image.isLoaded ||
135
136
  this.image.visibility !== 'visible' ||
136
137
  this.image.tintColor ||
137
- UIAccessibilityIsReduceMotionEnabled()
138
+ reducedMotion()
138
139
  )
139
140
  return this.remove()
140
141
  const native = this.image.nativeViewProtected as UIImageView | undefined
package/src/loader.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Application, Color, isIOS, type View } from '@nativescript/core'
2
2
  import { toView } from './animate.js'
3
3
  import type { Target } from './types.js'
4
+ import { reducedMotion } from './config.js'
4
5
 
5
6
  export type LoaderStyle = 'dots' | 'bars' | 'ring'
6
7
 
@@ -160,7 +161,7 @@ class Loader {
160
161
  0,
161
162
  )
162
163
  }
163
- const reduced = UIAccessibilityIsReduceMotionEnabled()
164
+ const reduced = reducedMotion()
164
165
  item.opacity = style === 'ring' ? 0.35 : 1
165
166
  if (reduced && this.animating) {
166
167
  item.removeAnimationForKey(key)