@rafaelaugusto/jolt 0.1.0-beta.10 → 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 (92) hide show
  1. package/README.md +55 -6
  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/driver/android-gradient.d.ts +5 -0
  11. package/dist/driver/android-gradient.d.ts.map +1 -0
  12. package/dist/driver/android-gradient.js +89 -0
  13. package/dist/driver/android-gradient.js.map +1 -0
  14. package/dist/driver/ios-gradient.d.ts +5 -0
  15. package/dist/driver/ios-gradient.d.ts.map +1 -0
  16. package/dist/driver/ios-gradient.js +109 -0
  17. package/dist/driver/ios-gradient.js.map +1 -0
  18. package/dist/easing.d.ts +1 -1
  19. package/dist/easing.d.ts.map +1 -1
  20. package/dist/easing.js +11 -0
  21. package/dist/easing.js.map +1 -1
  22. package/dist/engine.d.ts +18 -1
  23. package/dist/engine.d.ts.map +1 -1
  24. package/dist/engine.js +143 -36
  25. package/dist/engine.js.map +1 -1
  26. package/dist/gradient.d.ts +90 -0
  27. package/dist/gradient.d.ts.map +1 -0
  28. package/dist/gradient.js +253 -0
  29. package/dist/gradient.js.map +1 -0
  30. package/dist/index.d.ts +7 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/keyboard.d.ts.map +1 -1
  35. package/dist/keyboard.js +63 -10
  36. package/dist/keyboard.js.map +1 -1
  37. package/dist/mixer.d.ts +49 -0
  38. package/dist/mixer.d.ts.map +1 -0
  39. package/dist/mixer.js +238 -0
  40. package/dist/mixer.js.map +1 -0
  41. package/dist/motion-scope.d.ts +4 -1
  42. package/dist/motion-scope.d.ts.map +1 -1
  43. package/dist/motion-scope.js +8 -3
  44. package/dist/motion-scope.js.map +1 -1
  45. package/dist/motion-states.d.ts +18 -6
  46. package/dist/motion-states.d.ts.map +1 -1
  47. package/dist/motion-states.js +114 -26
  48. package/dist/motion-states.js.map +1 -1
  49. package/dist/motion-value.d.ts +115 -0
  50. package/dist/motion-value.d.ts.map +1 -0
  51. package/dist/motion-value.js +437 -0
  52. package/dist/motion-value.js.map +1 -0
  53. package/dist/path.d.ts.map +1 -1
  54. package/dist/path.js +2 -2
  55. package/dist/path.js.map +1 -1
  56. package/dist/track.d.ts.map +1 -1
  57. package/dist/track.js +31 -12
  58. package/dist/track.js.map +1 -1
  59. package/dist/transition.d.ts +30 -4
  60. package/dist/transition.d.ts.map +1 -1
  61. package/dist/transition.js +63 -7
  62. package/dist/transition.js.map +1 -1
  63. package/dist/types.d.ts +5 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/vue/index.d.ts +28 -2
  66. package/dist/vue/index.d.ts.map +1 -1
  67. package/dist/vue/index.js +78 -2
  68. package/dist/vue/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +195 -0
  71. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
  72. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
  73. package/platforms/android/proguard-rules.pro +2 -0
  74. package/src/android-plugin.d.ts +56 -0
  75. package/src/animate.ts +99 -16
  76. package/src/drag.ts +67 -21
  77. package/src/driver/android-gradient.ts +109 -0
  78. package/src/driver/ios-gradient.ts +114 -0
  79. package/src/easing.ts +14 -0
  80. package/src/engine.ts +158 -53
  81. package/src/gradient.ts +378 -0
  82. package/src/index.ts +34 -3
  83. package/src/keyboard.ts +83 -18
  84. package/src/mixer.ts +264 -0
  85. package/src/motion-scope.ts +14 -6
  86. package/src/motion-states.ts +145 -41
  87. package/src/motion-value.ts +563 -0
  88. package/src/path.ts +2 -1
  89. package/src/track.ts +37 -12
  90. package/src/transition.ts +99 -10
  91. package/src/types.ts +5 -0
  92. package/src/vue/index.ts +98 -1
