@rafaelaugusto/jolt 0.1.0-beta.4 → 0.1.0-beta.6

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 (201) hide show
  1. package/README.md +134 -4
  2. package/dist/animate.d.ts +7 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +58 -13
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +3 -0
  15. package/dist/driver/android-color.d.ts.map +1 -1
  16. package/dist/driver/android-color.js +20 -18
  17. package/dist/driver/android-color.js.map +1 -1
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +30 -36
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +9 -0
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +2 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -1
  34. package/dist/driver/ios-color.js +19 -8
  35. package/dist/driver/ios-color.js.map +1 -1
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +47 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +76 -40
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +64 -3
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +243 -19
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +12 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +5 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/items.d.ts +20 -0
  64. package/dist/items.d.ts.map +1 -0
  65. package/dist/items.js +109 -0
  66. package/dist/items.js.map +1 -0
  67. package/dist/keyboard.d.ts +17 -0
  68. package/dist/keyboard.d.ts.map +1 -0
  69. package/dist/keyboard.js +116 -0
  70. package/dist/keyboard.js.map +1 -0
  71. package/dist/label.d.ts +7 -0
  72. package/dist/label.d.ts.map +1 -0
  73. package/dist/label.js +60 -0
  74. package/dist/label.js.map +1 -0
  75. package/dist/liquid.d.ts.map +1 -1
  76. package/dist/liquid.js +2 -1
  77. package/dist/liquid.js.map +1 -1
  78. package/dist/loader.d.ts.map +1 -1
  79. package/dist/loader.js +2 -1
  80. package/dist/loader.js.map +1 -1
  81. package/dist/particles.d.ts.map +1 -1
  82. package/dist/particles.js +4 -3
  83. package/dist/particles.js.map +1 -1
  84. package/dist/path.d.ts +12 -0
  85. package/dist/path.d.ts.map +1 -0
  86. package/dist/path.js +90 -0
  87. package/dist/path.js.map +1 -0
  88. package/dist/playback-group.d.ts +6 -0
  89. package/dist/playback-group.d.ts.map +1 -0
  90. package/dist/playback-group.js +141 -0
  91. package/dist/playback-group.js.map +1 -0
  92. package/dist/playback.d.ts +25 -0
  93. package/dist/playback.d.ts.map +1 -0
  94. package/dist/playback.js +35 -0
  95. package/dist/playback.js.map +1 -0
  96. package/dist/press.d.ts.map +1 -1
  97. package/dist/press.js +3 -3
  98. package/dist/press.js.map +1 -1
  99. package/dist/properties.d.ts +2 -1
  100. package/dist/properties.d.ts.map +1 -1
  101. package/dist/properties.js +14 -1
  102. package/dist/properties.js.map +1 -1
  103. package/dist/reveal.d.ts.map +1 -1
  104. package/dist/reveal.js +3 -2
  105. package/dist/reveal.js.map +1 -1
  106. package/dist/roll-diff.d.ts +22 -0
  107. package/dist/roll-diff.d.ts.map +1 -0
  108. package/dist/roll-diff.js +36 -0
  109. package/dist/roll-diff.js.map +1 -0
  110. package/dist/roll.d.ts +29 -0
  111. package/dist/roll.d.ts.map +1 -0
  112. package/dist/roll.js +442 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/scroll-linked.d.ts +10 -0
  115. package/dist/scroll-linked.d.ts.map +1 -0
  116. package/dist/scroll-linked.js +58 -0
  117. package/dist/scroll-linked.js.map +1 -0
  118. package/dist/sequence.d.ts +10 -5
  119. package/dist/sequence.d.ts.map +1 -1
  120. package/dist/sequence.js +208 -14
  121. package/dist/sequence.js.map +1 -1
  122. package/dist/shimmer.d.ts.map +1 -1
  123. package/dist/shimmer.js +2 -1
  124. package/dist/shimmer.js.map +1 -1
  125. package/dist/svg-path.d.ts +14 -0
  126. package/dist/svg-path.d.ts.map +1 -0
  127. package/dist/svg-path.js +281 -0
  128. package/dist/svg-path.js.map +1 -0
  129. package/dist/symbol-effect.d.ts.map +1 -1
  130. package/dist/symbol-effect.js +4 -3
  131. package/dist/symbol-effect.js.map +1 -1
  132. package/dist/text-layout.d.ts +8 -0
  133. package/dist/text-layout.d.ts.map +1 -1
  134. package/dist/text-layout.js +8 -2
  135. package/dist/text-layout.js.map +1 -1
  136. package/dist/text.d.ts +1 -1
  137. package/dist/text.d.ts.map +1 -1
  138. package/dist/text.js +60 -64
  139. package/dist/text.js.map +1 -1
  140. package/dist/track.d.ts +2 -0
  141. package/dist/track.d.ts.map +1 -1
  142. package/dist/track.js +72 -16
  143. package/dist/track.js.map +1 -1
  144. package/dist/transition.d.ts.map +1 -1
  145. package/dist/transition.js +4 -1
  146. package/dist/transition.js.map +1 -1
  147. package/dist/types.d.ts +22 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/vue/index.d.ts +89 -0
  150. package/dist/vue/index.d.ts.map +1 -0
  151. package/dist/vue/index.js +134 -0
  152. package/dist/vue/index.js.map +1 -0
  153. package/package.json +15 -3
  154. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  155. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  156. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  157. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  158. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  159. package/platforms/android/proguard-rules.pro +7 -0
  160. package/src/android-plugin.d.ts +64 -0
  161. package/src/animate.ts +96 -33
  162. package/src/config.ts +46 -0
  163. package/src/decoration.ts +180 -0
  164. package/src/driver/android-color.ts +22 -20
  165. package/src/driver/android-decoration.ts +182 -0
  166. package/src/driver/android-playback.ts +157 -0
  167. package/src/driver/android.ts +57 -43
  168. package/src/driver/index.ts +15 -0
  169. package/src/driver/ios-color.ts +19 -10
  170. package/src/driver/ios-decoration.ts +332 -0
  171. package/src/driver/ios-playback.ts +51 -0
  172. package/src/driver/ios-shadow.ts +50 -0
  173. package/src/driver/ios.ts +87 -46
  174. package/src/engine.ts +267 -22
  175. package/src/haptics.ts +25 -2
  176. package/src/index.ts +12 -1
  177. package/src/items.ts +140 -0
  178. package/src/keyboard.ts +132 -0
  179. package/src/label.ts +76 -0
  180. package/src/liquid.ts +2 -1
  181. package/src/loader.ts +2 -1
  182. package/src/particles.ts +4 -4
  183. package/src/path.ts +124 -0
  184. package/src/playback-group.ts +143 -0
  185. package/src/playback.ts +62 -0
  186. package/src/press.ts +3 -5
  187. package/src/properties.ts +18 -2
  188. package/src/reveal.ts +3 -3
  189. package/src/roll-diff.ts +57 -0
  190. package/src/roll.ts +515 -0
  191. package/src/scroll-linked.ts +73 -0
  192. package/src/sequence.ts +204 -14
  193. package/src/shimmer.ts +2 -1
  194. package/src/svg-path.ts +355 -0
  195. package/src/symbol-effect.ts +4 -3
  196. package/src/text-layout.ts +14 -2
  197. package/src/text.ts +62 -81
  198. package/src/track.ts +98 -25
  199. package/src/transition.ts +4 -1
  200. package/src/types.ts +23 -1
  201. package/src/vue/index.ts +189 -0
