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

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 (105) hide show
  1. package/README.md +24 -1
  2. package/dist/animate.d.ts +3 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +11 -3
  5. package/dist/animate.js.map +1 -1
  6. package/dist/decoration.d.ts +18 -0
  7. package/dist/decoration.d.ts.map +1 -0
  8. package/dist/decoration.js +155 -0
  9. package/dist/decoration.js.map +1 -0
  10. package/dist/driver/android-color.d.ts +3 -0
  11. package/dist/driver/android-color.d.ts.map +1 -1
  12. package/dist/driver/android-color.js +20 -18
  13. package/dist/driver/android-color.js.map +1 -1
  14. package/dist/driver/android-decoration.d.ts +22 -0
  15. package/dist/driver/android-decoration.d.ts.map +1 -0
  16. package/dist/driver/android-decoration.js +139 -0
  17. package/dist/driver/android-decoration.js.map +1 -0
  18. package/dist/driver/android-playback.d.ts +13 -0
  19. package/dist/driver/android-playback.d.ts.map +1 -0
  20. package/dist/driver/android-playback.js +141 -0
  21. package/dist/driver/android-playback.js.map +1 -0
  22. package/dist/driver/android.d.ts.map +1 -1
  23. package/dist/driver/android.js +23 -36
  24. package/dist/driver/android.js.map +1 -1
  25. package/dist/driver/index.d.ts +7 -0
  26. package/dist/driver/index.d.ts.map +1 -1
  27. package/dist/driver/index.js.map +1 -1
  28. package/dist/driver/ios-color.d.ts +2 -0
  29. package/dist/driver/ios-color.d.ts.map +1 -1
  30. package/dist/driver/ios-color.js +19 -8
  31. package/dist/driver/ios-color.js.map +1 -1
  32. package/dist/driver/ios-decoration.d.ts +17 -0
  33. package/dist/driver/ios-decoration.d.ts.map +1 -0
  34. package/dist/driver/ios-decoration.js +271 -0
  35. package/dist/driver/ios-decoration.js.map +1 -0
  36. package/dist/driver/ios-playback.d.ts +10 -0
  37. package/dist/driver/ios-playback.d.ts.map +1 -0
  38. package/dist/driver/ios-playback.js +33 -0
  39. package/dist/driver/ios-playback.js.map +1 -0
  40. package/dist/driver/ios-shadow.d.ts +6 -0
  41. package/dist/driver/ios-shadow.d.ts.map +1 -0
  42. package/dist/driver/ios-shadow.js +52 -0
  43. package/dist/driver/ios-shadow.js.map +1 -0
  44. package/dist/driver/ios.d.ts.map +1 -1
  45. package/dist/driver/ios.js +75 -40
  46. package/dist/driver/ios.js.map +1 -1
  47. package/dist/engine.d.ts +48 -2
  48. package/dist/engine.d.ts.map +1 -1
  49. package/dist/engine.js +210 -18
  50. package/dist/engine.js.map +1 -1
  51. package/dist/haptics.d.ts +4 -1
  52. package/dist/haptics.d.ts.map +1 -1
  53. package/dist/haptics.js +27 -3
  54. package/dist/haptics.js.map +1 -1
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/playback-group.d.ts +6 -0
  58. package/dist/playback-group.d.ts.map +1 -0
  59. package/dist/playback-group.js +113 -0
  60. package/dist/playback-group.js.map +1 -0
  61. package/dist/playback.d.ts +25 -0
  62. package/dist/playback.d.ts.map +1 -0
  63. package/dist/playback.js +35 -0
  64. package/dist/playback.js.map +1 -0
  65. package/dist/properties.d.ts +2 -1
  66. package/dist/properties.d.ts.map +1 -1
  67. package/dist/properties.js +14 -1
  68. package/dist/properties.js.map +1 -1
  69. package/dist/sequence.d.ts.map +1 -1
  70. package/dist/sequence.js +6 -0
  71. package/dist/sequence.js.map +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +10 -1
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts.map +1 -1
  76. package/dist/transition.js +4 -1
  77. package/dist/transition.js.map +1 -1
  78. package/dist/types.d.ts +15 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  82. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  83. package/platforms/android/proguard-rules.pro +1 -0
  84. package/src/animate.ts +18 -5
  85. package/src/decoration.ts +180 -0
  86. package/src/driver/android-color.ts +22 -20
  87. package/src/driver/android-decoration.ts +182 -0
  88. package/src/driver/android-playback.ts +157 -0
  89. package/src/driver/android.ts +43 -43
  90. package/src/driver/index.ts +13 -0
  91. package/src/driver/ios-color.ts +19 -10
  92. package/src/driver/ios-decoration.ts +332 -0
  93. package/src/driver/ios-playback.ts +37 -0
  94. package/src/driver/ios-shadow.ts +50 -0
  95. package/src/driver/ios.ts +85 -46
  96. package/src/engine.ts +220 -20
  97. package/src/haptics.ts +25 -2
  98. package/src/index.ts +1 -0
  99. package/src/playback-group.ts +110 -0
  100. package/src/playback.ts +62 -0
  101. package/src/properties.ts +18 -2
  102. package/src/sequence.ts +5 -0
  103. package/src/track.ts +9 -1
  104. package/src/transition.ts +4 -1
  105. package/src/types.ts +15 -0
