@rafaelaugusto/jolt 0.1.0-rc.2 → 0.1.0-rc.4

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 (91) hide show
  1. package/README.md +4 -4
  2. package/dist/animate.d.ts.map +1 -1
  3. package/dist/animate.js +20 -5
  4. package/dist/animate.js.map +1 -1
  5. package/dist/bottom-sheet/geometry.d.ts +7 -2
  6. package/dist/bottom-sheet/geometry.d.ts.map +1 -1
  7. package/dist/bottom-sheet/geometry.js +7 -3
  8. package/dist/bottom-sheet/geometry.js.map +1 -1
  9. package/dist/bottom-sheet/index.d.ts +10 -0
  10. package/dist/bottom-sheet/index.d.ts.map +1 -1
  11. package/dist/bottom-sheet/index.js +72 -22
  12. package/dist/bottom-sheet/index.js.map +1 -1
  13. package/dist/bottom-sheet/native.d.ts +5 -0
  14. package/dist/bottom-sheet/native.d.ts.map +1 -1
  15. package/dist/bottom-sheet/native.js +6 -0
  16. package/dist/bottom-sheet/native.js.map +1 -1
  17. package/dist/driver/android.d.ts.map +1 -1
  18. package/dist/driver/android.js +15 -7
  19. package/dist/driver/android.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/interactive-navigation/index.d.ts +2 -0
  25. package/dist/interactive-navigation/index.d.ts.map +1 -1
  26. package/dist/interactive-navigation/index.js +1 -0
  27. package/dist/interactive-navigation/index.js.map +1 -1
  28. package/dist/interactive-navigation/modal.d.ts +34 -0
  29. package/dist/interactive-navigation/modal.d.ts.map +1 -0
  30. package/dist/interactive-navigation/modal.js +253 -0
  31. package/dist/interactive-navigation/modal.js.map +1 -0
  32. package/dist/live-spring-driver.d.ts +5 -0
  33. package/dist/live-spring-driver.d.ts.map +1 -1
  34. package/dist/live-spring-driver.js +13 -0
  35. package/dist/live-spring-driver.js.map +1 -1
  36. package/dist/mixer.d.ts +7 -1
  37. package/dist/mixer.d.ts.map +1 -1
  38. package/dist/mixer.js +57 -22
  39. package/dist/mixer.js.map +1 -1
  40. package/dist/motion-value.d.ts +12 -1
  41. package/dist/motion-value.d.ts.map +1 -1
  42. package/dist/motion-value.js +115 -7
  43. package/dist/motion-value.js.map +1 -1
  44. package/dist/scroll-header/geometry.d.ts +1 -1
  45. package/dist/scroll-header/geometry.d.ts.map +1 -1
  46. package/dist/scroll-header/geometry.js +7 -2
  47. package/dist/scroll-header/geometry.js.map +1 -1
  48. package/dist/scroll-header/index.d.ts.map +1 -1
  49. package/dist/scroll-header/index.js +13 -0
  50. package/dist/scroll-header/index.js.map +1 -1
  51. package/dist/shapes/index.d.ts +1 -1
  52. package/dist/shapes/index.d.ts.map +1 -1
  53. package/dist/shapes/index.js +23 -9
  54. package/dist/shapes/index.js.map +1 -1
  55. package/dist/types.d.ts +5 -1
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/value-track.d.ts +9 -0
  58. package/dist/value-track.d.ts.map +1 -1
  59. package/dist/value-track.js +28 -16
  60. package/dist/value-track.js.map +1 -1
  61. package/package.json +1 -1
  62. package/platforms/android/java/org/rafaelaugusto/jolt/BlurEffect.java +14 -2
  63. package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +30 -8
  64. package/platforms/android/java/org/rafaelaugusto/jolt/KeyframeFactory.java +26 -0
  65. package/platforms/android/java/org/rafaelaugusto/jolt/LiveSpring.java +36 -9
  66. package/platforms/android/java/org/rafaelaugusto/jolt/ScrollHeader.java +3 -2
  67. package/platforms/android/java/org/rafaelaugusto/jolt/ShapeLayer.java +17 -7
  68. package/platforms/android/proguard-rules.pro +1 -0
  69. package/platforms/ios/src/JoltInteractiveModal.h +16 -0
  70. package/platforms/ios/src/JoltInteractiveModal.m +178 -0
  71. package/platforms/ios/src/JoltLiveSpring.h +1 -0
  72. package/platforms/ios/src/JoltLiveSpring.m +183 -67
  73. package/platforms/ios/src/module.modulemap +1 -0
  74. package/src/android-plugin.d.ts +10 -0
  75. package/src/animate.ts +23 -5
  76. package/src/bottom-sheet/geometry.ts +9 -2
  77. package/src/bottom-sheet/index.ts +94 -20
  78. package/src/bottom-sheet/native.ts +6 -0
  79. package/src/driver/android.ts +31 -14
  80. package/src/index.ts +1 -1
  81. package/src/interactive-navigation/index.ts +7 -0
  82. package/src/interactive-navigation/modal.ts +306 -0
  83. package/src/ios-plugin.d.ts +23 -0
  84. package/src/live-spring-driver.ts +15 -0
  85. package/src/mixer.ts +62 -22
  86. package/src/motion-value.ts +121 -9
  87. package/src/scroll-header/geometry.ts +7 -2
  88. package/src/scroll-header/index.ts +12 -0
  89. package/src/shapes/index.ts +20 -9
  90. package/src/types.ts +5 -2
  91. package/src/value-track.ts +25 -14
