@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.
- package/README.md +134 -4
- package/dist/animate.d.ts +7 -4
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +58 -13
- package/dist/animate.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/decoration.d.ts +18 -0
- package/dist/decoration.d.ts.map +1 -0
- package/dist/decoration.js +155 -0
- package/dist/decoration.js.map +1 -0
- package/dist/driver/android-color.d.ts +3 -0
- package/dist/driver/android-color.d.ts.map +1 -1
- package/dist/driver/android-color.js +20 -18
- package/dist/driver/android-color.js.map +1 -1
- package/dist/driver/android-decoration.d.ts +22 -0
- package/dist/driver/android-decoration.d.ts.map +1 -0
- package/dist/driver/android-decoration.js +139 -0
- package/dist/driver/android-decoration.js.map +1 -0
- package/dist/driver/android-playback.d.ts +13 -0
- package/dist/driver/android-playback.d.ts.map +1 -0
- package/dist/driver/android-playback.js +141 -0
- package/dist/driver/android-playback.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +30 -36
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +9 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +2 -0
- package/dist/driver/ios-color.d.ts.map +1 -1
- package/dist/driver/ios-color.js +19 -8
- package/dist/driver/ios-color.js.map +1 -1
- package/dist/driver/ios-decoration.d.ts +17 -0
- package/dist/driver/ios-decoration.d.ts.map +1 -0
- package/dist/driver/ios-decoration.js +271 -0
- package/dist/driver/ios-decoration.js.map +1 -0
- package/dist/driver/ios-playback.d.ts +10 -0
- package/dist/driver/ios-playback.d.ts.map +1 -0
- package/dist/driver/ios-playback.js +47 -0
- package/dist/driver/ios-playback.js.map +1 -0
- package/dist/driver/ios-shadow.d.ts +6 -0
- package/dist/driver/ios-shadow.d.ts.map +1 -0
- package/dist/driver/ios-shadow.js +52 -0
- package/dist/driver/ios-shadow.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +76 -40
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +64 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +243 -19
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +4 -1
- package/dist/haptics.d.ts.map +1 -1
- package/dist/haptics.js +27 -3
- package/dist/haptics.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +17 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +116 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/liquid.d.ts.map +1 -1
- package/dist/liquid.js +2 -1
- package/dist/liquid.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -1
- package/dist/loader.js.map +1 -1
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +4 -3
- package/dist/particles.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts +6 -0
- package/dist/playback-group.d.ts.map +1 -0
- package/dist/playback-group.js +141 -0
- package/dist/playback-group.js.map +1 -0
- package/dist/playback.d.ts +25 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +35 -0
- package/dist/playback.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -3
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +2 -1
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +14 -1
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts.map +1 -1
- package/dist/reveal.js +3 -2
- package/dist/reveal.js.map +1 -1
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +208 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts.map +1 -1
- package/dist/shimmer.js +2 -1
- package/dist/shimmer.js.map +1 -1
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts.map +1 -1
- package/dist/symbol-effect.js +4 -3
- package/dist/symbol-effect.js.map +1 -1
- package/dist/text-layout.d.ts +8 -0
- package/dist/text-layout.d.ts.map +1 -1
- package/dist/text-layout.js +8 -2
- package/dist/text-layout.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +60 -64
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +2 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +72 -16
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +4 -1
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +89 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +134 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +15 -3
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
- package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +7 -0
- package/src/android-plugin.d.ts +64 -0
- package/src/animate.ts +96 -33
- package/src/config.ts +46 -0
- package/src/decoration.ts +180 -0
- package/src/driver/android-color.ts +22 -20
- package/src/driver/android-decoration.ts +182 -0
- package/src/driver/android-playback.ts +157 -0
- package/src/driver/android.ts +57 -43
- package/src/driver/index.ts +15 -0
- package/src/driver/ios-color.ts +19 -10
- package/src/driver/ios-decoration.ts +332 -0
- package/src/driver/ios-playback.ts +51 -0
- package/src/driver/ios-shadow.ts +50 -0
- package/src/driver/ios.ts +87 -46
- package/src/engine.ts +267 -22
- package/src/haptics.ts +25 -2
- package/src/index.ts +12 -1
- package/src/items.ts +140 -0
- package/src/keyboard.ts +132 -0
- package/src/label.ts +76 -0
- package/src/liquid.ts +2 -1
- package/src/loader.ts +2 -1
- package/src/particles.ts +4 -4
- package/src/path.ts +124 -0
- package/src/playback-group.ts +143 -0
- package/src/playback.ts +62 -0
- package/src/press.ts +3 -5
- package/src/properties.ts +18 -2
- package/src/reveal.ts +3 -3
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +204 -14
- package/src/shimmer.ts +2 -1
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +4 -3
- package/src/text-layout.ts +14 -2
- package/src/text.ts +62 -81
- package/src/track.ts +98 -25
- package/src/transition.ts +4 -1
- package/src/types.ts +23 -1
- package/src/vue/index.ts +189 -0
package/src/driver/android.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/driver/index.ts
CHANGED
|
@@ -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) {
|
package/src/driver/ios-color.ts
CHANGED
|
@@ -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
|
|
39
|
-
if (
|
|
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(
|
|
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
|
+
}
|