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

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 (50) 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/driver/android.d.ts.map +1 -1
  6. package/dist/driver/android.js +15 -7
  7. package/dist/driver/android.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/interactive-navigation/index.d.ts +2 -0
  13. package/dist/interactive-navigation/index.d.ts.map +1 -1
  14. package/dist/interactive-navigation/index.js +1 -0
  15. package/dist/interactive-navigation/index.js.map +1 -1
  16. package/dist/interactive-navigation/modal.d.ts +34 -0
  17. package/dist/interactive-navigation/modal.d.ts.map +1 -0
  18. package/dist/interactive-navigation/modal.js +253 -0
  19. package/dist/interactive-navigation/modal.js.map +1 -0
  20. package/dist/scroll-header/geometry.d.ts +1 -1
  21. package/dist/scroll-header/geometry.d.ts.map +1 -1
  22. package/dist/scroll-header/geometry.js +7 -2
  23. package/dist/scroll-header/geometry.js.map +1 -1
  24. package/dist/scroll-header/index.d.ts.map +1 -1
  25. package/dist/scroll-header/index.js +13 -0
  26. package/dist/scroll-header/index.js.map +1 -1
  27. package/dist/shapes/index.d.ts +1 -1
  28. package/dist/shapes/index.d.ts.map +1 -1
  29. package/dist/shapes/index.js +23 -9
  30. package/dist/shapes/index.js.map +1 -1
  31. package/dist/types.d.ts +5 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/platforms/android/java/org/rafaelaugusto/jolt/KeyframeFactory.java +26 -0
  35. package/platforms/android/java/org/rafaelaugusto/jolt/ScrollHeader.java +3 -2
  36. package/platforms/android/proguard-rules.pro +1 -0
  37. package/platforms/ios/src/JoltInteractiveModal.h +16 -0
  38. package/platforms/ios/src/JoltInteractiveModal.m +178 -0
  39. package/platforms/ios/src/module.modulemap +1 -0
  40. package/src/android-plugin.d.ts +9 -0
  41. package/src/animate.ts +23 -5
  42. package/src/driver/android.ts +31 -14
  43. package/src/index.ts +1 -1
  44. package/src/interactive-navigation/index.ts +7 -0
  45. package/src/interactive-navigation/modal.ts +306 -0
  46. package/src/ios-plugin.d.ts +22 -0
  47. package/src/scroll-header/geometry.ts +7 -2
  48. package/src/scroll-header/index.ts +12 -0
  49. package/src/shapes/index.ts +20 -9
  50. package/src/types.ts +5 -2
@@ -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
+ }
@@ -343,6 +343,28 @@ declare class JoltInteractiveNavigation extends NSObject {
343
343
  cancel(): void
344
344
  disposeRestoringSwipe(enabled: boolean): void
345
345
  }
346
+ declare class JoltInteractiveModal extends NSObject {
347
+ static attachStiffnessDampingMassShouldBeginOnEvent(
348
+ controller: UIViewController,
349
+ stiffness: number,
350
+ damping: number,
351
+ mass: number,
352
+ shouldBegin: () => boolean,
353
+ onEvent: (event: number) => void,
354
+ ): JoltInteractiveModal
355
+ static activeCount(): number
356
+ static recognizerCount(): number
357
+ readonly interactive: boolean
358
+ readonly progress: number
359
+ readonly velocity: number
360
+ preparePresentingReducedTimeScale(
361
+ presenting: boolean,
362
+ reduced: boolean,
363
+ timeScale: number,
364
+ ): void
365
+ cancel(): void
366
+ dispose(): void
367
+ }
346
368
 
347
369
  declare class JoltCircleWorld extends NSObject {
348
370
  static createOptions(container: UIView, options: string): JoltCircleWorld
@@ -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 {