@@ -1,15 +1,27 @@
1
+ import {
2
+ androidPlayback,
3
+ androidClockFactory,
4
+ type AndroidPlayback,
5
+ } from './android-playback.js'
6
+ import { isDecorationProperty } from '../decoration.js'
7
+ import {
8
+ syncAndroidDecoration,
9
+ playAndroidDecoration,
10
+ presentedAndroidDecoration,
11
+ } from './android-decoration.js'
1
12
  import { Utils, type View } from '@nativescript/core'
2
13
  import type { NumericProperty } from '../properties.js'
3
14
  import type { Driver } from './index.js'
4
15
  import { isColorProperty } from '../color.js'
5
- import { playAndroidColor, presentedAndroidColor } from './android-color.js'
16
+ import {
17
+ playAndroidColor,
18
+ presentedAndroidColor,
19
+ syncAndroidColor,
20
+ } from './android-color.js'
6
21
 
7
22
  type NativeView = android.view.View
8
23
 
9
- interface Handle {
10
- cancel(): void
11
- elapsed(): number
12
- }
24
+ type Handle = AndroidPlayback
13
25
 
14
26
  /** Own only layers we create; keep app-configured hardware/software layers and paints intact. */
15
27
  function fadeLayer(view: View, nativeView: NativeView): (() => void) | undefined {
@@ -74,12 +86,19 @@ export function createAndroidDriver(): Driver {
74
86
  rotateX: View.ROTATION_X,
75
87
  rotateY: View.ROTATION_Y,
76
88
  } satisfies Record<NumericProperty, unknown>
89
+ const clock = androidClockFactory()
77
90
  const interpolator = new android.view.animation.LinearInterpolator()
78
91
 
79
92
  return {
80
93
  now: () => android.os.SystemClock.uptimeMillis(),
94
+ clock,
95
+ control(_view, handle, elapsed, paused, command) {
96
+ ;(handle as Handle).control(elapsed, paused, command)
97
+ },
81
98
 
82
99
  play(view, track, _startedAt, onEnd) {
100
+ if (isDecorationProperty(track.property))
101
+ return playAndroidDecoration(view, track, onEnd)
83
102
  if (isColorProperty(track.property)) return playAndroidColor(view, track, onEnd)
84
103
  const nativeView = view.nativeViewProtected as NativeView
85
104
  const scale = unit(track.property)
@@ -104,9 +123,6 @@ export function createAndroidDriver(): Driver {
104
123
  nativeView,
105
124
  holders,
106
125
  )
107
- let cancelled = false
108
- let ended = false
109
- let started = false
110
126
  let restoreLayer: (() => void) | undefined
111
127
  const releaseLayer = () => {
112
128
  restoreLayer?.()
@@ -127,52 +143,36 @@ export function createAndroidDriver(): Driver {
127
143
  : android.animation.ValueAnimator.RESTART,
128
144
  )
129
145
  }
130
- animator.addListener(
131
- new android.animation.Animator.AnimatorListener({
132
- onAnimationStart() {
133
- started = true
134
- if (
135
- !cancelled &&
136
- !ended &&
137
- track.property === 'opacity' &&
138
- track.values.some((value) => value !== track.values[0])
139
- )
140
- restoreLayer = fadeLayer(view, nativeView)
141
- },
142
- onAnimationRepeat() {},
143
- onAnimationCancel() {
144
- cancelled = true
145
- releaseLayer()
146
- },
147
- onAnimationEnd() {
148
- if (ended) return
149
- ended = true
150
- releaseLayer()
151
- onEnd()
152
- },
153
- }),
154
- )
155
-
156
146
  accessors[track.property].set(nativeView, track.values[0]! * scale)
157
- animator.start()
158
- return {
159
- elapsed: () => (started ? track.delay + animator.getCurrentPlayTime() : 0),
160
- cancel() {
161
- // Cancel before native callbacks: Android can send onStart while cancelling a delay.
162
- cancelled = true
163
- releaseLayer()
164
- animator.cancel()
147
+ return androidPlayback(
148
+ animator,
149
+ track,
150
+ onEnd,
151
+ () => {
152
+ if (
153
+ track.property === 'opacity' &&
154
+ track.values.some((value) => value !== track.values[0])
155
+ )
156
+ restoreLayer = fadeLayer(view, nativeView)
165
157
  },
166
- } satisfies Handle
158
+ releaseLayer,
159
+ )
167
160
  },
168
161
 
169
162
  stop(_view, handle) {
170
163
  ;(handle as Handle).cancel()
171
164
  },
172
165
 
166
+ sync(view) {
167
+ syncAndroidDecoration(view)
168
+ syncAndroidColor(view)
169
+ },
170
+
173
171
  elapsed: (handle) => (handle as Handle).elapsed(),
174
172
 
175
173
  presented(view, property) {
174
+ if (isDecorationProperty(property))
175
+ return presentedAndroidDecoration(view, property)
176
176
  if (isColorProperty(property)) return presentedAndroidColor(view, property)
177
177
  const nativeView = view.nativeViewProtected as NativeView | undefined
178
178
  return nativeView ? accessors[property].get(nativeView) / unit(property) : undefined
@@ -207,5 +207,19 @@ export function createAndroidDriver(): Driver {
207
207
  marker.cancel()
208
208
  }
209
209
  },
210
+
211
+ reducedMotion() {
212
+ // Remove animations sets the animator duration scale to zero.
213
+ if (android.os.Build.VERSION.SDK_INT >= 26)
214
+ return !android.animation.ValueAnimator.areAnimatorsEnabled()
215
+ const { Global } = android.provider.Settings
216
+ return (
217
+ Global.getFloat(
218
+ Utils.android.getApplicationContext().getContentResolver(),
219
+ Global.ANIMATOR_DURATION_SCALE,
220
+ 1,
221
+ ) === 0
222
+ )
223
+ },
210
224
  }
211
225
  }
@@ -1,3 +1,4 @@
1
+ import type { PlaybackClock, PlaybackCommand } from '../playback.js'
1
2
  import { isAndroid, type View } from '@nativescript/core'
2
3
  import type { Property } from '../properties.js'
3
4
  import type { Track } from '../track.js'
@@ -15,12 +16,26 @@ export interface Driver {
15
16
  onEnd: (finished?: boolean) => void,
16
17
  ): unknown
17
18
  stop(view: View, handle: unknown): void
19
+ /** A native elapsed-time marker when system animation speed affects time. */
20
+ clock?(duration: number): PlaybackClock
21
+ /** Move only this native track to an elapsed time, optionally holding it. */
22
+ control?(
23
+ view: View,
24
+ handle: unknown,
25
+ elapsed: number,
26
+ paused: boolean,
27
+ command?: PlaybackCommand,
28
+ ): void
29
+ /** Reapply native presentation after a Core model write rebuilds its drawable/layers. */
30
+ sync?(view: View, excluding?: Property): void
18
31
  /** Track time, including its delay, when the native clock differs from `now()`. */
19
32
  elapsed?(handle: unknown): number
20
33
  /** The value on screen, when the platform knows it better than the track does. */
21
34
  presented(view: View, property: Property): number | undefined
22
35
  /** Calls back after `ms` of animation time, respecting platform speed settings. */
23
36
  after(ms: number, callback: () => void): () => void
37
+ /** The platform's accessibility setting: Reduce Motion on iOS, Remove animations on Android. */
38
+ reducedMotion?(): boolean
24
39
  }
