@rafaelaugusto/jolt 0.1.0-beta.11 → 0.1.0-beta.12

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 (79) hide show
  1. package/README.md +38 -7
  2. package/dist/animate.d.ts +14 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +50 -34
  5. package/dist/animate.js.map +1 -1
  6. package/dist/drag.d.ts +17 -3
  7. package/dist/drag.d.ts.map +1 -1
  8. package/dist/drag.js +34 -11
  9. package/dist/drag.js.map +1 -1
  10. package/dist/easing.d.ts +1 -1
  11. package/dist/easing.d.ts.map +1 -1
  12. package/dist/easing.js +11 -0
  13. package/dist/easing.js.map +1 -1
  14. package/dist/engine.d.ts +18 -1
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +143 -36
  17. package/dist/engine.js.map +1 -1
  18. package/dist/gradient.d.ts.map +1 -1
  19. package/dist/gradient.js +10 -4
  20. package/dist/gradient.js.map +1 -1
  21. package/dist/index.d.ts +4 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts.map +1 -1
  26. package/dist/keyboard.js +63 -10
  27. package/dist/keyboard.js.map +1 -1
  28. package/dist/mixer.d.ts +49 -0
  29. package/dist/mixer.d.ts.map +1 -0
  30. package/dist/mixer.js +238 -0
  31. package/dist/mixer.js.map +1 -0
  32. package/dist/motion-scope.d.ts +4 -1
  33. package/dist/motion-scope.d.ts.map +1 -1
  34. package/dist/motion-scope.js +8 -3
  35. package/dist/motion-scope.js.map +1 -1
  36. package/dist/motion-states.d.ts +10 -5
  37. package/dist/motion-states.d.ts.map +1 -1
  38. package/dist/motion-states.js +55 -22
  39. package/dist/motion-states.js.map +1 -1
  40. package/dist/motion-value.d.ts +115 -0
  41. package/dist/motion-value.d.ts.map +1 -0
  42. package/dist/motion-value.js +437 -0
  43. package/dist/motion-value.js.map +1 -0
  44. package/dist/path.d.ts.map +1 -1
  45. package/dist/path.js +2 -2
  46. package/dist/path.js.map +1 -1
  47. package/dist/track.d.ts.map +1 -1
  48. package/dist/track.js +31 -12
  49. package/dist/track.js.map +1 -1
  50. package/dist/transition.d.ts +15 -4
  51. package/dist/transition.d.ts.map +1 -1
  52. package/dist/transition.js +46 -7
  53. package/dist/transition.js.map +1 -1
  54. package/dist/types.d.ts +5 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/vue/index.d.ts +28 -2
  57. package/dist/vue/index.d.ts.map +1 -1
  58. package/dist/vue/index.js +78 -2
  59. package/dist/vue/index.js.map +1 -1
  60. package/package.json +1 -1
  61. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
  62. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
  63. package/src/android-plugin.d.ts +27 -0
  64. package/src/animate.ts +99 -16
  65. package/src/drag.ts +67 -21
  66. package/src/easing.ts +14 -0
  67. package/src/engine.ts +158 -53
  68. package/src/gradient.ts +10 -4
  69. package/src/index.ts +18 -1
  70. package/src/keyboard.ts +83 -18
  71. package/src/mixer.ts +264 -0
  72. package/src/motion-scope.ts +12 -4
  73. package/src/motion-states.ts +79 -29
  74. package/src/motion-value.ts +563 -0
  75. package/src/path.ts +2 -1
  76. package/src/track.ts +37 -12
  77. package/src/transition.ts +69 -10
  78. package/src/types.ts +5 -0
  79. package/src/vue/index.ts +98 -1
package/src/engine.ts CHANGED
@@ -13,6 +13,7 @@ import { createTrack, type Track } from './track.js'
13
13
  import type { ResolvedTransition } from './transition.js'
14
14
  import { isColorProperty } from './color.js'
15
15
  import type { PreparedPath, PathProperty } from './path.js'
16
+ import { BASE, identity, MixHandle, mixFor, mixOf, mixesOf } from './mixer.js'
16
17
 