@@ -0,0 +1,378 @@
1
+ import { isAndroid, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import { colorMixer, parseColor, type ColorValue } from './color.js'
4
+ import { reducedMotion } from './config.js'
5
+ import { getDriver } from './driver/index.js'
6
+ import { androidGradient } from './driver/android-gradient.js'
7
+ import { iosGradient } from './driver/ios-gradient.js'
8
+ import type { Animation } from './engine.js'
9
+ import { createTrack, type Track } from './track.js'
10
+ import {
11
+ resolveTransition,
12
+ springs,
13
+ type SpringName,
14
+ type Transition,
15
+ } from './transition.js'
16
+ import type { Target } from './types.js'
17
+
18
+ export type GradientType = 'linear' | 'radial' | 'conic'
19
+
20
+ export interface GradientValues {
21
+ /** Two to eight colors. Animating keeps the count. */
22
+ colors?: readonly ColorValue[]
23
+ /** Where each color sits, from 0 to 1. Evenly spaced by default. */
24
+ stops?: readonly number[]
25
+ /** Degrees, like CSS: `linear` points this way (180 runs top to bottom), `conic` starts here (0 is up). */
26
+ angle?: number
27
+ /** `radial` and `conic`: the center, in fractions of the view's width and height. */
28
+ center?: { readonly x: number; readonly y: number }
29
+ /** `radial`: the radius, in fractions of the view's shorter side. */
30
+ radius?: number
31
+ }
32
+
33
+ export interface GradientOptions extends GradientValues {
34
+ type: GradientType
35
+ colors: readonly ColorValue[]
36
+ }
37
+
38
+ export interface Gradient {
39
+ readonly type: GradientType
40
+ /** Animates to new values from the ones on screen. Colors and stops keep their count. */
41
+ animate(values: GradientValues, transition?: Transition | SpringName): Animation
42
+ /** Applies values at once, stopping the animation in progress. */
43
+ set(values: GradientValues): void
44
+ /** Stops the animation in progress where it is. */
45
+ stop(): void
46
+ /** Takes the gradient off the view. */
47
+ remove(): void
48
+ }
49
+
50
+ /** Everything the platform needs to paint one frame. */
51
+ export interface GradientFrame {
52
+ colors: number[]
53
+ stops: number[]
54
+ angle: number
55
+ x: number
56
+ y: number
57
+ radius: number
58
+ }
59
+
60
+ export interface GradientTiming {
61
+ duration: number
62
+ delay: number
63
+ repeat: number
64
+ reverse: boolean
65
+ }
66
+
67
+ export interface GradientPainter {
68
+ paint(frame: GradientFrame): void
69
+ /** Plays sampled frames natively; `onEnd(false)` when cancelled. */
70
+ play(
71
+ frames: readonly GradientFrame[],
72
+ times: readonly number[],
73
+ timing: GradientTiming,
74
+ onEnd: (finished: boolean) => void,
75
+ ): { cancel(): void }
76
+ remove(): void
77
+ }
78
+
79
+ type PainterFactory = (view: View, type: GradientType) => GradientPainter | undefined
80
+
81
+ let factory: PainterFactory | undefined
82
+
83
+ /** Test seam, like `setDriver`. */
84
+ export function setGradientPainter(next: PainterFactory | undefined) {
85
+ factory = next
86
+ }
87
+
88
+ function platformPainter(view: View, type: GradientType) {
89
+ if (isIOS) return iosGradient(view, type)
90
+ if (isAndroid) return androidGradient(view, type)
91
+ return undefined
92
+ }
93
+
94
+ const types: readonly GradientType[] = ['linear', 'radial', 'conic']
95
+
96
+ /**
97
+ * Paints a native gradient behind a layout's children and animates it.
98
+ *
99
+ * ```ts
100
+ * const glow = gradient(halo, { type: 'radial', colors: ['#FF0080', '#7928CA00'], radius: 0.8 })
101
+ * glow.animate({ center: { x: 0.8, y: 0.2 }, radius: 1.2 }, { duration: 2000 })
102
+ * ```
103
+ */
104
+ export function gradient(target: Target, options: GradientOptions): Gradient {
105
+ const view = toView(target)
106
+ if (!options || !types.includes(options.type))
107
+ throw new TypeError(`Gradient type must be 'linear', 'radial' or 'conic'.`)
108
+ const first = resolve(
109
+ { colors: [], stops: [], angle: 180, x: 0.5, y: 0.5, radius: 0.5 },
110
+ options,
111
+ false,
112
+ )
113
+ return new GradientController(view, options.type, first)
114
+ }
115
+
116
+ class GradientController implements Gradient {
117
+ private painter: GradientPainter | undefined
118
+ private pending: GradientFrame | undefined
119
+ private removed = false
120
+ private model: GradientFrame
121
+ private running:
122
+ | {
123
+ from: GradientFrame
124
+ to: GradientFrame
125
+ track: Track
126
+ startedAt: number
127
+ handle: { cancel(): void }
128
+ settle: (finished: boolean) => void
129
+ }
130
+ | undefined
131
+
132
+ constructor(
133
+ private readonly view: View,
134
+ readonly type: GradientType,
135
+ frame: GradientFrame,
136
+ ) {
137
+ this.model = frame
138
+ this.attach((factory ?? platformPainter)(view, type))
139
+ view.on('unloaded', this.unloaded)
140
+ }
141
+
142
+ private unloaded = () => this.stop()
143
+
144
+ private attach(painter: GradientPainter | undefined) {
145
+ if (!painter) return
146
+ if (this.removed) return painter.remove()
147
+ this.painter = painter
148
+ painter.paint(this.pending ?? this.model)
149
+ this.pending = undefined
150
+ }
151
+
152
+ animate(values: GradientValues, transition?: Transition | SpringName): Animation {
153
+ this.assertActive()
154
+ const from = this.presented()
155
+ const to = resolve(from, values, true)
156
+ const resolved =
157
+ typeof transition === 'string' ? springOf(transition) : (transition ?? {})
158
+ const track = createTrack(
159
+ 'opacity',
160
+ [0, 1],
161
+ resolveTransition(resolved, 'opacity', 2),
162
+ 0,
163
+ )
164
+ const reduced = reducedMotion()
165
+ const frameAt = (progress: number) => mix(from, to, progress, reduced)
166
+ this.cancelRunning()
167
+ this.model = frameAt(track.to)
168
+
169
+ let settle!: (finished: boolean) => void
170
+ const finished = new Promise<boolean>((resolve) => (settle = resolve))
171
+ const animation: Animation = {
172
+ finished,
173
+ stop: () => {
174
+ if (this.running?.settle === settle) this.stop()
175
+ },
176
+ then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
177
+ }
178
+ if (track.end === 0 || !this.painter) {
179
+ this.paint(this.model)
180
+ settle(true)
181
+ return animation
182
+ }
183
+ const handle = this.painter.play(
184
+ track.values.map(frameAt),
185
+ track.times,
186
+ {
187
+ duration: track.duration,
188
+ delay: track.delay,
189
+ repeat: track.repeat,
190
+ reverse: track.reverse,
191
+ },
192
+ (completed) => {
193
+ if (this.running?.settle !== settle) return
194
+ this.running = undefined
195
+ settle(completed)
196
+ },
197
+ )
198
+ this.running = {
199
+ from,
200
+ to,
201
+ track,
202
+ startedAt: getDriver().now(),
203
+ handle,
204
+ settle,
205
+ }
206
+ return animation
207
+ }
208
+
209
+ set(values: GradientValues) {
210
+ this.assertActive()
211
+ const next = resolve(this.presented(), values, false)
212
+ this.cancelRunning()
213
+ this.model = next
214
+ this.paint(next)
215
+ }
216
+
217
+ stop() {
218
+ if (!this.running) return
219
+ const current = this.presented()
220
+ this.cancelRunning()
221
+ this.model = current
222
+ this.paint(current)
223
+ }
224
+
225
+ remove() {
226
+ if (this.removed) return
227
+ this.cancelRunning()
228
+ this.removed = true
229
+ this.view.off('unloaded', this.unloaded)
230
+ this.painter?.remove()
231
+ this.painter = undefined
232
+ }
233
+
234
+ private paint(frame: GradientFrame) {
235
+ if (this.painter) this.painter.paint(frame)
236
+ else this.pending = frame
237
+ }
238
+
239
+ private presented(): GradientFrame {
240
+ const running = this.running
241
+ if (!running) return this.model
242
+ const elapsed = getDriver().now() - running.startedAt
243
+ return mix(running.from, running.to, running.track.valueAt(elapsed), reducedMotion())
244
+ }
245
+
246
+ private cancelRunning() {
247
+ const running = this.running
248
+ if (!running) return
249
+ this.running = undefined
250
+ running.handle.cancel()
251
+ running.settle(false)
252
+ }
253
+
254
+ private assertActive() {
255
+ if (this.removed) throw new Error('This gradient was removed.')
256
+ }
257
+ }
258
+
259
+ function springOf(name: SpringName): Transition {
260
+ const spring = springs[name]
261
+ if (!spring || !Object.hasOwn(springs, name))
262
+ throw new Error(`Unknown spring "${name}".`)
263
+ return spring
264
+ }
265
+
266
+ /** Validates values and fills in what they leave out from `base`. */
267
+ function resolve(
268
+ base: GradientFrame,
269
+ values: GradientValues,
270
+ sameCount: boolean,
271
+ ): GradientFrame {
272
+ if (!values || typeof values !== 'object')
273
+ throw new TypeError('Gradient values must be an object.')
274
+ const colors = values.colors ? values.colors.map(parseColor) : base.colors
275
+ if (colors.length < 2 || colors.length > 8)
276
+ throw new RangeError('A gradient needs between two and eight colors.')
277
+ if (sameCount && values.colors && colors.length !== base.colors.length)
278
+ throw new RangeError(
279
+ `Animating a gradient keeps its ${base.colors.length} colors; use set() to change the count.`,
280
+ )
281
+ const stops = values.stops
282
+ ? [...values.stops]
283
+ : values.colors || base.stops.length !== colors.length
284
+ ? colors.map((_, i) => i / (colors.length - 1))
285
+ : base.stops
286
+ if (
287
+ stops.length !== colors.length ||
288
+ stops.some(
289
+ (stop, i) =>
290
+ !Number.isFinite(stop) || stop < 0 || stop > 1 || stop < (stops[i - 1] ?? 0),
291
+ )
292
+ )
293
+ throw new RangeError(
294
+ 'Gradient stops need one value per color, from 0 to 1, without going back.',
295
+ )
296
+ const angle = values.angle ?? base.angle
297
+ const x = values.center?.x ?? base.x
298
+ const y = values.center?.y ?? base.y
299
+ const radius = values.radius ?? base.radius
300
+ for (const [name, value] of [
301
+ ['angle', angle],
302
+ ['center', x],
303
+ ['center', y],
304
+ ['radius', radius],
305
+ ] as const)
306
+ if (!Number.isFinite(value))
307
+ throw new RangeError(`Invalid gradient ${name}: ${value}.`)
308
+ if (radius < 0)
309
+ throw new RangeError(`Gradient radius must not be negative, got ${radius}.`)
310
+ return { colors, stops, angle, x, y, radius }
311
+ }
312
+
313
+ /** One frame between two states. Geometry follows the curve, overshoot included; colors and stops stay between their ends. */
314
+ export function mix(
315
+ from: GradientFrame,
316
+ to: GradientFrame,
317
+ progress: number,
318
+ reduced = false,
319
+ ): GradientFrame {
320
+ const p = Math.min(Math.max(progress, 0), 1)
321
+ const g = reduced ? 1 : progress
322
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t
323
+ return {
324
+ colors: to.colors.map((color, i) => colorMixer(from.colors[i] ?? color, color)(p)),
325
+ stops: to.stops.map((stop, i) => lerp(from.stops[i] ?? stop, stop, p)),
326
+ angle: lerp(from.angle, to.angle, g),
327
+ x: lerp(from.x, to.x, g),
328
+ y: lerp(from.y, to.y, g),
329
+ radius: Math.max(0, lerp(from.radius, to.radius, g)),
330
+ }
331
+ }
332
+
333
+ /**
334
+ * The points a platform gradient needs, in unit coordinates of a `width` × `height` box.
335
+ * Linear follows CSS: the line runs through the center at `angle` and is long enough
336
+ * for the corners to get the first and last colors.
337
+ */
338
+ export function gradientPoints(
339
+ type: GradientType,
340
+ frame: GradientFrame,
341
+ width: number,
342
+ height: number,
343
+ ) {
344
+ const radians = (frame.angle * Math.PI) / 180
345
+ const sin = Math.sin(radians)
346
+ const cos = Math.cos(radians)
347
+ if (type === 'linear') {
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
+ }
358
+ }
359
+ const center = { x: frame.x, y: frame.y }
360
+ const shorter = Math.min(width, height)
361
+ if (type === 'radial') {
362
+ const r = frame.radius * shorter
363
+ return {
364
+ start: center,
365
+ end: {
366
+ x: center.x + (width > 0 ? r / width : 0),
367
+ y: center.y + (height > 0 ? r / height : 0),
368
+ },
369
+ }
370
+ }
371
+ return {
372
+ start: center,
373
+ end: {
374
+ x: center.x + (width > 0 ? (sin * shorter) / (2 * width) : 0),
375
+ y: center.y + (height > 0 ? (-cos * shorter) / (2 * height) : 0),
376
+ },
377
+ }
378
+ }
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'
@@ -42,12 +58,27 @@ export type {
42
58
  SymbolEffectName,
43
59
  SymbolEffectOptions,
44
60
  } from './symbol-effect.js'
61
+ export { gradient } from './gradient.js'
62
+ export type {
63
+ Gradient,
64
+ GradientOptions,
65
+ GradientType,
66
+ GradientValues,
67
+ } from './gradient.js'
45
68
  export { configure, onReducedMotion, reducedMotion } from './config.js'
46
69
  export type { JoltConfig } from './config.js'
47
70
  export { definePreset, presets } from './presets.js'
48
71
  export type { Preset, PresetName, Presets } from './presets.js'
49
- export { springs } from './transition.js'
50
- export type { Spring, SpringName, Timing, Transition, Tween } from './transition.js'
72
+ export { origamiSpring, springs } from './transition.js'
73
+ export type {
74
+ OrigamiSpring,
75
+ RepeatDelayRange,
76
+ Spring,
77
+ SpringName,
78
+ Timing,
79
+ Transition,
80
+ Tween,
81
+ } from './transition.js'
51
82
  export { cubicBezier } from './easing.js'
52
83
  export type { CubicBezier, Easing, EasingFunction, EasingName } from './easing.js'
53
84
  export type { PlaybackAnimation } from './playback.js'
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