@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
@@ -3,6 +3,7 @@ import {
3
3
  GridLayout,
4
4
  ScrollView,
5
5
  Trace,
6
+ Utils,
6
7
  View,
7
8
  isAndroid,
8
9
  } from '@nativescript/core'
@@ -19,6 +20,7 @@ import {
19
20
  import {
20
21
  captureFocus,
21
22
  dismissKeyboard,
23
+ hostInsets,
22
24
  revealFocusedField,
23
25
  sheetActions,
24
26
  windowHeight,
@@ -42,10 +44,25 @@ export interface BottomSheetOptions {
42
44
  keyboard?: 'follow' | 'none'
43
45
  /** Whether dragging, the backdrop, back and accessibility can dismiss. Defaults to true. */
44
46
  dismissible?: boolean
47
+ /**
48
+ * Overrides the host's safe-area insets, in DIPs. Core already lays the host's children
49
+ * out inside its insets, so a top override only adds to that placement.
50
+ */
45
51
  safeArea?: { top?: number; bottom?: number }
46
52
  returnFocus?: View
47
53
  spring?: Pick<SpringValueOptions, 'stiffness' | 'damping' | 'mass'>
48
- accessibility?: { label?: string; expand?: string; collapse?: string; dismiss?: string }
54
+ /**
55
+ * Spoken labels for the handle. Expand and Collapse appear only where a larger or smaller
56
+ * stop exists; `value` formats the spoken value per stop, with `null` when closed, and
57
+ * replaces the default, which is omitted for a single stop.
58
+ */
59
+ accessibility?: {
60
+ label?: string
61
+ expand?: string
62
+ collapse?: string
63
+ dismiss?: string
64
+ value?: (point: SheetSnap | null) => string | undefined
65
+ }
49
66
  onChange?: (point: SheetSnap | null) => void
50
67
  /** Called after a close settles; interrupted closes do not dismiss. */
51
68
  onDismiss?: () => void
@@ -142,6 +159,8 @@ export function bottomSheet(
142
159
  const original = {
143
160
  contentAlignment: content?.verticalAlignment,
144
161
  height: panel.height,
162
+ ignoreSafeArea: panel.iosIgnoreSafeArea,
163
+ paddingBottom: panel.paddingBottom,
145
164
  marginTop: panel.marginTop,
146
165
  marginBottom: panel.marginBottom,
147
166
  alignment: panel.verticalAlignment,
@@ -155,6 +174,7 @@ export function bottomSheet(
155
174
  value: handle.accessibilityValue,
156
175
  }
157
176
  let geometry: SheetGeometry | undefined, input: Gestures | undefined
177
+ let extended = 0
158
178
  let opened = false,
159
179
  point = points[0]!,
160
180
  ended = false,
@@ -173,6 +193,44 @@ export function bottomSheet(
173
193
  restoreFocus = () => {}
174
194
  const dismissible = options.dismissible !== false
175
195
  const followKeyboard = options.keyboard !== 'none'
196
+ const labels = options.accessibility
197
+ const spoken = (next: SheetSnap | null) =>
198
+ labels?.value
199
+ ? labels.value(next)
200
+ : points.length > 1
201
+ ? next === null
202
+ ? 'Closed'
203
+ : String(next)
204
+ : undefined
205
+ const announce = (next: SheetSnap | null) => {
206
+ handle.accessibilityValue = spoken(next) as string
207
+ }
208
+ let installed: string | undefined
209
+ // Only actions that can do something are offered; the set follows the selected stop.
210
+ function installActions() {
211
+ if (ended || !handle.isLoaded) return
212
+ const current = geometry?.stops.find((stop) => stop.point === point)
213
+ const larger =
214
+ !!current && geometry!.stops.some((stop) => stop.height > current.height + 0.01)
215
+ const smaller =
216
+ !!current && geometry!.stops.some((stop) => stop.height < current.height - 0.01)
217
+ const actions = [
218
+ ...(larger
219
+ ? [{ label: labels?.expand ?? 'Expand', run: () => adjacent(true) }]
220
+ : []),
221
+ ...(smaller
222
+ ? [{ label: labels?.collapse ?? 'Collapse', run: () => adjacent(false) }]
223
+ : []),
224
+ ...(dismissible
225
+ ? [{ label: labels?.dismiss ?? 'Dismiss', run: () => setTarget(null) }]
226
+ : []),
227
+ ]
228
+ const signature = actions.map((action) => action.label).join('\n')
229
+ if (signature === installed) return
230
+ releaseActions()
231
+ releaseActions = sheetActions(handle, actions)
232
+ installed = signature
233
+ }
176
234
  const keyboardOpen = () => followKeyboard && keyboard.visible
177
235
  const targetY = () =>
178
236
  !opened
@@ -253,7 +311,8 @@ export function bottomSheet(
253
311
  pendingDismiss ||= wasOpen
254
312
  dismissKeyboard(panel)
255
313
  }
256
- handle.accessibilityValue = opened ? String(point) : 'Closed'
314
+ announce(opened ? point : null)
315
+ installActions()
257
316
  move()
258
317
  if (changed) report(() => options.onChange?.(next))
259
318
  }
@@ -280,11 +339,16 @@ export function bottomSheet(
280
339
  return
281
340
  const size = host.getActualSize()
282
341
  if (!(size.height > 0)) return
283
- const safe = host.getSafeAreaInsets()
284
- const top = options.safeArea?.top ?? safe.top
342
+ const safe = hostInsets(host)
343
+ // The host places its children below its own top inset; the panel can only start lower.
344
+ const top = Math.max(safe.top, options.safeArea?.top ?? safe.top)
285
345
  const bottom = options.safeArea?.bottom ?? safe.bottom
346
+ // The padding that keeps rows above the bottom inset is not sheet chrome.
286
347
  const chrome = scroll
287
- ? Math.max(0, panel.getActualSize().height - scroll.getActualSize().height)
348
+ ? Math.max(
349
+ 0,
350
+ panel.getActualSize().height - scroll.getActualSize().height - extended,
351
+ )
288
352
  : 0
289
353
  const contentHeight = (content?.getActualSize().height ?? 0) + chrome
290
354
  const next = sheetGeometry(
@@ -296,14 +360,24 @@ export function bottomSheet(
296
360
  top,
297
361
  bottom,
298
362
  contentHeight,
363
+ safe.bottom === 0,
299
364
  )
300
365
  const previous = geometry
301
366
  geometry = next
302
- const panelHeight = keyboardOpen()
303
- ? next.height
304
- : Math.max(...next.stops.map((stop) => stop.height))
367
+ const panelHeight =
368
+ (keyboardOpen()
369
+ ? next.height
370
+ : Math.max(...next.stops.map((stop) => stop.height))) + next.extension
305
371
  if (panel.height !== panelHeight) panel.height = panelHeight
306
- if (panel.marginTop !== next.top) panel.marginTop = next.top
372
+ const margin = next.top - safe.top
373
+ if (panel.marginTop !== margin) panel.marginTop = margin
374
+ if (extended !== next.extension) {
375
+ const base =
376
+ Utils.layout.toDeviceIndependentPixels(panel.effectivePaddingBottom ?? 0) -
377
+ extended
378
+ panel.paddingBottom = base + next.extension
379
+ extended = next.extension
380
+ }
307
381
  if (!input) {
308
382
  input = gestures(panel, {
309
383
  pan: { axis: 'y', bounds: { top: minimum(), bottom: next.closedY } },
@@ -317,7 +391,7 @@ export function bottomSheet(
317
391
  releaseClose = () => {}
318
392
  opened = true
319
393
  pendingDismiss = false
320
- handle.accessibilityValue = String(point)
394
+ announce(point)
321
395
  if (!wasOpen) report(() => options.onChange?.(point))
322
396
  },
323
397
  onContactEnd(event) {
@@ -345,6 +419,7 @@ export function bottomSheet(
345
419
  releasePanel = bind(panel, { y: input.y })
346
420
  if (held || !opened) input.pause()
347
421
  }
422
+ installActions()
348
423
  if (
349
424
  !previous ||
350
425
  previous.closedY !== next.closedY ||
@@ -393,15 +468,8 @@ export function bottomSheet(
393
468
  restoreFocus = captureFocus(panel, options.returnFocus)
394
469
  focusCaptured = true
395
470
  }
396
- releaseActions()
397
- const labels = options.accessibility
398
- releaseActions = sheetActions(handle, [
399
- { label: labels?.expand ?? 'Expand', run: () => adjacent(true) },
400
- { label: labels?.collapse ?? 'Collapse', run: () => adjacent(false) },
401
- ...(dismissible
402
- ? [{ label: labels?.dismiss ?? 'Dismiss', run: () => setTarget(null) }]
403
- : []),
404
- ])
471
+ installed = undefined
472
+ installActions()
405
473
  releaseKeyboard()
406
474
  releaseKeyboard = onKeyboard((state) => {
407
475
  keyboard = state
@@ -421,6 +489,7 @@ export function bottomSheet(
421
489
  if (backdrop) backdrop.visibility = 'hidden'
422
490
  releaseActions()
423
491
  releaseActions = () => {}
492
+ installed = undefined
424
493
  releaseKeyboard()
425
494
  releaseKeyboard = () => {}
426
495
  releasePanel()
@@ -486,6 +555,8 @@ export function bottomSheet(
486
555
  Application.android.off(Application.android.activityBackPressedEvent, back)
487
556
  if (content) content.verticalAlignment = original.contentAlignment!
488
557
  panel.height = original.height
558
+ panel.iosIgnoreSafeArea = original.ignoreSafeArea
559
+ panel.paddingBottom = original.paddingBottom
489
560
  panel.marginTop = original.marginTop
490
561
  panel.marginBottom = original.marginBottom
491
562
  panel.verticalAlignment = original.alignment
@@ -504,6 +575,9 @@ export function bottomSheet(
504
575
  }
505
576
  owners.set(panel, control)
506
577
  if (content && points.includes('content')) content.verticalAlignment = 'top'
578
+ // The geometry owns the panel's placement and its rows' distance from the bottom inset;
579
+ // Core would otherwise stretch it to the screen edges or inset its rows a second time.
580
+ panel.iosIgnoreSafeArea = true
507
581
  panel.verticalAlignment = 'top'
508
582
  panel.marginBottom = 0
509
583
  panel.visibility = 'hidden'
@@ -511,7 +585,7 @@ export function bottomSheet(
511
585
  handle.accessible = true
512
586
  handle.accessibilityLabel =
513
587
  options.accessibility?.label ?? (handle.accessibilityLabel || 'Bottom sheet')
514
- handle.accessibilityValue = 'Closed'
588
+ announce(null)
515
589
  if (backdrop) backdrop.visibility = 'hidden'
516
590
  for (const view of [panel, handle, ...(scroll ? [scroll] : [])])
517
591
  view.on('loaded', attach)
@@ -16,6 +16,12 @@ function firstResponder(view: UIView): UIView | undefined {
16
16
  return undefined
17
17
  }
18
18
 
19
+ /** Core reports a view's safe-area insets in device pixels; the sheet works in DIPs. */
20
+ export function hostInsets(view: View): { top: number; bottom: number } {
21
+ const insets = view.getSafeAreaInsets()
22
+ const dip = Utils.layout.toDeviceIndependentPixels
23
+ return { top: dip(insets.top), bottom: dip(insets.bottom) }
24
+ }
19
25
  export function windowHeight(view: View): number {
20
26
  if (isIOS)
21
27
  return (
@@ -130,20 +130,37 @@ export function createAndroidDriver(): Driver {
130
130
  )
131
131
  }
132
132
 
133
- const keyframes = Array.create(android.animation.Keyframe, track.values.length)
134
- track.values.forEach((value, i) => {
135
- keyframes[i] = android.animation.Keyframe.ofFloat(track.times[i]!, value * scale)
136
- })
137
- const holders = Array.create(android.animation.PropertyValuesHolder, 1)
138
- holders[0] = android.animation.PropertyValuesHolder.ofKeyframe(
139
- nativeProperties[track.property],
140
- keyframes,
141
- )
142
-
143
- const animator = android.animation.ObjectAnimator.ofPropertyValuesHolder(
144
- nativeView,
145
- holders,
146
- )
133
+ let animator: android.animation.ObjectAnimator
134
+ if (
135
+ typeof org !== 'undefined' &&
136
+ typeof org.rafaelaugusto?.jolt?.KeyframeFactory === 'function'
137
+ ) {
138
+ animator = org.rafaelaugusto.jolt.KeyframeFactory.create(
139
+ nativeView,
140
+ nativeProperties[track.property],
141
+ track.times,
142
+ track.values,
143
+ scale,
144
+ )
145
+ } else {
146
+ // Keep JS-only updates working with an app binary built before the factory.
147
+ const keyframes = Array.create(android.animation.Keyframe, track.values.length)
148
+ track.values.forEach((value, i) => {
149
+ keyframes[i] = android.animation.Keyframe.ofFloat(
150
+ track.times[i]!,
151
+ value * scale,
152
+ )
153
+ })
154
+ const holders = Array.create(android.animation.PropertyValuesHolder, 1)
155
+ holders[0] = android.animation.PropertyValuesHolder.ofKeyframe(
156
+ nativeProperties[track.property],
157
+ keyframes,
158
+ )
159
+ animator = android.animation.ObjectAnimator.ofPropertyValuesHolder(
160
+ nativeView,
161
+ holders,
162
+ )
163
+ }
147
164
  let restoreLayer: (() => void) | undefined
148
165
  const releaseLayer = () => {
149
166
  restoreLayer?.()
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { timeline } from './sequence.js'
2
2
  export type { TimelineAnimation, TimelineStep, PlayableAnimation } from './sequence.js'
3
3
  export { playbackProgress } from './motion-value.js'
4
- export { animate, decay, set, stop } from './animate.js'
4
+ export { animate, decay, set, stop, toView } from './animate.js'
5
5
  export { bind, motionValue, scrollValue, springValue } from './motion-value.js'
6
6
  export type {
7
7
  Bindings,
@@ -1,4 +1,11 @@
1
1
  import { ReturnScope } from '../shared-navigation/return-signal.js'
2
+ export { interactiveModal } from './modal.js'
3
+ export type {
4
+ InteractiveModal,
5
+ InteractiveModalEvent,
6
+ InteractiveModalOptions,
7
+ InteractiveModalState,
8
+ } from './modal.js'
2
9
  import {
3
10
  Application,
4
11
  Image,
@@ -0,0 +1,306 @@
1
+ import {
2
+ Application,
3
+ isIOS,
4
+ SharedTransition,
5
+ Trace,
6
+ Transition,
7
+ View,
8
+ type NavigationTransition,
9
+ } from '@nativescript/core'
10
+ import { onReducedMotion, reducedMotion, timeScale } from '../config.js'
11
+ import { springPhysics } from '../spring.js'
12
+ import { springs, type Spring } from '../transition.js'
13
+
14
+ export type InteractiveModalState =
15
+ | 'ready'
16
+ | 'presenting'
17
+ | 'idle'
18
+ | 'dragging'
19
+ | 'finishing'
20
+ | 'cancelling'
21
+ | 'closed'
22
+ | 'disposed'
23
+ export interface InteractiveModalEvent {
24
+ state: InteractiveModalState
25
+ outcome?: 'completed' | 'cancelled'
26
+ /** Modal heights per second; supplied only at release. */
27
+ release?: Readonly<{ progress: number; velocity: number }>
28
+ }
29
+ export interface InteractiveModalOptions {
30
+ spring?:
31
+ | keyof typeof springs
32
+ | Pick<Spring, 'duration' | 'bounce' | 'stiffness' | 'damping' | 'mass'>
33
+ /** Synchronous gesture gate. Core's cancelable:false is also respected. */
34
+ canBegin?: () => boolean
35
+ /** Boundaries only; gesture updates and settling execute in Objective-C. */
36
+ onState?: (event: InteractiveModalEvent) => void
37
+ }
38
+ export interface InteractiveModal {
39
+ readonly supported: boolean
40
+ /** Use for one Core showModal / NativeScript-Vue $showModal presentation. */
41
+ readonly transition: NavigationTransition | undefined
42
+ readonly state: InteractiveModalState
43
+ /** Close once, forwarding Core's callback arguments. Queues during opening. */
44
+ close(...args: unknown[]): boolean
45
+ /** Roll back a held drag; an already committed release finishes normally. */
46
+ cancel(): void
47
+ /** Detach without closing the modal. Core closeModal remains available. */
48
+ dispose(): void
49
+ }
50
+
51
+ /** A full-window iOS modal transition using NativeScript's modal owner/delegate. */
52
+ export function interactiveModal(
53
+ options: InteractiveModalOptions = {},
54
+ ): InteractiveModal {
55
+ const input =
56
+ typeof options.spring === 'string' ? springs[options.spring] : options.spring
57
+ if (options.spring !== undefined && !input) throw new TypeError('Unknown modal spring.')
58
+ for (const [key, value] of Object.entries(input ?? {}) as [string, unknown][]) {
59
+ if (!['type', 'duration', 'bounce', 'stiffness', 'damping', 'mass'].includes(key))
60
+ throw new TypeError(`Unsupported modal spring option: ${key}.`)
61
+ if (key === 'type' && value !== 'spring')
62
+ throw new TypeError('Modal motion requires a spring.')
63
+ if (key !== 'type' && (typeof value !== 'number' || !Number.isFinite(value)))
64
+ throw new RangeError(`${key} must be finite.`)
65
+ if (['duration', 'stiffness', 'mass'].includes(key) && (value as number) <= 0)
66
+ throw new RangeError(`${key} must be positive.`)
67
+ if (key === 'damping' && (value as number) < 0)
68
+ throw new RangeError('damping must be nonnegative.')
69
+ }
70
+ const physics = springPhysics(input ?? springs.smooth)
71
+ if (Object.values(physics).some((value) => !Number.isFinite(value)))
72
+ throw new RangeError('Modal spring physics must be finite.')
73
+ const supported =
74
+ isIOS &&
75
+ typeof JoltInteractiveModal !== 'undefined' &&
76
+ typeof View.closedModallyEvent === 'string'
77
+ let state: InteractiveModalState = 'ready'
78
+ let transition: NavigationTransition | undefined
79
+ let view: View | undefined, native: JoltInteractiveModal | undefined
80
+ let start: (() => void) | undefined, offMotion: (() => void) | undefined
81
+ let timer: ReturnType<typeof setTimeout> | undefined
82
+ let pendingClose: unknown[] | undefined
83
+ let disposing = false,
84
+ suspended = false,
85
+ shown = false,
86
+ returning = false
87
+ const update = (value: {
88
+ interactiveBegan?: boolean
89
+ interactiveCancelled?: boolean
90
+ }) => {
91
+ if (transition?.instance) SharedTransition.updateState(transition.instance.id, value)
92
+ }
93
+ const emit = (
94
+ next: InteractiveModalState,
95
+ outcome?: InteractiveModalEvent['outcome'],
96
+ release?: InteractiveModalEvent['release'],
97
+ ) => {
98
+ if (state === 'disposed') return
99
+ state = next
100
+ try {
101
+ options.onState?.({
102
+ state,
103
+ ...(outcome ? { outcome } : {}),
104
+ ...(release ? { release } : {}),
105
+ })
106
+ } catch (error) {
107
+ Trace.write(
108
+ `Interactive modal observer failed: ${String(error)}`,
109
+ 'Jolt',
110
+ Trace.messageType.error,
111
+ )
112
+ }
113
+ }
114
+ const release = () => {
115
+ if (state === 'disposed') return
116
+ if (timer !== undefined) clearTimeout(timer)
117
+ timer = undefined
118
+ view?.off('shownModally', arrived)
119
+ view?.off('closedModally', closed)
120
+ view?.off('unloaded', unloaded)
121
+ Application.off(Application.suspendEvent, suspend)
122
+ Application.off(Application.resumeEvent, resume)
123
+ offMotion?.()
124
+ offMotion = undefined
125
+ native?.dispose()
126
+ native = undefined
127
+ if (transition?.instance) SharedTransition.finishState(transition.instance.id)
128
+ view = undefined
129
+ start = undefined
130
+ pendingClose = undefined
131
+ emit('disposed')
132
+ }
133
+ const cancelled = () => {
134
+ if (state === 'disposed') return
135
+ // UIKit's dismissal completion must observe interactiveCancelled before we
136
+ // clear it. Core clears interactiveBegan after preserving the modal's owner.
137
+ const core = transition?.instance && SharedTransition.getState(transition.instance.id)
138
+ if (core?.interactiveBegan) {
139
+ timer = setTimeout(cancelled, 10)
140
+ return
141
+ }
142
+ timer = undefined
143
+ update({ interactiveCancelled: false })
144
+ emit('idle', 'cancelled')
145
+ if (disposing) release()
146
+ }
147
+ const arrived = () => {
148
+ shown = true
149
+ // UIKit skips animator creation for Core's animated:false. Keep ordinary
150
+ // close usable; no native interactive recognizer is attached in that mode.
151
+ if (state === 'ready') emit('idle')
152
+ if (pendingClose && state === 'idle') {
153
+ const args = pendingClose
154
+ pendingClose = undefined
155
+ control.close(...args)
156
+ }
157
+ }
158
+ const closed = () => {
159
+ if (state === 'disposed' || state === 'closed') return
160
+ emit('closed', 'completed')
161
+ release()
162
+ }
163
+ const checkDetached = () => {
164
+ timer = undefined
165
+ if (
166
+ !suspended &&
167
+ view &&
168
+ !view.isLoaded &&
169
+ !view.viewController?.presentingViewController
170
+ )
171
+ control.dispose()
172
+ }
173
+ const unloaded = () => {
174
+ if (state === 'cancelling') return
175
+ if (timer !== undefined) clearTimeout(timer)
176
+ timer = setTimeout(checkDetached, 0)
177
+ }
178
+ const suspend = () => {
179
+ suspended = true
180
+ native?.cancel()
181
+ }
182
+ const resume = () => {
183
+ suspended = false
184
+ if (state !== 'cancelling') unloaded()
185
+ }
186
+ class ModalMotion extends Transition {
187
+ override setupInteractiveGesture(callback: () => void, modal: View) {
188
+ if (view || state === 'disposed')
189
+ throw new Error('Use a fresh interactiveModal for each presentation.')
190
+ view = modal
191
+ start = callback
192
+ view.on('shownModally', arrived)
193
+ view.on('closedModally', closed)
194
+ view.on('unloaded', unloaded)
195
+ }
196
+ override iosPresentedController(presented: UIViewController) {
197
+ if (native || state === 'disposed' || !view) return undefined
198
+ returning = false
199
+ native = JoltInteractiveModal.attachStiffnessDampingMassShouldBeginOnEvent(
200
+ presented,
201
+ physics.stiffness,
202
+ physics.damping,
203
+ physics.mass,
204
+ () => {
205
+ try {
206
+ return (
207
+ !disposing &&
208
+ shown &&
209
+ state === 'idle' &&
210
+ !suspended &&
211
+ (options.canBegin?.() ?? true) === true
212
+ )
213
+ } catch {
214
+ return false
215
+ }
216
+ },
217
+ (event) => {
218
+ if (state === 'disposed') return
219
+ if (event === 2) {
220
+ update({ interactiveBegan: true, interactiveCancelled: false })
221
+ emit('dragging')
222
+ start?.()
223
+ } else if (event === 3 || event === 4) {
224
+ update({ interactiveCancelled: event === 4 })
225
+ emit(
226
+ event === 3 ? 'finishing' : 'cancelling',
227
+ undefined,
228
+ native && { progress: native.progress, velocity: native.velocity },
229
+ )
230
+ } else if (event === 0) emit(returning ? 'finishing' : 'presenting')
231
+ else if (event === 1) {
232
+ emit('idle')
233
+ if (disposing) release()
234
+ else if (shown) arrived()
235
+ } else if (event === 6) cancelled()
236
+ // Core's closedModally event owns final disposal and callback delivery.
237
+ },
238
+ )
239
+ native.preparePresentingReducedTimeScale(true, reducedMotion(), timeScale())
240
+ return native
241
+ }
242
+ override iosDismissedController() {
243
+ if (!native || state === 'disposed') return undefined
244
+ returning = true
245
+ native.preparePresentingReducedTimeScale(false, reducedMotion(), timeScale())
246
+ return native
247
+ }
248
+ override iosInteractionDismiss() {
249
+ return native?.interactive ? native : undefined
250
+ }
251
+ }
252
+ const control: InteractiveModal = {
253
+ supported,
254
+ get transition() {
255
+ return transition
256
+ },
257
+ get state() {
258
+ return state
259
+ },
260
+ close(...args) {
261
+ if (
262
+ !supported ||
263
+ disposing ||
264
+ ['closed', 'disposed', 'dragging', 'finishing', 'cancelling'].includes(state)
265
+ )
266
+ return false
267
+ if (!shown || state === 'presenting' || state === 'ready') {
268
+ if (pendingClose) return false
269
+ pendingClose = args
270
+ return true
271
+ }
272
+ if (!view) return false
273
+ update({ interactiveBegan: false, interactiveCancelled: false })
274
+ emit('finishing')
275
+ view.closeModal(...args)
276
+ return true
277
+ },
278
+ cancel() {
279
+ native?.cancel()
280
+ },
281
+ dispose() {
282
+ if (state === 'disposed' || disposing) return
283
+ disposing = true
284
+ pendingClose = undefined
285
+ native?.cancel()
286
+ if (
287
+ state !== 'dragging' &&
288
+ state !== 'cancelling' &&
289
+ state !== 'finishing' &&
290
+ state !== 'presenting'
291
+ )
292
+ release()
293
+ },
294
+ }
295
+ if (supported) {
296
+ transition = SharedTransition.custom(new ModalMotion(), {
297
+ interactive: { dismiss: {} },
298
+ })
299
+ Application.on(Application.suspendEvent, suspend)
300
+ Application.on(Application.resumeEvent, resume)
301
+ offMotion = onReducedMotion((value) => {
302
+ if (value) control.cancel()
303
+ })
304
+ }
305
+ return control
306
+ }
@@ -116,6 +116,7 @@ declare class JoltLiveSpring extends NSObject {
116
116
  readonly moving: boolean
117
117
  followMappings(source: JoltLiveSpring, mappings: string): void
118
118
  updateInput(snapshot: string): void
119
+ setInputsRest(snapshots: string, rest: number): void
119
120
  setTarget(value: number): void
120
121
  setReduced(value: boolean): void
121
122
  setPaused(value: boolean): void
@@ -343,6 +344,28 @@ declare class JoltInteractiveNavigation extends NSObject {
343
344
  cancel(): void
344
345
  disposeRestoringSwipe(enabled: boolean): void
345
346
  }
347
+ declare class JoltInteractiveModal extends NSObject {
348
+ static attachStiffnessDampingMassShouldBeginOnEvent(
349
+ controller: UIViewController,
350
+ stiffness: number,
351
+ damping: number,
352
+ mass: number,
353
+ shouldBegin: () => boolean,
354
+ onEvent: (event: number) => void,
355
+ ): JoltInteractiveModal
356
+ static activeCount(): number
357
+ static recognizerCount(): number
358
+ readonly interactive: boolean
359
+ readonly progress: number
360
+ readonly velocity: number
361
+ preparePresentingReducedTimeScale(
362
+ presenting: boolean,
363
+ reduced: boolean,
364
+ timeScale: number,
365
+ ): void
366
+ cancel(): void
367
+ dispose(): void
368
+ }
346
369
 
347
370
  declare class JoltCircleWorld extends NSObject {
348
371
  static createOptions(container: UIView, options: string): JoltCircleWorld