@@ -0,0 +1,182 @@
1
+ import { Utils, type View } from '@nativescript/core'
2
+ import {
3
+ isWritingDecoration,
4
+ radiusKeys,
5
+ widthKeys,
6
+ validateDecorationTarget,
7
+ type DecorationProperty,
8
+ } from '../decoration.js'
9
+ import { androidPlayback, type AndroidPlayback } from './android-playback.js'
10
+ import type { PlaybackCommand } from '../playback.js'
11
+ import type { Track } from '../track.js'
12
+ interface NativeTarget {
13
+ getValue(): number
14
+ setValue(value: number): void
15
+ isSupported(): boolean
16
+ acquire(): void
17
+ release(): void
18
+ }
19
+ interface Handle {
20
+ control(elapsed: number, paused: boolean, command?: PlaybackCommand): void
21
+ cancel(): void
22
+ elapsed(): number
23
+ target: NativeTarget
24
+ }
25
+ const playing = new WeakMap<View, Map<DecorationProperty, Handle>>()
26
+ const ids = { borderRadius: 0, borderWidth: 1, elevation: 2, translationZ: 3 } as const
27
+ export function syncAndroidDecoration(view: View) {
28
+ for (const handle of playing.get(view)?.values() ?? [])
29
+ handle.target.setValue(handle.target.getValue())
30
+ }
31
+ export const presentedAndroidDecoration = (view: View, property: DecorationProperty) => {
32
+ const target = playing.get(view)?.get(property)?.target
33
+ return target ? target.getValue() / Utils.layout.getDisplayDensity() : undefined
34
+ }
35
+ export function playAndroidDecoration(
36
+ view: View,
37
+ track: Track,
38
+ onEnd: (finished?: boolean) => void,
39
+ ): Handle {
40
+ const property = track.property as keyof typeof ids
41
+ const density = Utils.layout.getDisplayDensity()
42
+ const bridge = globalThis as unknown as {
43
+ org: {
44
+ rafaelaugusto: {
45
+ jolt: {
46
+ DecorationTarget: new (
47
+ view: android.view.View,
48
+ property: number,
49
+ value: number,
50
+ borderColor: number,
51
+ ) => NativeTarget
52
+ }
53
+ }
54
+ }
55
+ }
56
+ let target: NativeTarget
57
+ try {
58
+ target = new bridge.org.rafaelaugusto.jolt.DecorationTarget(
59
+ view.nativeViewProtected,
60
+ ids[property],
61
+ track.values[0]! * density,
62
+ view.borderTopColor?.argb ?? 0,
63
+ )
64
+ } catch {
65
+ onEnd(false)
66
+ return {
67
+ cancel() {},
68
+ control() {},
69
+ elapsed: () => 0,
70
+ target: {
71
+ getValue: () => track.to * density,
72
+ setValue() {},
73
+ isSupported: () => false,
74
+ acquire() {},
75
+ release() {},
76
+ },
77
+ }
78
+ }
79
+ const frames = Array.create(android.animation.Keyframe, track.values.length)
80
+ track.values.forEach((value, index) => {
81
+ frames[index] = android.animation.Keyframe.ofFloat(
82
+ track.times[index]!,
83
+ value * density,
84
+ )
85
+ })
86
+ const holders = Array.create(android.animation.PropertyValuesHolder, 1)
87
+ holders[0] = android.animation.PropertyValuesHolder.ofKeyframe('value', frames)
88
+ const animator = android.animation.ObjectAnimator.ofPropertyValuesHolder(
89
+ target as unknown as java.lang.Object,
90
+ holders,
91
+ )
92
+ animator.setDuration(Math.max(track.duration, 1))
93
+ animator.setStartDelay(track.delay)
94
+ animator.setInterpolator(new android.view.animation.LinearInterpolator())
95
+ if (track.repeat > 0) {
96
+ animator.setRepeatCount(
97
+ Number.isFinite(track.repeat)
98
+ ? track.repeat
99
+ : android.animation.ValueAnimator.INFINITE,
100
+ )
101
+ animator.setRepeatMode(
102
+ track.reverse
103
+ ? android.animation.ValueAnimator.REVERSE
104
+ : android.animation.ValueAnimator.RESTART,
105
+ )
106
+ }
107
+ let stopped = false
108
+ const changes = [
109
+ ...radiusKeys,
110
+ ...widthKeys,
111
+ 'clipPath',
112
+ 'cornerShape',
113
+ 'boxShadow',
114
+ 'androidElevation',
115
+ ].map((name) => `${name}Change`)
116
+ const cleanup = () => {
117
+ if (stopped) return
118
+ stopped = true
119
+ view.off('unloaded', cancel)
120
+ for (const event of changes) view.style.off(event, external)
121
+ target.release()
122
+ if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
123
+ }
124
+ let player: AndroidPlayback | undefined
125
+ const cancel = () => {
126
+ if (!stopped) {
127
+ cleanup()
128
+ player?.cancel()
129
+ onEnd(false)
130
+ }
131
+ }
132
+ const external = (event: { eventName: string }) => {
133
+ if (isWritingDecoration(view)) return
134
+ const own =
135
+ property === 'borderRadius'
136
+ ? radiusKeys
137
+ : property === 'borderWidth'
138
+ ? widthKeys
139
+ : property === 'elevation'
140
+ ? ['androidElevation']
141
+ : []
142
+ void Promise.resolve().then(() => {
143
+ if (stopped) return
144
+ if (own.some((name) => event.eventName === `${name}Change`)) return cancel()
145
+ try {
146
+ validateDecorationTarget(view, property)
147
+ } catch {
148
+ cancel()
149
+ }
150
+ })
151
+ }
152
+ const handle: Handle = {
153
+ target,
154
+ elapsed: () => player?.elapsed() ?? 0,
155
+ control(time, paused, command) {
156
+ player?.control(time, paused, command)
157
+ },
158
+ cancel() {
159
+ cleanup()
160
+ player?.cancel()
161
+ },
162
+ }
163
+ const entries = playing.get(view) ?? new Map<DecorationProperty, Handle>()
164
+ playing.set(view, entries)
165
+ entries.set(property, handle)
166
+ view.on('unloaded', cancel)
167
+ for (const event of changes) view.style.on(event, external)
168
+ if (target.isSupported()) {
169
+ target.acquire()
170
+ target.setValue(track.values[0]! * density)
171
+ player = androidPlayback(animator, track, () => {
172
+ if (!stopped) {
173
+ cleanup()
174
+ onEnd()
175
+ }
176
+ })
177
+ } else {
178
+ cleanup()
179
+ onEnd(false)
180
+ }
181
+ return handle
182
+ }
@@ -0,0 +1,157 @@
1
+ import type { Track } from '../track.js'
2
+ import type { PlaybackClock, PlaybackCommand } from '../playback.js'
3
+
4
+ export interface AndroidPlayback {
5
+ cancel(): void
6
+ elapsed(): number
7
+ control(elapsed: number, paused: boolean, command?: PlaybackCommand): void
8
+ }
9
+
10
+ /** Reuse native keyframes when seeking, including rewinding into a start delay. */
11
+ export function androidPlayback(
12
+ animator: android.animation.ValueAnimator,
13
+ track: Track,
14
+ onEnd: () => void,
15
+ onStart?: () => void,
16
+ onRelease?: () => void,
17
+ ): AndroidPlayback {
18
+ let suppressed = false
19
+ let stopped = false
20
+ let started = false
21
+ let acquired = false
22
+ let origin = 0
23
+ let delay = track.delay
24
+ let held: number | undefined
25
+ animator.addListener(
26
+ new android.animation.Animator.AnimatorListener({
27
+ onAnimationStart() {
28
+ if (!suppressed && !stopped) {
29
+ started = true
30
+ if (!acquired) {
31
+ acquired = true
32
+ onStart?.()
33
+ }
34
+ }
35
+ },
36
+ onAnimationRepeat() {},
37
+ onAnimationCancel() {
38
+ if (!suppressed && !stopped) onRelease?.()
39
+ },
40
+ onAnimationEnd() {
41
+ if (suppressed || stopped || held !== undefined) return
42
+ stopped = true
43
+ onRelease?.()
44
+ onEnd()
45
+ },
46
+ }),
47
+ )
48
+ const elapsed = () =>
49
+ held ?? (started ? origin + delay + animator.getCurrentPlayTime() : origin)
50
+ const control = (
51
+ position: number,
52
+ paused: boolean,
53
+ command: PlaybackCommand = 'seek',
54
+ ) => {
55
+ if (stopped) return
56
+ if (command === 'pause') {
57
+ held = position
58
+ animator.pause()
59
+ return
60
+ }
61
+ if (command === 'play') {
62
+ held = undefined
63
+ animator.resume()
64
+ return
65
+ }
66
+ suppressed = true
67
+ animator.cancel()
68
+ started = false
69
+ origin = Math.min(position, track.delay)
70
+ delay = Math.max(0, track.delay - position)
71
+ animator.setStartDelay(delay)
72
+ held = paused ? position : undefined
73
+ suppressed = false
74
+ animator.start()
75
+ if (paused) animator.pause()
76
+ if (position >= track.delay)
77
+ animator.setCurrentPlayTime(Math.max(0, position - track.delay))
78
+ }
79
+ animator.start()
80
+ return {
81
+ elapsed,
82
+ control,
83
+ cancel() {
84
+ if (stopped) return
85
+ stopped = true
86
+ onRelease?.()
87
+ animator.cancel()
88
+ },
89
+ }
90
+ }
91
+
92
+ /** Share one native clock among active animations. Pause it when every owner
93
+ * is paused, and dispose it when the last owner settles. No frame callbacks. */
94
+ export function androidClockFactory() {
95
+ let animator: android.animation.ValueAnimator | undefined
96
+ let owners = 0,
97
+ running = 0,
98
+ accumulated = 0
99
+ const now = () => accumulated + (animator?.getCurrentPlayTime() ?? 0)
100
+ const start = () => {
101
+ if (animator) return
102
+ const values = Array.create('float', 2)
103
+ values[0] = 0
104
+ values[1] = 1
105
+ animator = android.animation.ValueAnimator.ofFloat(values)
106
+ animator.setDuration(60_000)
107
+ animator.setRepeatCount(android.animation.ValueAnimator.INFINITE)
108
+ animator.start()
109
+ }
110
+ const stop = () => {
111
+ accumulated = now()
112
+ animator?.cancel()
113
+ animator = undefined
114
+ }
115
+ return (_duration: number): PlaybackClock => {
116
+ start()
117
+ owners++
118
+ running++
119
+ let offset = 0,
120
+ anchor = now(),
121
+ paused = false,
122
+ stopped = false
123
+ const time = () => offset + (paused || stopped ? 0 : now() - anchor)
124
+ const pause = () => {
125
+ if (paused || stopped) return
126
+ offset = time()
127
+ paused = true
128
+ // getCurrentPlayTime keeps counting while a ValueAnimator is paused and
129
+ // corrects itself only on its next frame. Start a fresh marker on resume
130
+ // so command-time reads cannot jump forward and then backward.
131
+ if (--running === 0) stop()
132
+ }
133
+ return {
134
+ time,
135
+ pause,
136
+ play() {
137
+ if (!paused || stopped) return
138
+ if (running++ === 0) start()
139
+ anchor = now()
140
+ paused = false
141
+ },
142
+ seek(ms) {
143
+ offset = ms
144
+ anchor = now()
145
+ },
146
+ cancel() {
147
+ if (stopped) return
148
+ pause()
149
+ stopped = true
150
+ if (--owners === 0) {
151
+ stop()
152
+ accumulated = 0
153
+ }
154
+ },
155
+ }
156
+ }
157
+ }
@@ -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
@@ -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,6 +16,18 @@ 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. */
@@ -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,7 @@ 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
206
+ timing.configure(animation, layer)
198
207
  if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
199
208
  const maximum = Math.min(
200
209
  120,
@@ -212,7 +221,7 @@ export function playIOSColor(
212
221
  animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
213
222
  }
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()