25
40
 
26
41
  export function after(ms: number, callback: () => void) {
@@ -2,6 +2,7 @@ import { Color, type View } from '@nativescript/core'
2
2
  import { colorProperty } from '@nativescript/core/ui/styling/style-properties'
3
3
  import { formattedTextProperty, textProperty } from '@nativescript/core/ui/text-base'
4
4
  import { readColor, type ColorProperty } from '../color.js'
5
+ import { iosPlayback } from './ios-playback.js'
5
6
  import type { Track } from '../track.js'
6
7
 
7
8
  type TextView = View & {
@@ -18,6 +19,8 @@ type PaintLayer = {
18
19
  }
19
20
  type NativeView = UIView & { borderLayer?: CAShapeLayer }
20
21
  interface ColorHandle {
22
+ elapsed(): number
23
+ control(elapsed: number, paused: boolean): void
21
24
  stop(): void
22
25
  presented(): number | undefined
23
26
  }
@@ -35,8 +38,8 @@ export function presentedIOSColor(
35
38
  view: View,
36
39
  property: ColorProperty,
37
40
  ): number | undefined {
38
- const current = playing.get(view)?.get(property)?.presented()
39
- if (current !== undefined) return current
41
+ const active = playing.get(view)?.get(property)
42
+ if (active) return active.presented()
40
43
  if (property === 'color' && !view.color) {
41
44
  const label = labelOf(view)
42
45
  if (label?.textColor) return Color.fromIosColor(label.textColor).argb >>> 0
@@ -56,6 +59,7 @@ export function playIOSColor(
56
59
  startedAt: number,
57
60
  onEnd: (finished?: boolean) => void,
58
61
  ): ColorHandle {
62
+ const timing = iosPlayback(track, startedAt)
59
63
  const property = track.property as ColorProperty
60
64
  let target: PaintLayer | undefined
61
65
  let restore: (() => void) | undefined
@@ -107,11 +111,21 @@ export function playIOSColor(
107
111
  }
108
112
  const handle: ColorHandle = {
109
113
  stop: cancel,
114
+ elapsed: timing.elapsed,
115
+ control(time, paused) {
116
+ if (!stopped) {
117
+ timing.control(time, paused)
118
+ attach()
119
+ }
120
+ },
110
121
  presented() {
122
+ // A new CA copy commits at the end of the turn. After controls, the engine
123
+ // clock is authoritative even before presentationLayer catches up.
124
+ if (timing.controlled) return undefined
111
125
  if (!target) return track.values[0]
112
126
  const layer = target.layer.presentationLayer()
113
127
  // Before CA commits its first frame, the model already contains the target.
114
- if (!layer) return track.valueAt(CACurrentMediaTime() * 1000 - startedAt)
128
+ if (!layer) return track.valueAt(timing.elapsed())
115
129
  const color = (layer as unknown as Record<string, interop.Pointer | null>)[
116
130
  target.path
117
131
  ]
@@ -189,12 +203,6 @@ export function playIOSColor(
189
203
  )
190
204
  animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
191
205
  animation.duration = Math.max(track.duration, 1) / 1000
192
- animation.beginTime = layer.convertTimeFromLayer(
193
- (startedAt + track.delay) / 1000,
194
- null,
195
- )
196
- animation.fillMode = kCAFillModeBackwards
197
- animation.removedOnCompletion = track.repeat !== Infinity
198
206
  if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
199
207
  const maximum = Math.min(
200
208
  120,
@@ -211,8 +219,9 @@ export function playIOSColor(
211
219
  animation.autoreverses = track.reverse
212
220
  animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
213
221
  }
222
+ timing.configure(animation, layer)
214
223
  CATransaction.begin()
215
- if (track.repeat !== Infinity)
224
+ if (!timing.paused && track.repeat !== Infinity)
216
225
  CATransaction.setCompletionBlock(() => {
217
226
  if (!stopped && instance === generation) {
218
227
  cleanup()
@@ -0,0 +1,332 @@
1
+ import type { View } from '@nativescript/core'
2
+ import {
3
+ dip,
4
+ isWritingDecoration,
5
+ radiusKeys,
6
+ widthKeys,
7
+ validateDecorationTarget,
8
+ type DecorationProperty,
9
+ } from '../decoration.js'
10
+ import { iosPlayback } from './ios-playback.js'
11
+ import type { Track } from '../track.js'
12
+
13
+ type NativeView = UIView & { outerShadowContainerLayer?: CALayer }
14
+ type Binding = { layer: CALayer; path: string; values: unknown[] }
15
+ interface Handle {
16
+ elapsed(): number
17
+ control(elapsed: number, paused: boolean): void
18
+ track: Track
19
+ stop(): void
20
+ presented(): number | undefined
21
+ attach(): void
22
+ }
23
+ const playing = new WeakMap<View, Map<DecorationProperty, Handle>>()
24
+ const paths = {
25
+ borderRadius: 'cornerRadius',
26
+ borderWidth: 'borderWidth',
27
+ shadowOpacity: 'shadowOpacity',
28
+ shadowRadius: 'shadowRadius',
29
+ shadowOffsetX: 'shadowOffset.width',
30
+ shadowOffsetY: 'shadowOffset.height',
31
+ } as const
32
+
33
+ export function syncIOSDecoration(view: View, excluding?: string) {
34
+ for (const [property, handle] of playing.get(view) ?? [])
35
+ if (property !== excluding) handle.attach()
36
+ }
37
+ export const presentedIOSDecoration = (view: View, property: DecorationProperty) =>
38
+ playing.get(view)?.get(property)?.presented()
39
+
40
+ /** Animate Core's uniform border and its separate outer-shadow layers. */
41
+ export function playIOSDecoration(
42
+ view: View,
43
+ track: Track,
44
+ startedAt: number,
45
+ onEnd: (finished?: boolean) => void,
46
+ ): Handle {
47
+ const timing = iosPlayback(track, startedAt)
48
+ const property = track.property as Exclude<
49
+ DecorationProperty,
50
+ 'elevation' | 'translationZ'
51
+ >
52
+ const native = view.nativeViewProtected as NativeView
53
+ const key = `jolt:${property}`
54
+ let bindings: Binding[] = []
55
+ let stopped = false
56
+ let queued = false
57
+ let generation = 0
58
+ const entries = playing.get(view) ?? new Map<DecorationProperty, Handle>()
59
+ playing.set(view, entries)
60
+ const cleanup = () => {
61
+ if (stopped) return
62
+ stopped = true
63
+ generation++
64
+ view.off('unloaded', cancel)
65
+ view.off('layoutChanged', refresh)
66
+ view.style.off('backgroundInternalChange', refresh)
67
+ for (const event of changes) view.style.off(event, external)
68
+ for (const binding of bindings) binding.layer.removeAnimationForKey(key)
69
+ if (entries.get(property) === handle) entries.delete(property)
70
+ }
71
+ const cancel = () => {
72
+ if (!stopped) {
73
+ cleanup()
74
+ onEnd(false)
75
+ }
76
+ }
77
+ const refresh = () => {
78
+ if (stopped || queued) return
79
+ queued = true
80
+ void Promise.resolve().then(() => {
81
+ queued = false
82
+ if (!stopped && view.nativeViewProtected === native) handle.attach()
83
+ })
84
+ }
85
+ const external = (event: { eventName: string }) => {
86
+ if (isWritingDecoration(view)) return
87
+ const own =
88
+ property === 'borderRadius'
89
+ ? radiusKeys
90
+ : property === 'borderWidth'
91
+ ? widthKeys
92
+ : ['boxShadow']
93
+ void Promise.resolve().then(() => {
94
+ if (stopped) return
95
+ if (own.some((name) => event.eventName === `${name}Change`)) return cancel()
96
+ try {
97
+ validateDecorationTarget(view, property)
98
+ } catch {
99
+ cancel()
100
+ }
101
+ })
102
+ }
103
+ const changes = [
104
+ ...radiusKeys,
105
+ ...widthKeys,
106
+ 'borderTopColor',
107
+ 'borderRightColor',
108
+ 'borderBottomColor',
109
+ 'borderLeftColor',
110
+ 'clipPath',
111
+ 'cornerShape',
112
+ 'boxShadow',
113
+ ].map((name) => `${name}Change`)
114
+ const handle: Handle = {
115
+ track,
116
+ stop: cancel,
117
+ elapsed: timing.elapsed,
118
+ control(time, paused) {
119
+ if (!stopped) {
120
+ timing.control(time, paused)
121
+ handle.attach()
122
+ }
123
+ },
124
+ presented() {
125
+ if (timing.controlled) return undefined
126
+ const binding = bindings[0]
127
+ const layer = binding?.layer.presentationLayer()
128
+ if (!layer) return track.valueAt(timing.elapsed())
129
+ if (property === 'shadowOffsetX') return layer.shadowOffset.width
130
+ if (property === 'shadowOffsetY') return layer.shadowOffset.height
131
+ return (layer as unknown as Record<string, number>)[paths[property]]!
132
+ },
133
+ attach() {
134
+ if (stopped) return
135
+ const instance = ++generation
136
+ for (const binding of bindings) binding.layer.removeAnimationForKey(key)
137
+ bindings = []
138
+ // Path clipping and spread are nonlinear. Sample even a linear radius
139
+ // tween so its shadow and inner cutout follow the capped native corner.
140
+ const steps = Math.max(1, Math.min(6000, Math.ceil((track.duration * 60) / 1000)))
141
+ const times =
142
+ property === 'borderRadius'
143
+ ? [
144
+ ...new Set([
145
+ ...track.times,
146
+ ...Array.from({ length: steps + 1 }, (_, i) => i / steps),
147
+ ]),
148
+ ].sort((a, b) => a - b)
149
+ : [...track.times]
150
+ const values =
151
+ property === 'borderRadius'
152
+ ? times.map((time) =>
153
+ time === 1
154
+ ? track.values.at(-1)!
155
+ : track.valueAt(track.delay + time * track.duration),
156
+ )
157
+ : [...track.values]
158
+ if (property === 'borderRadius' || property === 'borderWidth') {
159
+ bindings.push({
160
+ layer: native.layer,
161
+ path: paths[property],
162
+ values:
163
+ property === 'borderRadius' && native.bounds?.size
164
+ ? values.map((value) =>
165
+ Math.min(
166
+ value,
167
+ native.bounds.size.width / 2,
168
+ native.bounds.size.height / 2,
169
+ ),
170
+ )
171
+ : values,
172
+ })
173
+ if (property === 'borderRadius') {
174
+ const shadows = view.boxShadow
175
+ const layers = native.outerShadowContainerLayer?.sublayers
176
+ if (Array.isArray(shadows) && layers) {
177
+ for (let i = 0; i < Math.min(shadows.length, layers.count); i++) {
178
+ const layer = layers.objectAtIndex(i)
179
+ const spread = dip(shadows[i]!.spreadRadius)
180
+ bindings.push({
181
+ layer,
182
+ path: 'shadowPath',
183
+ values: values.map((radius) =>
184
+ roundedPath(native.bounds, radius, spread),
185
+ ),
186
+ })
187
+ if (layer.mask)
188
+ bindings.push({
189
+ layer: layer.mask,
190
+ path: 'path',
191
+ values: values.map((radius) =>
192
+ shadowMask(native.bounds, radius, maskExtent(view, entries)),
193
+ ),
194
+ })
195
+ }
196
+ }
197
+ }
198
+ } else {
199
+ const layer = native.outerShadowContainerLayer?.sublayers?.firstObject
200
+ if (!layer) return cancel()
201
+ bindings.push({ layer, path: paths[property], values: [...track.values] })
202
+ // Core's mask bounds are sized for the final blur/offset. Give intermediate
203
+ // samples room too. Radius owns the animated inner edge when it is active.
204
+ if (layer.mask && !entries.has('borderRadius')) {
205
+ CATransaction.begin()
206
+ CATransaction.setDisableActions(true)
207
+ ;(layer.mask as CAShapeLayer).path = shadowMask(
208
+ native.bounds,
209
+ native.layer.cornerRadius,
210
+ maskExtent(view, entries),
211
+ )
212
+ CATransaction.commit()
213
+ }
214
+ }
215
+ CATransaction.begin()
216
+ CATransaction.setDisableActions(true)
217
+ if (!timing.paused && track.repeat !== Infinity)
218
+ CATransaction.setCompletionBlock(() => {
219
+ if (stopped || generation !== instance) return
220
+ cleanup()
221
+ onEnd()
222
+ })
223
+ // Core treats a zero-alpha shadow as opaque. Commit the native endpoint
224
+ // explicitly too; otherwise a fade to zero would flash back on completion.
225
+ const paint = bindings[0]!.layer
226
+ if (property === 'shadowOpacity') paint.shadowOpacity = track.to
227
+ else if (property === 'shadowRadius') paint.shadowRadius = track.to
228
+ else if (property === 'shadowOffsetX')
229
+ paint.shadowOffset = CGSizeMake(track.to, paint.shadowOffset.height)
230
+ else if (property === 'shadowOffsetY')
231
+ paint.shadowOffset = CGSizeMake(paint.shadowOffset.width, track.to)
232
+ for (const { layer, path, values } of bindings) {
233
+ const animation = CAKeyframeAnimation.animationWithKeyPath(path)
234
+ animation.values = NSArray.arrayWithArray(values)
235
+ animation.keyTimes = NSArray.arrayWithArray(times)
236
+ animation.duration = Math.max(1, track.duration) / 1000
237
+ if (track.repeat > 0) {
238
+ animation.autoreverses = track.reverse
239
+ animation.repeatCount = track.reverse
240
+ ? (track.repeat + 1) / 2
241
+ : track.repeat + 1
242
+ }
243
+ timing.configure(animation, layer)
244
+ if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
245
+ const max = Math.min(
246
+ 120,
247
+ native.window?.screen.maximumFramesPerSecond ??
248
+ UIScreen.mainScreen.maximumFramesPerSecond,
249
+ )
250
+ animation.preferredFrameRateRange = CAFrameRateRangeMake(
251
+ Math.min(80, max),
252
+ max,
253
+ max,
254
+ )
255
+ }
256
+ layer.addAnimationForKey(animation, key)
257
+ }
258
+ CATransaction.commit()
259
+ },
260
+ }
261
+ entries.set(property, handle)
262
+ view.on('unloaded', cancel)
263
+ view.on('layoutChanged', refresh)
264
+ view.style.on('backgroundInternalChange', refresh)
265
+ for (const event of changes) view.style.on(event, external)
266
+ // All model endpoints have been committed before driver.play(). Each handle
267
+ // follows the same timeline even if Core redraws its layers during layout.
268
+ for (const active of entries.values()) active.attach()
269
+ return handle
270
+ }
271
+
272
+ function maskExtent(view: View, entries: Map<DecorationProperty, Handle>): number {
273
+ const shadows = Array.isArray(view.boxShadow) ? view.boxShadow : []
274
+ const maximum = (property: DecorationProperty, fallback: number) =>
275
+ Math.max(
276
+ Math.abs(fallback),
277
+ ...(entries.get(property)?.track.values.map(Math.abs) ?? []),
278
+ )
279
+ return Math.max(
280
+ 1,
281
+ ...shadows.map(
282
+ (shadow) =>
283
+ maximum('shadowRadius', dip(shadow.blurRadius) / 2) * 6 +
284
+ Math.abs(dip(shadow.spreadRadius)) +
285
+ maximum('shadowOffsetX', dip(shadow.offsetX)) +
286
+ maximum('shadowOffsetY', dip(shadow.offsetY)) +
287
+ 1,
288
+ ),
289
+ )
290
+ }
291
+
292
+ /** Keep identical path topology at radius zero so CA can interpolate it. */
293
+ function roundedPath(bounds: CGRect, radius: number, spread = 0): interop.Pointer {
294
+ const rect = CGRectInset(bounds, -spread, -spread)
295
+ const r = Math.min(
296
+ Math.max(0, Math.min(radius, bounds.size.width / 2, bounds.size.height / 2) + spread),
297
+ Math.max(0, rect.size.width / 2),
298
+ Math.max(0, rect.size.height / 2),
299
+ )
300
+ const x = rect.origin.x,
301
+ y = rect.origin.y,
302
+ right = x + rect.size.width,
303
+ bottom = y + rect.size.height
304
+ const k = r * 0.5522847498307936
305
+ const path = CGPathCreateMutable()
306
+ CGPathMoveToPoint(path, null, x + r, y)
307
+ CGPathAddLineToPoint(path, null, right - r, y)
308
+ CGPathAddCurveToPoint(path, null, right - r + k, y, right, y + r - k, right, y + r)
309
+ CGPathAddLineToPoint(path, null, right, bottom - r)
310
+ CGPathAddCurveToPoint(
311
+ path,
312
+ null,
313
+ right,
314
+ bottom - r + k,
315
+ right - r + k,
316
+ bottom,
317
+ right - r,
318
+ bottom,
319
+ )
320
+ CGPathAddLineToPoint(path, null, x + r, bottom)
321
+ CGPathAddCurveToPoint(path, null, x + r - k, bottom, x, bottom - r + k, x, bottom - r)
322
+ CGPathAddLineToPoint(path, null, x, y + r)
323
+ CGPathAddCurveToPoint(path, null, x, y + r - k, x + r - k, y, x + r, y)
324
+ CGPathCloseSubpath(path)
325
+ return path
326
+ }
327
+ function shadowMask(bounds: CGRect, radius: number, extent: number): interop.Pointer {
328
+ const path = CGPathCreateMutable()
329
+ CGPathAddPath(path, null, roundedPath(bounds, radius))
330
+ CGPathAddRect(path, null, CGRectInset(bounds, -extent, -extent))
331
+ return path
332
+ }
@@ -0,0 +1,51 @@
1
+ import type { Track } from '../track.js'
2
+
3
+ /** Each CAAnimation owns its clock; the view and its children keep their speed. */
4
+ export function iosPlayback(track: Track, startedAt: number) {
5
+ let anchor = startedAt
6
+ let offset = 0
7
+ let paused = false
8
+ let controlled = false
9
+ const elapsed = () => offset + (paused ? 0 : CACurrentMediaTime() * 1000 - anchor)
10
+ return {
11
+ elapsed,
12
+ get controlled() {
13
+ return controlled
14
+ },
15
+ get paused() {
16
+ return paused
17
+ },
18
+ control(time: number, hold: boolean) {
19
+ controlled = true
20
+ offset = time
21
+ anchor = CACurrentMediaTime() * 1000
22
+ paused = hold
23
+ },
24
+ configure(animation: CAKeyframeAnimation, layer: CALayer) {
25
+ const now = CACurrentMediaTime()
26
+ const position = elapsed()
27
+ animation.speed = paused ? 0 : 1
28
+ animation.timeOffset = paused ? Math.max(0, position - track.delay) / 1000 : 0
29
+ animation.beginTime = layer.convertTimeFromLayer(
30
+ paused ? now : now - position / 1000 + track.delay / 1000,
31
+ null,
32
+ )
33
+ animation.fillMode = paused ? 'both' : kCAFillModeBackwards
34
+ animation.removedOnCompletion = !paused && track.repeat !== Infinity
35
+ // With speed 0, CA folds an exact duration boundary back to the first
36
+ // frame. Hold the actual endpoint with a constant copy, also when a
37
+ // shorter property waits at its end while another one is being scrubbed.
38
+ if (paused && Number.isFinite(track.end) && position >= track.end) {
39
+ const values = animation.values!
40
+ const index = track.reverse && track.repeat % 2 === 1 ? 0 : values.count - 1
41
+ const value = values.objectAtIndex(index)
42
+ animation.values = NSArray.arrayWithArray([value, value])
43
+ animation.keyTimes = NSArray.arrayWithArray([0, 1])
44
+ animation.duration = 1
45
+ animation.repeatCount = 0
46
+ animation.autoreverses = false
47
+ animation.timeOffset = 0
48
+ }
49
+ },
50
+ }
51
+ }