17
18
  export interface Animation extends PromiseLike<boolean> {
18
19
  /**
@@ -32,10 +33,17 @@ export interface Plan {
32
33
  transition: ResolvedTransition
33
34
  relative?: boolean
34
35
  path?: PreparedPath
36
+ /** A track that is already sampled, like a motion value mapped onto a property. */
37
+ track?: () => Track
38
+ /** A named layer composed with the property's base instead of replacing it. */
39
+ layer?: string
35
40
  }
36
41
 
37
42
  interface Entry {
38
43
  readonly track: Track
44
+ /** The property, or the property and its layer. */
45
+ readonly key: string
46
+ readonly layer?: string
39
47
  readonly startedAt: number
40
48
  readonly owner: ViewAnimation
41
49
  handle: unknown
@@ -48,7 +56,7 @@ interface Entry {
48
56
 
49
57
  const revisions = new WeakMap<View, Map<Property, symbol>>()
50
58
 
51
- const running = new WeakMap<View, Map<Property, Entry>>()
59
+ const running = new WeakMap<View, Map<string, Entry>>()
52
60
  const arrivals = new WeakMap<Animation, Promise<unknown>>()
53
61
 
54
62
  /** Internal forecast for starting a timeline step before another one arrives. */
@@ -188,7 +196,7 @@ export class ViewAnimation implements PlaybackAnimation {
188
196
  entry.done = false
189
197
  entry.finished = false
190
198
  this.pending++
191
- entriesOf(this.view).set(entry.track.property, entry)
199
+ entriesOf(this.view).set(entry.key, entry)
192
200
  this.playEntry(entry, position)
193
201
  }
194
202
  }
@@ -199,9 +207,9 @@ export class ViewAnimation implements PlaybackAnimation {
199
207
  this.timingChanged()
200
208
  }
201
209
  elapsed(entry: Entry, now: number) {
202
- return this.controlled
203
- ? this.time()
204
- : (getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt)
210
+ if (this.controlled) return this.time()
211
+ if (entry.handle instanceof MixHandle) return entry.handle.elapsed()
212
+ return getDriver().elapsed?.(entry.handle) ?? now - entry.startedAt
205
213
  }
206
214
  private time() {
207
215
  return this.clock?.time() ?? 0
@@ -209,8 +217,10 @@ export class ViewAnimation implements PlaybackAnimation {
209
217
  private controlTracks(position: number, command: PlaybackCommand = 'seek') {
210
218
  const driver = getDriver()
211
219
  for (const entry of this.entries)
212
- if (!entry.done)
213
- driver.control?.(this.view, entry.handle, position, this._paused, command)
220
+ if (entry.done) continue
221
+ else if (entry.handle instanceof MixHandle)
222
+ entry.handle.control(position, this._paused)
223
+ else driver.control?.(this.view, entry.handle, position, this._paused, command)
214
224
  }
215
225
  private scheduleArrival() {
216
226
  this.cancelArrival?.()
@@ -244,19 +254,31 @@ export class ViewAnimation implements PlaybackAnimation {
244
254
  const initial = entry.generation === 0
245
255
  const instance = ++entry.generation
246
256
  const driver = getDriver()
247
- entry.handle = driver.play(
248
- this.view,
249
- entry.track,
250
- initial ? entry.startedAt : driver.now() - elapsed,
251
- (finished = true) => {
252
- if (entry.done || instance !== entry.generation) return
253
- entry.done = true
254
- entry.finished = finished
255
- entry.completedValue = readValue(this.view, entry.track.property)
256
- forget(entry)
257
- this.ended(finished)
258
- },
259
- )
257
+ const startedAt = initial ? entry.startedAt : driver.now() - elapsed
258
+ const onEnd = (finished = true) => {
259
+ if (entry.done || instance !== entry.generation) return
260
+ entry.done = true
261
+ entry.finished = finished
262
+ entry.completedValue = readValue(this.view, entry.track.property)
263
+ forget(entry)
264
+ this.ended(finished)
265
+ }
266
+ const { property } = entry.track
267
+ const mix =
268
+ entry.layer !== undefined ? mixing(this.view, property) : mixOf(this.view, property)
269
+ entry.handle = mix
270
+ ? mix.adopt(entry.layer ?? BASE, entry.track, startedAt, onEnd)
271
+ : driver.play(this.view, entry.track, startedAt, onEnd)
272
+ }
273
+
274
+ /** Moves a natively playing entry into its property's mix, without restarting it. */
275
+ rehome(entry: Entry) {
276
+ if (entry.done || entry.handle instanceof MixHandle) return
277
+ const elapsed = this.elapsed(entry, getDriver().now())
278
+ const handle = entry.handle
279
+ entry.generation++
280
+ getDriver().stop(this.view, handle)
281
+ this.playEntry(entry, elapsed)
260
282
  }
261
283
  private unloaded = () => this.stop()
262
284
 
@@ -322,52 +344,78 @@ export class ViewAnimation implements PlaybackAnimation {
322
344
  const now = driver.now()
323
345
  const entries = entriesOf(this.view)
324
346
 
325
- const tracks = plans.map(({ property, keyframes, transition, relative, path }) => {
326
- const previous = entries.get(property)
347
+ const made = plans.map((plan) => {
348
+ const { property, keyframes, transition, relative, path, layer } = plan
349
+ const key = layer === undefined ? property : `${property}#${layer}`
350
+ const mixed = layer !== undefined || mixOf(this.view, property) !== undefined
351
+ const previous = entries.get(key)
327
352
  let current: number
328
353
  let velocity = 0
329
354
  if (previous) {
330
355
  const elapsed = elapsedOf(previous, now)
331
- current = driver.presented(this.view, property) ?? previous.track.valueAt(elapsed)
356
+ current =
357
+ (mixed ? undefined : driver.presented(this.view, property)) ??
358
+ previous.track.valueAt(elapsed)
332
359
  velocity = previous.owner.paused ? 0 : previous.track.velocityAt(elapsed)
333
360
  release(previous, now, false)
361
+ } else if (mixed) {
362
+ current =
363
+ mixOf(this.view, property)?.rest(layer ?? BASE) ??
364
+ (layer === undefined ? readValue(this.view, property) : identity(property))
334
365
  } else {
335
366
  current =
336
367
  (isColorProperty(property)
337
368
  ? driver.presented(this.view, property)
338
369
  : undefined) ?? readValue(this.view, property)
339
370
  }
340
- if (path)
341
- return path.track(
342
- property as PathProperty,
343
- relative ? current : 0,
344
- transition.delay,
371
+ const build = () => {
372
+ if (plan.track) return plan.track()
373
+ if (path)
374
+ return path.track(
375
+ property as PathProperty,
376
+ relative ? current : 0,
377
+ transition.delay,
378
+ )
379
+ return createTrack(
380
+ property,
381
+ keyframes.map((value) => {
382
+ if (value === null) return current
383
+ if (layer !== undefined || !relative || isColorProperty(property))
384
+ return value
385
+ return property === 'opacity' || property.startsWith('scale')
386
+ ? current * value
387
+ : current + value
388
+ }),
389
+ transition,
390
+ velocity,
345
391
  )
346
- return createTrack(
347
- property,
348
- keyframes.map((value) => {
349
- if (value === null) return current
350
- if (!relative || isColorProperty(property)) return value
351
- return property === 'opacity' || property.startsWith('scale')
352
- ? current * value
353
- : current + value
354
- }),
355
- transition,
356
- velocity,
357
- )
392
+ }
393
+ return { track: build(), key, layer }
358
394
  })
359
-
360
- for (const track of tracks) writeValue(this.view, track.property, track.to)
395
+ const tracks = made.map(({ track }) => track)
396
+
397
+ for (const { track, layer } of made) {
398
+ const mix =
399
+ layer !== undefined
400
+ ? mixing(this.view, track.property)
401
+ : mixOf(this.view, track.property)
402
+ if (!mix) writeValue(this.view, track.property, track.to)
403
+ else if (track.end === 0) mix.setRest(layer ?? BASE, track.to)
404
+ }
361
405
  driver.sync?.(this.view)
362
406
 
363
- for (const track of tracks) {
364
- let tokens = revisions.get(this.view)
365
- if (!tokens) revisions.set(this.view, (tokens = new Map()))
407
+ for (const { track, key, layer } of made) {
366
408
  const token = Symbol(track.property)
367
- tokens.set(track.property, token)
409
+ if (layer === undefined) {
410
+ let tokens = revisions.get(this.view)
411
+ if (!tokens) revisions.set(this.view, (tokens = new Map()))
412
+ tokens.set(track.property, token)
413
+ }
368
414
  if (track.end === 0) continue
369
415
  const entry: Entry = {
370
416
  track,
417
+ key,
418
+ layer,
371
419
  startedAt: now,
372
420
  owner: this,
373
421
  handle: undefined,
@@ -376,7 +424,7 @@ export class ViewAnimation implements PlaybackAnimation {
376
424
  generation: 0,
377
425
  token,
378
426
  }
379
- entries.set(track.property, entry)
427
+ entries.set(key, entry)
380
428
  this.entries.push(entry)
381
429
  this.pending++
382
430
  }
@@ -405,8 +453,8 @@ export class ViewAnimation implements PlaybackAnimation {
405
453
  }
406
454
 
407
455
  private waitForLoad(plans: readonly Plan[]) {
408
- for (const { property, keyframes, relative } of plans) {
409
- if (relative && !isColorProperty(property)) continue
456
+ for (const { property, keyframes, relative, layer } of plans) {
457
+ if (layer !== undefined || (relative && !isColorProperty(property))) continue
410
458
  const first = keyframes[0]
411
459
  if (first !== null && first !== undefined) writeValue(this.view, property, first)
412
460
  }
@@ -438,6 +486,8 @@ export class ViewAnimation implements PlaybackAnimation {
438
486
 
439
487
  /** Current native presentation when available, otherwise the active curve or model. */
440
488
  export function currentValue(view: View, property: Property): number {
489
+ const mix = mixOf(view, property)
490
+ if (mix) return mix.value()
441
491
  const entry = running.get(view)?.get(property)
442
492
  if (!entry) return readValue(view, property)
443
493
  const driver = getDriver()
@@ -465,19 +515,69 @@ export function captureValue(view: View, property: Property): () => number | und
465
515
  }
466
516
  }
467
517
 
468
- /** Stops whatever is animating these properties of the view, or all of them. */
518
+ /** Stops whatever is animating these properties of the view, or all of them, layers included. */
469
519
  export function stopView(view: View, properties?: readonly Property[]) {
470
520
  const tokens = revisions.get(view)
471
521
  for (const property of properties ?? tokens?.keys() ?? [])
472
522
  tokens?.set(property, Symbol(property))
523
+ for (const mix of mixesOf(view))
524
+ if (!properties || properties.includes(mix.property)) mix.freeze()
473
525
  const entries = running.get(view)
474
526
  if (!entries) return
475
527
  const now = getDriver().now()
476
- for (const [property, entry] of [...entries]) {
477
- if (!properties || properties.includes(property)) release(entry, now, true)
528
+ for (const entry of [...entries.values()]) {
529
+ if (!properties || properties.includes(entry.track.property))
530
+ release(entry, now, true)
531
+ }
532
+ }
533
+
534
+ /** Stops the regular animations of these properties and leaves their layers running. */
535
+ export function stopBase(view: View, properties: readonly Property[]) {
536
+ const tokens = revisions.get(view)
537
+ for (const property of properties) tokens?.set(property, Symbol(property))
538
+ const now = getDriver().now()
539
+ for (const property of properties) {
540
+ const entry = running.get(view)?.get(property)
541
+ if (entry) release(entry, now, true)
478
542
  }
479
543
  }
480
544
 
545
+ /** Stops one layer and takes away what it adds, also after it finished. */
546
+ export function stopLayer(view: View, layer: string, properties?: readonly Property[]) {
547
+ const now = getDriver().now()
548
+ for (const entry of [...(running.get(view)?.values() ?? [])])
549
+ if (
550
+ entry.layer === layer &&
551
+ (!properties || properties.includes(entry.track.property))
552
+ )
553
+ release(entry, now, true)
554
+ for (const mix of mixesOf(view))
555
+ if (!properties || properties.includes(mix.property))
556
+ mix.setRest(layer, identity(mix.property))
557
+ }
558
+
559
+ /** Writes a property's base value, under its layers when it has any. */
560
+ export function setBase(view: View, property: Property, value: number) {
561
+ const mix = mixOf(view, property)
562
+ if (mix) mix.setRest(BASE, value)
563
+ else writeValue(view, property, value)
564
+ }
565
+
566
+ /** A property's base value, without what its layers add. */
567
+ export function baseValue(view: View, property: Property): number {
568
+ return mixOf(view, property)?.rest(BASE) ?? readValue(view, property)
569
+ }
570
+
571
+ /** The property's mix, created on its first layer; a base playing natively moves into it. */
572
+ function mixing(view: View, property: Property) {
573
+ const existing = mixOf(view, property)
574
+ if (existing) return existing
575
+ const mix = mixFor(view, property)
576
+ const base = running.get(view)?.get(property)
577
+ if (base) base.owner.rehome(base)
578
+ return mix
579
+ }
580
+
481
581
  function entriesOf(view: View) {
482
582
  let entries = running.get(view)
483
583
  if (!entries) running.set(view, (entries = new Map()))
@@ -486,7 +586,7 @@ function entriesOf(view: View) {
486
586
 
487
587
  function forget(entry: Entry) {
488
588
  const entries = running.get(entry.owner.view)
489
- if (entries?.get(entry.track.property) === entry) entries.delete(entry.track.property)
589
+ if (entries?.get(entry.key) === entry) entries.delete(entry.key)
490
590
  }
491
591
 
492
592
  function release(entry: Entry, now: number, commit: boolean) {
@@ -495,6 +595,11 @@ function release(entry: Entry, now: number, commit: boolean) {
495
595
  const { property } = entry.track
496
596
  entry.done = true
497
597
  forget(entry)
598
+ if (entry.handle instanceof MixHandle) {
599
+ entry.handle.mix.detach(entry.layer ?? BASE, commit)
600
+ entry.owner.ended(false)
601
+ return
602
+ }
498
603
  if (commit) {
499
604
  const value =
500
605
  driver.presented(view, property) ?? entry.track.valueAt(elapsedOf(entry, now))
package/src/gradient.ts CHANGED
@@ -345,10 +345,16 @@ export function gradientPoints(
345
345
  const sin = Math.sin(radians)
346
346
  const cos = Math.cos(radians)
347
347
  if (type === 'linear') {
348
- const length = Math.abs(width * sin) + Math.abs(height * cos)
349
- const dx = width > 0 ? (sin * length) / (2 * width) : 0
350
- const dy = height > 0 ? (-cos * length) / (2 * height) : 0
351
- return { start: { x: 0.5 - dx, y: 0.5 - dy }, end: { x: 0.5 + dx, y: 0.5 + dy } }
348
+ // Core Animation keeps an axial gradient's bands square to its line in unit space, so
349
+ // on a box that isn't square the line leans to keep them square to `angle` on screen.
350
+ const ux = height > 0 ? sin / height : 0
351
+ const uy = width > 0 ? -cos / width : 0
352
+ const squared = ux * ux + uy * uy
353
+ const reach = squared > 0 ? (Math.abs(ux) + Math.abs(uy)) / (2 * squared) : 0
354
+ return {
355
+ start: { x: 0.5 - ux * reach, y: 0.5 - uy * reach },
356
+ end: { x: 0.5 + ux * reach, y: 0.5 + uy * reach },
357
+ }
352
358
  }
353
359
  const center = { x: frame.x, y: frame.y }
354
360
  const shorter = Math.min(width, height)
package/src/index.ts CHANGED
@@ -1,11 +1,27 @@
1
1
  export { animate, decay, set, stop } from './animate.js'
2
+ export { bind, motionValue, scrollValue } from './motion-value.js'
3
+ export type {
4
+ Bindings,
5
+ DerivedColor,
6
+ DerivedValue,
7
+ MapOptions,
8
+ MotionValue,
9
+ ScrollValue,
10
+ } from './motion-value.js'
2
11
  export { sequence } from './sequence.js'
3
12
  export type { Step, SequenceOptions, SequencePosition } from './sequence.js'
4
13
  export { press } from './press.js'
5
14
  export { interactionSurface } from './interaction-surface.js'
6
15
  export type { InteractionSurface } from './interaction-surface.js'
7
16
  export { drag } from './drag.js'
8
- export type { Drag, DragOptions, DragPoint, DragEvent, DragRelease } from './drag.js'
17
+ export type {
18
+ Drag,
19
+ DragOptions,
20
+ DragValueOptions,
21
+ DragPoint,
22
+ DragEvent,
23
+ DragRelease,
24
+ } from './drag.js'
9
25
  export { scrollLinked } from './scroll-linked.js'
10
26
  export type { ScrollLinkedOptions } from './scroll-linked.js'
11
27
  export { animateItems } from './items.js'
@@ -56,6 +72,7 @@ export type { Preset, PresetName, Presets } from './presets.js'
56
72
  export { origamiSpring, springs } from './transition.js'
57
73
  export type {
58
74
  OrigamiSpring,
75
+ RepeatDelayRange,
59
76
  Spring,
60
77
  SpringName,
61
78
  Timing,
package/src/keyboard.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Application, Utils, isAndroid, isIOS, type View } from '@nativescript/core'
2
- import { stop, toView } from './animate.js'
2
+ import { animate, stop, toView } from './animate.js'
3
3
  import type { EasingFunction, EasingName } from './easing.js'
4
- import type { Spring, Transition } from './transition.js'
4
+ import type { Spring, Transition, Tween } from './transition.js'
5
5
  import type { Target } from './types.js'
6
6
 
7
7
  export interface KeyboardOptions {
@@ -30,6 +30,12 @@ const iosKeyboard: Spring = {
30
30
  damping: 2 * Math.sqrt(3000),
31
31
  }
32
32
  const iosCurves: readonly EasingName[] = ['easeInOut', 'easeIn', 'easeOut', 'linear']
33
+ // Before Android 11 nothing reports the keyboard's timing. Measured on an Android 9 emulator,
34
+ // its window fades and slides into place in about 150 ms, decelerating, and leaves as quickly.
35
+ const LEGACY_DURATION = 150
36
+ const legacyShow: Tween = { duration: LEGACY_DURATION, ease: 'quadOut' }
37
+ const legacyHide: Tween = { duration: LEGACY_DURATION, ease: 'quadIn' }
38
+ const legacy = () => isAndroid && android.os.Build.VERSION.SDK_INT < 30
33
39
  const hidden: KeyboardState = Object.freeze({
34
40
  visible: false,
35
41
  height: 0,
@@ -109,9 +115,11 @@ function watchIOS() {
109
115
  }
110
116
 
111
117
  function watchAndroid() {
112
- if (android.os.Build.VERSION.SDK_INT < 30) return undefined
113
118
  const density = Utils.layout.getDisplayDensity()
114
- let observer: org.rafaelaugusto.jolt.KeyboardObserver | undefined
119
+ let observer:
120
+ | org.rafaelaugusto.jolt.KeyboardObserver
121
+ | org.rafaelaugusto.jolt.KeyboardFrame
122
+ | undefined
115
123
  let stopped = false
116
124
  const attach = () => {
117
125
  Application.off(Application.launchEvent, attach)
@@ -121,19 +129,31 @@ function watchAndroid() {
121
129
  const root = activity?.getWindow()?.getDecorView()
122
130
  if (!root) return
123
131
  try {
124
- observer = new org.rafaelaugusto.jolt.KeyboardObserver(
125
- root,
126
- new org.rafaelaugusto.jolt.KeyboardObserver.Listener({
127
- onChange: (height, duration, curve) =>
128
- update(
129
- height / density,
130
- duration,
131
- duration
132
- ? { duration, ease: curve ? sampled(curve) : 'linear' }
133
- : { duration: 0 },
134
- ),
135
- }),
136
- )
132
+ observer = legacy()
133
+ ? new org.rafaelaugusto.jolt.KeyboardFrame(
134
+ root,
135
+ new org.rafaelaugusto.jolt.KeyboardFrame.Listener({
136
+ onChange: (height) =>
137
+ update(
138
+ height / density,
139
+ LEGACY_DURATION,
140
+ height ? legacyShow : legacyHide,
141
+ ),
142
+ }),
143
+ )
144
+ : new org.rafaelaugusto.jolt.KeyboardObserver(
145
+ root,
146
+ new org.rafaelaugusto.jolt.KeyboardObserver.Listener({
147
+ onChange: (height, duration, curve) =>
148
+ update(
149
+ height / density,
150
+ duration,
151
+ duration
152
+ ? { duration, ease: curve ? sampled(curve) : 'linear' }
153
+ : { duration: 0 },
154
+ ),
155
+ }),
156
+ )
137
157
  } catch {
138
158
  // A JS-only update cannot install the native plugin.
139
159
  return
@@ -241,7 +261,7 @@ function followIOS(view: View, offset: number) {
241
261
  }
242
262
 
243
263
  function followAndroid(view: View, offset: number) {
244
- if (android.os.Build.VERSION.SDK_INT < 30) return () => {}
264
+ if (legacy()) return followLegacy(view, offset)
245
265
  const density = Utils.layout.getDisplayDensity()
246
266
  let disposed = false
247
267
  let follower: org.rafaelaugusto.jolt.KeyboardFollower | undefined
@@ -283,6 +303,51 @@ function followAndroid(view: View, offset: number) {
283
303
  return cleanup
284
304
  }
285
305
 
306
+ /** Android 7 to 10: no frame-by-frame progress, so the view animates with the keyboard's usual timing. */
307
+ function followLegacy(view: View, offset: number) {
308
+ const density = Utils.layout.getDisplayDensity()
309
+ let disposed = false
310
+ let follower: org.rafaelaugusto.jolt.KeyboardFrameFollower | undefined
311
+ const attach = () => {
312
+ if (disposed || follower) return
313
+ const native = view.nativeViewProtected as android.view.View | undefined
314
+ if (!native) return
315
+ try {
316
+ follower = new org.rafaelaugusto.jolt.KeyboardFrameFollower(
317
+ native,
318
+ offset * density,
319
+ new org.rafaelaugusto.jolt.KeyboardFrameFollower.Listener({
320
+ onMove: (from, to, keyboard) => {
321
+ stop(view, ['y'])
322
+ view.translateY = from / density
323
+ animate(view, { y: to / density }, keyboard ? legacyShow : legacyHide)
324
+ },
325
+ }),
326
+ )
327
+ } catch {
328
+ // A JS-only update cannot install the native plugin. Leave the platform's resize in place.
329
+ return
330
+ }
331
+ follower.attach()
332
+ }
333
+ const cleanup = () => {
334
+ if (disposed) return
335
+ disposed = true
336
+ view.off('loaded', attach)
337
+ view.off('unloaded', cleanup)
338
+ if (follower) {
339
+ follower.detach()
340
+ follower = undefined
341
+ stop(view, ['y'])
342
+ view.translateY = 0
343
+ }
344
+ }
345
+ view.on('loaded', attach)
346
+ view.on('unloaded', cleanup)
347
+ if (view.isLoaded) attach()
348
+ return cleanup
349
+ }
350
+
286
351
  /** The runtime hands NSNumber values over as plain numbers. */
287
352
  function numberIn(info: NSDictionary<unknown, unknown>, key: string): number {
288
353
  const value = info.objectForKey(key) as number | NSNumber | null | undefined