@@ -37,6 +37,8 @@ export interface LiveNode {
37
37
  moving(): boolean
38
38
  follow(source: LiveNode, maps: readonly LiveMapping[]): void
39
39
  update(input: LiveInput): void
40
+ /** Several sampled inputs over a rest value, each on its own clock, summed natively. */
41
+ inputs(rest: number, inputs: readonly LiveInput[]): void
40
42
  reduced(value: boolean): void
41
43
  paused(value: boolean): void
42
44
  bind(view: View, property: NumericProperty, maps: readonly LiveMapping[]): LiveBinding
@@ -58,6 +60,17 @@ export function liveSpringDriver(): LiveSpringDriver {
58
60
  )
59
61
  return nativeDriver
60
62
  }
63
+ /** Native composition is optional: an app binary without the classes keeps sampled playback. */
64
+ export function liveComposition(): LiveSpringDriver | undefined {
65
+ return override ?? (available() ? nativeDriver : undefined)
66
+ }
67
+ export const stillPhysics: LiveSpringPhysics = {
68
+ stiffness: 100,
69
+ damping: 20,
70
+ mass: 1,
71
+ restDelta: 0.001,
72
+ restSpeed: 0.01,
73
+ }
61
74
  function available() {
62
75
  return isAndroid
63
76
  ? typeof org !== 'undefined' &&
@@ -104,6 +117,7 @@ const nativeDriver: LiveSpringDriver = {
104
117
  input.running
105
118
  ? native.updateInput(JSON.stringify(input))
106
119
  : native.setTarget(input.value),
120
+ inputs: (rest, inputs) => native.setInputs(rest, JSON.stringify(inputs)),
107
121
  reduced: (value) => native.setReduced(value),
108
122
  paused: (value) => native.setPaused(value),
109
123
  bind(view, property, maps) {
@@ -145,6 +159,7 @@ const nativeDriver: LiveSpringDriver = {
145
159
  input.running
146
160
  ? native.updateInput(JSON.stringify(input))
147
161
  : native.setTarget(input.value),
162
+ inputs: (rest, inputs) => native.setInputsRest(JSON.stringify(inputs), rest),
148
163
  reduced: (value) => native.setReduced(value),
149
164
  paused: (value) => native.setPaused(value),
150
165
  bind(view, property, maps) {
package/src/mixer.ts CHANGED
@@ -2,7 +2,13 @@ import type { View } from '@nativescript/core'
2
2
  import { getDriver } from './driver/index.js'
3
3
  import { readValue, writeValue, type Property } from './properties.js'
4
4
  import type { Track } from './track.js'
5
- import type { LiveInput } from './live-spring-driver.js'
5
+ import {
6
+ liveComposition,
7
+ stillPhysics,
8
+ type LiveBinding,
9
+ type LiveInput,
10
+ type LiveNode,
11
+ } from './live-spring-driver.js'
6
12
 
7
13
  /** The channel of regular animations; layers are named next to it. */
8
14
  export const BASE = ''
@@ -11,7 +17,10 @@ const SAMPLES_PER_SECOND = 60
11
17
  const MAX_SAMPLES = 6000
12
18
  /** Endless channels loop natively over the least common multiple of their periods, up to this. */
13
19
  const MAX_LOOP = 30000
14
- /** Beyond it, the composition is played in windows and sampled again at each one. */
20
+ /**
21
+ * Beyond it, a native composer plays the layers without rebuilding. An app binary
22
+ * without the native classes falls back to windows sampled again every few seconds.
23
+ */
15
24
  const WINDOW = 4000
16
25
 
17
26
  export const multiplies = (property: Property) =>
@@ -69,6 +78,7 @@ export function mixFor(view: View, property: Property): Mix {
69
78
  /** One property of one view: a base and named layers, played natively as one composite track. */
70
79
  export class Mix {
71
80
  private live: { read(): number; layers(inputs: readonly LiveInput[]): void } | undefined
81
+ private composer: { node: LiveNode; binding: LiveBinding } | undefined
72
82
  private readonly rests = new Map<string, number>()
73
83
  private readonly channels = new Map<string, Channel>()
74
84
  private native: unknown
@@ -93,6 +103,7 @@ export class Mix {
93
103
  read(): number
94
104
  layers(inputs: readonly LiveInput[]): void
95
105
  }): () => void {
106
+ this.release()
96
107
  this.live = base
97
108
  this.compose()
98
109
  return () => {
@@ -201,6 +212,30 @@ export class Mix {
201
212
  })
202
213
  }
203
214
 
215
+ /** Every channel and rest as a native layer; the base is one of them unless something else owns it. */
216
+ private inputs(now: number, withBase: boolean): LiveInput[] {
217
+ const layers: LiveInput[] = []
218
+ for (const key of new Set([...this.rests.keys(), ...this.channels.keys()])) {
219
+ if (key === BASE && !withBase) continue
220
+ const channel = this.channels.get(key)
221
+ const value = this.part(key, now, now)
222
+ const track = channel?.track
223
+ layers.push({
224
+ value,
225
+ running: !!channel && !channel.paused,
226
+ elapsed: this.elapsed(key, now),
227
+ duration: track?.duration ?? 0,
228
+ delay: track?.delay ?? 0,
229
+ repeat: track ? (Number.isFinite(track.repeat) ? track.repeat : -1) : 0,
230
+ reverse: track?.reverse ?? false,
231
+ to: track?.to ?? value,
232
+ times: track?.times ?? [],
233
+ values: track?.values ?? [],
234
+ })
235
+ }
236
+ return layers
237
+ }
238
+
204
239
  /** Samples the composition from now and plays it as one native track. */
205
240
  private compose() {
206
241
  const driver = getDriver()
@@ -211,26 +246,7 @@ export class Mix {
211
246
 
212
247
  if (this.live) {
213
248
  if (previous !== undefined) driver.stop(this.view, previous)
214
- const layers: LiveInput[] = []
215
- for (const key of new Set([...this.rests.keys(), ...this.channels.keys()])) {
216
- if (key === BASE) continue
217
- const channel = this.channels.get(key)
218
- const value = this.part(key, now, now)
219
- const track = channel?.track
220
- layers.push({
221
- value,
222
- running: !!channel && !channel.paused,
223
- elapsed: this.elapsed(key, now),
224
- duration: track?.duration ?? 0,
225
- delay: track?.delay ?? 0,
226
- repeat: track ? (Number.isFinite(track.repeat) ? track.repeat : -1) : 0,
227
- reverse: track?.reverse ?? false,
228
- to: track?.to ?? value,
229
- times: track?.times ?? [],
230
- values: track?.values ?? [],
231
- })
232
- }
233
- this.live.layers(layers)
249
+ this.live.layers(this.inputs(now, false))
234
250
  return
235
251
  }
236
252
 
@@ -248,11 +264,14 @@ export class Mix {
248
264
  if (previous !== undefined) driver.stop(this.view, previous)
249
265
  const loop = finite <= 0 && periods.length ? leastCommonMultiple(periods) : undefined
250
266
  if (finite <= 0 && !periods.length) {
267
+ this.release()
251
268
  writeValue(this.view, this.property, this.value(now))
252
269
  driver.sync?.(this.view)
253
270
  this.dissolve()
254
271
  return
255
272
  }
273
+ if (finite <= 0 && loop === undefined && this.composeNatively(now)) return
274
+ this.release()
256
275
 
257
276
  const span = finite > 0 ? finite : (loop ?? WINDOW)
258
277
  const steps = Math.max(
@@ -292,6 +311,27 @@ export class Mix {
292
311
  if (elapsed > 1) driver.control?.(this.view, native, elapsed, false)
293
312
  }
294
313
 
314
+ /** Endless layers with no common period play on a native composer instead of rolling windows. */
315
+ private composeNatively(now: number): boolean {
316
+ const live = liveComposition()
317
+ if (!live || !layerable(this.property) || !this.view.nativeViewProtected) return false
318
+ if (!this.composer) {
319
+ const node = live.create(identity(this.property), stillPhysics, true)
320
+ const binding = node.bind(this.view, this.property, [])
321
+ this.composer = { node, binding }
322
+ }
323
+ this.composer.binding.layers(this.inputs(now, true))
324
+ return true
325
+ }
326
+
327
+ private release() {
328
+ const composer = this.composer
329
+ if (!composer) return
330
+ this.composer = undefined
331
+ composer.binding.cancel()
332
+ composer.node.dispose()
333
+ }
334
+
295
335
  private dissolve() {
296
336
  if (this.live || this.channels.size) return
297
337
  for (const [key, value] of this.rests)
@@ -16,13 +16,16 @@ import {
16
16
  } from './color.js'
17
17
  import { onReducedMotion, reducedMotion, timeScale } from './config.js'
18
18
  import { springPhysics, type SpringParams } from './spring.js'
19
- import { mixFor } from './mixer.js'
19
+ import { layerable, mixFor } from './mixer.js'
20
20
  import {
21
+ type LiveBinding,
22
+ type LiveInput,
23
+ type LiveMapping,
24
+ type LiveNode,
25
+ liveComposition,
21
26
  liveSpringDriver,
27
+ stillPhysics,
22
28
  syncLiveSprings,
23
- type LiveNode,
24
- type LiveMapping,
25
- type LiveBinding,
26
29
  } from './live-spring-driver.js'
27
30
  import {
28
31
  clampDecoration,
@@ -44,7 +47,7 @@ import {
44
47
  import type { KeyframeProperty, Target } from './types.js'
45
48
  import { playbackSource, type PlaybackAnimation, type PlaybackClock } from './playback.js'
46
49
  import { TimedAnimation } from './timed-animation.js'
47
- import { valueTrack } from './value-track.js'
50
+ import { compositionSpan, valueTrack } from './value-track.js'
48
51
  import {
49
52
  ValueObservers,
50
53
  type SubscribeOptions,
@@ -108,6 +111,8 @@ interface Run {
108
111
  readonly track: Track
109
112
  readonly base: number
110
113
  readonly span: number
114
+ /** The track's samples in the value's units, ready for a native composer. */
115
+ readonly scaled: readonly number[]
111
116
  readonly animation: TimedAnimation
112
117
  readonly startedAt: number
113
118
  readonly clock?: Pick<PlaybackClock, 'time'>
@@ -213,7 +218,10 @@ class Root extends Derived {
213
218
  private readonly rests = new Map<string, number>()
214
219
  private readonly runs = new Map<string, Run>()
215
220
  private presentation: Presentation | undefined
221
+ private presented = false
216
222
  private cancelPresentation: (() => void) | undefined
223
+ private composer: LiveNode | undefined
224
+ private composition: { rest: number; inputs: LiveInput[] } | undefined
217
225
 
218
226
  constructor(initial: number) {
219
227
  super(undefined, (value) => value, false)
@@ -345,6 +353,7 @@ class Root extends Derived {
345
353
  track,
346
354
  base,
347
355
  span,
356
+ scaled: track.values.map((value) => base + span * value),
348
357
  animation,
349
358
  startedAt: driver.now(),
350
359
  clock: { time: () => animation.time },
@@ -398,6 +407,74 @@ class Root extends Derived {
398
407
  private sync() {
399
408
  this.cancelPresentation?.()
400
409
  this.cancelPresentation = undefined
410
+ this.presentation = undefined
411
+ this.presented = false
412
+ const now = getDriver().now()
413
+ const runs = [...this.runs.values()].filter((run) => !run.animation.paused)
414
+ const span =
415
+ runs.length > 1
416
+ ? compositionSpan(
417
+ runs.map((run) => ({ track: run.track, elapsed: this.runTime(run, now) })),
418
+ )
419
+ : undefined
420
+ this.composition = span?.windowed && liveComposition() ? this.inputs(now) : undefined
421
+ if (this.composition)
422
+ this.composer?.inputs(this.composition.rest, this.composition.inputs)
423
+ else {
424
+ this.settle()
425
+ this.present()
426
+ }
427
+ this.changed()
428
+ }
429
+
430
+ /** Every run as a native input on its own clock; rests and paused runs stand still. */
431
+ private inputs(now: number) {
432
+ let rest = 0
433
+ for (const [key, value] of this.rests) if (!this.runs.has(key)) rest += value
434
+ const inputs: LiveInput[] = []
435
+ for (const run of this.runs.values()) {
436
+ const elapsed = this.runTime(run, now)
437
+ const { track } = run
438
+ inputs.push({
439
+ value: run.base + run.span * track.valueAt(elapsed),
440
+ running: !run.animation.paused,
441
+ elapsed,
442
+ duration: track.duration,
443
+ delay: track.delay,
444
+ repeat: Number.isFinite(track.repeat) ? track.repeat : -1,
445
+ reverse: track.reverse,
446
+ to: run.base + run.span * track.to,
447
+ times: track.times,
448
+ values: run.scaled,
449
+ })
450
+ }
451
+ return { rest, inputs }
452
+ }
453
+
454
+ /** The native composer a binding joins while this value would otherwise roll windows. */
455
+ composerNode(): LiveNode | undefined {
456
+ const composition = this.composition
457
+ if (!composition) return undefined
458
+ if (!this.composer) {
459
+ const live = liveComposition()
460
+ if (!live) return undefined
461
+ this.composer = live.create(this.raw(), stillPhysics, true)
462
+ this.composer.inputs(composition.rest, composition.inputs)
463
+ }
464
+ return this.composer
465
+ }
466
+
467
+ /** Drops the composer once no binding needs it or the composition stops rolling. */
468
+ settle() {
469
+ if (this.composition && this.bindings.size) return
470
+ this.composer?.dispose()
471
+ this.composer = undefined
472
+ }
473
+
474
+ /** Sampled at once for tracks, on demand while bindings sit on the composer. */
475
+ private present(): Presentation | undefined {
476
+ if (this.presented) return this.presentation
477
+ this.presented = true
401
478
  const now = getDriver().now()
402
479
  let rest = 0
403
480
  for (const [key, value] of this.rests) if (!this.runs.has(key)) rest += value
@@ -429,7 +506,7 @@ class Root extends Derived {
429
506
  clock?.cancel()
430
507
  }
431
508
  }
432
- this.changed()
509
+ return this.presentation
433
510
  }
434
511
 
435
512
  protected changed() {
@@ -440,7 +517,7 @@ class Root extends Derived {
440
517
 
441
518
  /** The plan that plays this value's run, mapped, on one bound property. */
442
519
  plan(binding: Binding): Plan {
443
- const run = this.presentation
520
+ const run = this.present()
444
521
  const { property, read } = binding
445
522
  const write = (value: number) =>
446
523
  isDecorationProperty(property)
@@ -495,12 +572,13 @@ class Root extends Derived {
495
572
 
496
573
  /** Capture the current native presentation clock, even if a later command replaces it. */
497
574
  captureTime(): () => number {
498
- const run = this.presentation
575
+ const run = this.present()
499
576
  return run ? () => this.runTime(run) : () => 0
500
577
  }
501
578
 
502
579
  elapsed(): number {
503
- return this.presentation ? this.runTime(this.presentation) : 0
580
+ const run = this.present()
581
+ return run ? this.runTime(run) : 0
504
582
  }
505
583
 
506
584
  get paused(): boolean {
@@ -862,6 +940,16 @@ function attach(view: View, property: Property, source: Derived) {
862
940
  const root = source.root
863
941
  if (root instanceof SpringRoot)
864
942
  return attachSpring(view, property as NumericProperty, source, root)
943
+ let native: LiveBinding | undefined, composer: LiveNode | undefined
944
+ let releaseMix: (() => void) | undefined
945
+ const detach = () => {
946
+ if (!native) return
947
+ native.cancel()
948
+ releaseMix?.()
949
+ releaseMix = undefined
950
+ native = composer = undefined
951
+ syncLiveSprings(view)
952
+ }
865
953
  const binding: Binding = {
866
954
  view,
867
955
  property,
@@ -870,6 +958,24 @@ function attach(view: View, property: Property, source: Derived) {
870
958
  read: source.read,
871
959
  sync() {
872
960
  if (!view.isLoaded || !view.nativeViewProtected) return
961
+ const node = layerable(property) && !source.color ? root.composerNode() : undefined
962
+ if (node) {
963
+ if (composer === node) return
964
+ detach()
965
+ binding.animation?.stop()
966
+ binding.animation = undefined
967
+ stopBase(view, [property])
968
+ const handle = node.bind(view, property as NumericProperty, source.mappings)
969
+ native = handle
970
+ composer = node
971
+ releaseMix = mixFor(view, property).attachLive({
972
+ read: () => handle.value(),
973
+ layers: (layers) => handle.layers(layers),
974
+ })
975
+ syncLiveSprings(view)
976
+ return
977
+ }
978
+ detach()
873
979
  if (!root.running) {
874
980
  // A value at rest writes straight through, so scroll offsets cost one write per event.
875
981
  binding.animation?.stop()
@@ -894,12 +1000,17 @@ function attach(view: View, property: Property, source: Derived) {
894
1000
  animation.seekTime(elapsed)
895
1001
  binding.animation = animation
896
1002
  },
1003
+ cleanup() {
1004
+ view.off('unloaded', detach)
1005
+ detach()
1006
+ },
897
1007
  }
898
1008
  let properties = bound.get(view)
899
1009
  if (!properties) bound.set(view, (properties = new Map()))
900
1010
  properties.set(property, binding)
901
1011
  root.bindings.add(binding)
902
1012
  view.on('loaded', binding.sync)
1013
+ view.on('unloaded', detach)
903
1014
  try {
904
1015
  binding.sync()
905
1016
  } catch (error) {
@@ -1134,6 +1245,7 @@ function release(binding: Binding) {
1134
1245
  const properties = bound.get(binding.view)
1135
1246
  if (properties?.get(binding.property) === binding) properties.delete(binding.property)
1136
1247
  binding.cleanup?.()
1248
+ binding.root.settle()
1137
1249
  }
1138
1250
 
1139
1251
  /** A regular animation on a bound property takes it over; the value keeps its other bindings. */
@@ -61,13 +61,18 @@ export function headerGeometry(
61
61
  }
62
62
  }
63
63
 
64
- /** Keep the visible rows anchored when late artwork/text changes the header height. */
64
+ /** Keep collapsed rows relative to the pinned edge; keep expanded content in place. */
65
65
  export function resizedOffset(
66
66
  offset: number,
67
67
  before: ScrollHeaderGeometry,
68
68
  after: ScrollHeaderGeometry,
69
69
  ) {
70
- return offset <= 0 ? 0 : Math.max(0, offset + after.spacer - before.spacer)
70
+ if (offset <= 0) return 0
71
+ // Growing the compact title/section must not cover the previously visible row.
72
+ // Its position relative to the pinned bottom is range + row position - offset.
73
+ const delta =
74
+ offset >= before.range ? after.range - before.range : after.spacer - before.spacer
75
+ return Math.max(0, offset + delta)
71
76
  }
72
77
 
73
78
  export function restoredOffset(
@@ -137,6 +137,7 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
137
137
  const host = list.parent
138
138
  const saved = {
139
139
  clip: host.clipToBounds,
140
+ nativeClip: isIOS ? (host.nativeViewProtected as UIView)?.clipsToBounds : undefined,
140
141
  headerClip: header.clipToBounds,
141
142
  headerY: header.translateY,
142
143
  titleY: title.translateY,
@@ -284,6 +285,13 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
284
285
  }
285
286
  const measure = async () => {
286
287
  if (ended || !list.isLoaded || !header.isLoaded) return
288
+ // Core's default-valued property can be true while a plain UIView still
289
+ // has clipping disabled. Apply it when the native host exists, too.
290
+ if (isIOS && host.nativeViewProtected) {
291
+ const container = host.nativeViewProtected as UIView
292
+ saved.nativeClip ??= container.clipsToBounds
293
+ container.clipsToBounds = true
294
+ }
287
295
  const generation = revision
288
296
  await layout(host)
289
297
  if (ended || generation !== revision) return
@@ -509,6 +517,10 @@ export function scrollHeader(list: ListView, options: ScrollHeaderOptions): Scro
509
517
  set(artwork, { y: saved.artworkY, scaleX: saved.scaleX, scaleY: saved.scaleY })
510
518
  artwork.originY = saved.originY
511
519
  host.clipToBounds = saved.clip
520
+ if (isIOS && host.nativeViewProtected && saved.nativeClip !== undefined) {
521
+ const container = host.nativeViewProtected as UIView
522
+ if (container.clipsToBounds) container.clipsToBounds = saved.nativeClip
523
+ }
512
524
  header.clipToBounds = saved.headerClip
513
525
  options.setSpacerHeight(options.originalSpacerHeight ?? 0)
514
526
  if (owners.get(list) === control) owners.delete(list)
@@ -44,7 +44,7 @@ function resolve(base: ShapeFrame, values: ShapeValues): ShapeFrame {
44
44
  )
45
45
  return { path, start, end }
46
46
  }
47
- /** Draw an owned native vector surface over a view. Create after native layout. */
47
+ /** Draw an owned native vector surface over a view. Attaches once the view is loaded. */
48
48
  export function shape(target: Target, options: ShapeOptions): Shape {
49
49
  if (!options || typeof options !== 'object')
50
50
  throw new TypeError('Shape options are required.')
@@ -77,8 +77,6 @@ export function shape(target: Target, options: ShapeOptions): Shape {
77
77
  )
78
78
  throw new TypeError('Invalid shape cap, join or fill rule.')
79
79
  const view = toView(target)
80
- if (!view.isLoaded || !view.nativeViewProtected)
81
- throw new Error('Create shapes after native layout.')
82
80
  if (owners.has(view))
83
81
  throw new Error('This view already owns a shape. Dispose it first.')
84
82
  return new ShapeOwner(view, style, frame)
@@ -92,6 +90,7 @@ class ShapeOwner implements Shape {
92
90
  track: Track
93
91
  from: ShapeFrame
94
92
  to: ShapeFrame
93
+ started: boolean
95
94
  }
96
95
  private suspended = false
97
96
  private backgrounded = false
@@ -101,7 +100,7 @@ class ShapeOwner implements Shape {
101
100
  private readonly style: ShapeStyle,
102
101
  private frame: ShapeFrame,
103
102
  ) {
104
- this.attach()
103
+ if (view.isLoaded && view.nativeViewProtected) this.attach()
105
104
  owners.set(view, this)
106
105
  view.on('loaded', this.layout)
107
106
  view.on('layoutChanged', this.layout)
@@ -128,7 +127,8 @@ class ShapeOwner implements Shape {
128
127
  if (this.native && this.native !== this.view.nativeViewProtected) this.unload()
129
128
  if (!this.painter) {
130
129
  this.attach()
131
- if (this.active) {
130
+ if (this.active && !this.active.started) this.begin()
131
+ else if (this.active) {
132
132
  const { from, to, track, animation } = this.active
133
133
  this.painter!.play(from, to, track)
134
134
  this.painter!.control(animation.time, true)
@@ -140,6 +140,18 @@ class ShapeOwner implements Shape {
140
140
  this.suspended = false
141
141
  }
142
142
  }
143
+ private begin() {
144
+ const { from, to, track, animation } = this.active!
145
+ this.active!.started = true
146
+ if (track.end > 0) {
147
+ this.painter!.play(from, to, track)
148
+ animation.seekTime(0)
149
+ } else {
150
+ this.frame = to
151
+ this.painter!.paint(to)
152
+ }
153
+ animation.start()
154
+ }
143
155
  private unload = () => {
144
156
  if (this.backgrounded && this.active) this.frame = this.painter?.read() ?? this.frame
145
157
  else this.stop()
@@ -180,9 +192,7 @@ class ShapeOwner implements Shape {
180
192
  }
181
193
  animate(values: ShapeValues, options: Transition | SpringName = {}) {
182
194
  this.alive()
183
- if (!this.painter || !this.view.isLoaded)
184
- throw new Error('Load the shape before animating.')
185
- const from = this.painter.read(),
195
+ const from = this.painter?.read() ?? this.frame,
186
196
  to = resolve(from, values)
187
197
  compatible(from.path, to.path)
188
198
  if (typeof options === 'string' && !Object.hasOwn(springs, options))
@@ -229,8 +239,9 @@ class ShapeOwner implements Shape {
229
239
  if (this.active?.animation === animation) this.active = undefined
230
240
  },
231
241
  )
232
- this.active = { animation, track, from, to }
242
+ this.active = { animation, track, from, to, started: !!this.painter }
233
243
  try {
244
+ if (!this.painter) return animation
234
245
  if (track.end > 0) this.painter.play(from, to, track)
235
246
  else {
236
247
  this.frame = to
package/src/types.ts CHANGED
@@ -53,9 +53,12 @@ export type Values = {
53
53
  [K in KeyframeProperty]?: Exclude<Keyframes[K], readonly unknown[]>
54
54
  }
55
55
 
56
- /** A view, or anything that holds one: a NativeScript-Vue element, an Angular `ElementRef`. */
56
+ /** A view, or anything that holds one: a NativeScript-Vue element or component instance, an Angular `ElementRef`. */
57
57
  export type Target =
58
- View | { readonly nativeView: View } | { readonly nativeElement: View }
58
+ | View
59
+ | { readonly nativeView: View }
60
+ | { readonly nativeElement: View }
61
+ | { readonly $el: View | { readonly nativeView: View } }
59
62
 
60
63
  /** Translation velocities in DIPs per second. Omitted axes keep their current motion. */
61
64
  export interface DecayVelocity {
@@ -15,22 +15,12 @@ export function valueTrack(parts: readonly ValuePart[], rest: number) {
15
15
  sum + part.base + part.span * part.track.valueAt(part.elapsed + time),
16
16
  rest,
17
17
  )
18
- let finite = 0
19
- const periods: number[] = []
20
- for (const { track, elapsed } of parts) {
21
- if (Number.isFinite(track.end)) finite = Math.max(finite, track.end - elapsed)
22
- else if (elapsed < track.delay) finite = Math.max(finite, track.delay - elapsed)
23
- else periods.push(Math.max(1, track.duration * (track.reverse ? 2 : 1)))
24
- }
25
- if (finite <= 0 && !periods.length)
26
- return { track: instantTrack('x', value(0)), windowed: false }
27
- const loop = finite <= 0 && periods.length ? commonPeriod(periods) : undefined
28
- // Bound finite compositions too: a minutes-long base must not undersample a short pulse.
29
- const duration = finite > 0 ? Math.min(finite, 4000) : (loop ?? 4000)
18
+ const span = compositionSpan(parts)
19
+ if (!span) return { track: instantTrack('x', value(0)), windowed: false }
20
+ const { duration, endless, windowed } = span
30
21
  const steps = Math.max(1, Math.min(6000, Math.ceil((duration * 60) / 1000)))
31
22
  const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
32
23
  const values = times.map((time) => value(time * duration))
33
- const endless = loop !== undefined
34
24
  const at = (time: number) =>
35
25
  value(
36
26
  endless
@@ -51,8 +41,29 @@ export function valueTrack(parts: readonly ValuePart[], rest: number) {
51
41
  valueAt: at,
52
42
  velocityAt: (time) => (at(time + 0.5) - at(time - 0.5)) * 1000,
53
43
  }
44
+ return { track, windowed }
45
+ }
46
+
47
+ /**
48
+ * How long one sampled composition can play before it needs rebuilding. A rolling
49
+ * window repeats every four seconds; native composition avoids it entirely.
50
+ */
51
+ export function compositionSpan(parts: readonly Pick<ValuePart, 'track' | 'elapsed'>[]) {
52
+ let finite = 0
53
+ const periods: number[] = []
54
+ for (const { track, elapsed } of parts) {
55
+ if (Number.isFinite(track.end)) finite = Math.max(finite, track.end - elapsed)
56
+ else if (elapsed < track.delay) finite = Math.max(finite, track.delay - elapsed)
57
+ else periods.push(Math.max(1, track.duration * (track.reverse ? 2 : 1)))
58
+ }
59
+ if (finite <= 0 && !periods.length) return undefined
60
+ const loop = finite <= 0 && periods.length ? commonPeriod(periods) : undefined
61
+ // Bound finite compositions too: a minutes-long base must not undersample a short pulse.
62
+ const duration = finite > 0 ? Math.min(finite, 4000) : (loop ?? 4000)
63
+ const endless = loop !== undefined
54
64
  return {
55
- track,
65
+ duration,
66
+ endless,
56
67
  windowed:
57
68
  finite > duration ||
58
69
  (!endless && parts.some(({ track }) => !Number.isFinite(track.end))),