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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +112 -16
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +89 -3
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/decay.d.ts +11 -0
  11. package/dist/decay.d.ts.map +1 -0
  12. package/dist/decay.js +22 -0
  13. package/dist/decay.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +17 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +134 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android.d.ts +1 -1
  19. package/dist/driver/android.d.ts.map +1 -1
  20. package/dist/driver/android.js +101 -5
  21. package/dist/driver/android.js.map +1 -1
  22. package/dist/driver/index.d.ts +4 -2
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-color.d.ts +14 -0
  26. package/dist/driver/ios-color.d.ts.map +1 -0
  27. package/dist/driver/ios-color.js +381 -0
  28. package/dist/driver/ios-color.js.map +1 -0
  29. package/dist/driver/ios.d.ts.map +1 -1
  30. package/dist/driver/ios.js +9 -1
  31. package/dist/driver/ios.js.map +1 -1
  32. package/dist/engine.d.ts +1 -1
  33. package/dist/engine.d.ts.map +1 -1
  34. package/dist/engine.js +15 -8
  35. package/dist/engine.js.map +1 -1
  36. package/dist/index.d.ts +10 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/liquid-grid.d.ts +3 -0
  41. package/dist/liquid-grid.d.ts.map +1 -0
  42. package/dist/liquid-grid.js +24 -0
  43. package/dist/liquid-grid.js.map +1 -0
  44. package/dist/liquid.d.ts +18 -0
  45. package/dist/liquid.d.ts.map +1 -0
  46. package/dist/liquid.js +229 -0
  47. package/dist/liquid.js.map +1 -0
  48. package/dist/loader.d.ts +19 -0
  49. package/dist/loader.d.ts.map +1 -0
  50. package/dist/loader.js +172 -0
  51. package/dist/loader.js.map +1 -0
  52. package/dist/press.d.ts.map +1 -1
  53. package/dist/press.js +6 -1
  54. package/dist/press.js.map +1 -1
  55. package/dist/properties.d.ts +5 -3
  56. package/dist/properties.d.ts.map +1 -1
  57. package/dist/properties.js +13 -2
  58. package/dist/properties.js.map +1 -1
  59. package/dist/reveal.d.ts +25 -0
  60. package/dist/reveal.d.ts.map +1 -0
  61. package/dist/reveal.js +293 -0
  62. package/dist/reveal.js.map +1 -0
  63. package/dist/text-layout.d.ts +22 -0
  64. package/dist/text-layout.d.ts.map +1 -0
  65. package/dist/text-layout.js +179 -0
  66. package/dist/text-layout.js.map +1 -0
  67. package/dist/text.d.ts +17 -0
  68. package/dist/text.d.ts.map +1 -0
  69. package/dist/text.js +267 -0
  70. package/dist/text.js.map +1 -0
  71. package/dist/track.d.ts +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +63 -5
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts +5 -0
  76. package/dist/transition.d.ts.map +1 -1
  77. package/dist/transition.js +8 -2
  78. package/dist/transition.js.map +1 -1
  79. package/dist/types.d.ts +19 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +11 -2
  82. package/platforms/android/include.gradle +7 -0
  83. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  84. package/platforms/android/proguard-rules.pro +6 -0
  85. package/src/animate.ts +105 -5
  86. package/src/color.ts +101 -0
  87. package/src/decay.ts +45 -0
  88. package/src/driver/android-color.ts +168 -0
  89. package/src/driver/android.ts +120 -10
  90. package/src/driver/index.ts +9 -2
  91. package/src/driver/ios-color.ts +432 -0
  92. package/src/driver/ios.ts +13 -3
  93. package/src/engine.ts +17 -9
  94. package/src/index.ts +10 -1
  95. package/src/liquid-grid.ts +28 -0
  96. package/src/liquid.ts +282 -0
  97. package/src/loader.ts +201 -0
  98. package/src/press.ts +8 -2
  99. package/src/properties.ts +16 -6
  100. package/src/reveal.ts +374 -0
  101. package/src/text-layout.ts +216 -0
  102. package/src/text.ts +298 -0
  103. package/src/track.ts +105 -5
  104. package/src/transition.ts +16 -2
  105. package/src/types.ts +18 -0
@@ -1,11 +1,52 @@
1
1
  import { Utils, type View } from '@nativescript/core'
2
- import type { Property } from '../properties.js'
3
- import { after, type Driver } from './index.js'
2
+ import type { NumericProperty } from '../properties.js'
3
+ import type { Driver } from './index.js'
4
+ import { isColorProperty } from '../color.js'
5
+ import { playAndroidColor, presentedAndroidColor } from './android-color.js'
4
6
 
5
7
  type NativeView = android.view.View
6
8
 
9
+ interface Handle {
10
+ cancel(): void
11
+ elapsed(): number
12
+ }
13
+
14
+ /** Own only layers we create; keep app-configured hardware/software layers and paints intact. */
15
+ function fadeLayer(view: View, nativeView: NativeView): (() => void) | undefined {
16
+ const { View, ViewGroup } = android.view
17
+ if (
18
+ !view.isLoaded ||
19
+ view.nativeViewProtected !== nativeView ||
20
+ !(nativeView instanceof ViewGroup) ||
21
+ nativeView.getChildCount() === 0 ||
22
+ !nativeView.isHardwareAccelerated() ||
23
+ nativeView.getLayerType() !== View.LAYER_TYPE_NONE
24
+ )
25
+ return undefined
26
+
27
+ // Do not cache an entire oversized scrolling/content tree into a GPU texture.
28
+ const { widthPixels, heightPixels } = nativeView.getResources().getDisplayMetrics()
29
+ const width = nativeView.getWidth()
30
+ const height = nativeView.getHeight()
31
+ if (width <= 0 || height <= 0 || width > widthPixels || height > heightPixels)
32
+ return undefined
33
+
34
+ nativeView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
35
+ let active = true
36
+ const restore = () => {
37
+ if (!active) return
38
+ active = false
39
+ view.off('unloaded', restore)
40
+ if (nativeView.getLayerType() === View.LAYER_TYPE_HARDWARE) {
41
+ nativeView.setLayerType(View.LAYER_TYPE_NONE, null)
42
+ }
43
+ }
44
+ view.on('unloaded', restore)
45
+ return restore
46
+ }
47
+
7
48
  const accessors: Record<
8
- Property,
49
+ NumericProperty,
9
50
  { get(view: NativeView): number; set(view: NativeView, value: number): void }
10
51
  > = {
11
52
  opacity: { get: (v) => v.getAlpha(), set: (v, value) => v.setAlpha(value) },
@@ -18,7 +59,7 @@ const accessors: Record<
18
59
  rotateY: { get: (v) => v.getRotationY(), set: (v, value) => v.setRotationY(value) },
19
60
  }
20
61
 
21
- const unit = (property: Property) =>
62
+ const unit = (property: NumericProperty) =>
22
63
  property === 'x' || property === 'y' ? Utils.layout.getDisplayDensity() : 1
23
64
 
24
65
  export function createAndroidDriver(): Driver {
@@ -32,16 +73,23 @@ export function createAndroidDriver(): Driver {
32
73
  rotate: View.ROTATION,
33
74
  rotateX: View.ROTATION_X,
34
75
  rotateY: View.ROTATION_Y,
35
- } satisfies Record<Property, unknown>
76
+ } satisfies Record<NumericProperty, unknown>
36
77
  const interpolator = new android.view.animation.LinearInterpolator()
37
78
 
38
79
  return {
39
80
  now: () => android.os.SystemClock.uptimeMillis(),
40
81
 
41
82
  play(view, track, _startedAt, onEnd) {
83
+ if (isColorProperty(track.property)) return playAndroidColor(view, track, onEnd)
42
84
  const nativeView = view.nativeViewProtected as NativeView
43
85
  const scale = unit(track.property)
44
86
 
87
+ if (track.property === 'rotateX' || track.property === 'rotateY') {
88
+ nativeView.setCameraDistance(
89
+ (view.perspective || 300) * Utils.layout.getDisplayDensity(),
90
+ )
91
+ }
92
+
45
93
  const keyframes = Array.create(android.animation.Keyframe, track.values.length)
46
94
  track.values.forEach((value, i) => {
47
95
  keyframes[i] = android.animation.Keyframe.ofFloat(track.times[i]!, value * scale)
@@ -56,6 +104,14 @@ export function createAndroidDriver(): Driver {
56
104
  nativeView,
57
105
  holders,
58
106
  )
107
+ let cancelled = false
108
+ let ended = false
109
+ let started = false
110
+ let restoreLayer: (() => void) | undefined
111
+ const releaseLayer = () => {
112
+ restoreLayer?.()
113
+ restoreLayer = undefined
114
+ }
59
115
  animator.setDuration(Math.max(track.duration, 1))
60
116
  animator.setStartDelay(track.delay)
61
117
  animator.setInterpolator(interpolator)
@@ -73,10 +129,25 @@ export function createAndroidDriver(): Driver {
73
129
  }
74
130
  animator.addListener(
75
131
  new android.animation.Animator.AnimatorListener({
76
- onAnimationStart() {},
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
+ },
77
142
  onAnimationRepeat() {},
78
- onAnimationCancel() {},
143
+ onAnimationCancel() {
144
+ cancelled = true
145
+ releaseLayer()
146
+ },
79
147
  onAnimationEnd() {
148
+ if (ended) return
149
+ ended = true
150
+ releaseLayer()
80
151
  onEnd()
81
152
  },
82
153
  }),
@@ -84,18 +155,57 @@ export function createAndroidDriver(): Driver {
84
155
 
85
156
  accessors[track.property].set(nativeView, track.values[0]! * scale)
86
157
  animator.start()
87
- return animator
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()
165
+ },
166
+ } satisfies Handle
88
167
  },
89
168
 
90
169
  stop(_view, handle) {
91
- ;(handle as android.animation.ObjectAnimator).cancel()
170
+ ;(handle as Handle).cancel()
92
171
  },
93
172
 
173
+ elapsed: (handle) => (handle as Handle).elapsed(),
174
+
94
175
  presented(view, property) {
176
+ if (isColorProperty(property)) return presentedAndroidColor(view, property)
95
177
  const nativeView = view.nativeViewProtected as NativeView | undefined
96
178
  return nativeView ? accessors[property].get(nativeView) / unit(property) : undefined
97
179
  },
98
180
 
99
- after,
181
+ after(ms, callback) {
182
+ // A native marker follows animator duration scale, including changes during playback.
183
+ // Only completion crosses the bridge; there is no JavaScript update listener.
184
+ const values = Array.create('float', 2)
185
+ values[0] = 0
186
+ values[1] = 1
187
+ const marker = android.animation.ValueAnimator.ofFloat(values)
188
+ marker.setDuration(ms)
189
+ let active = true
190
+ marker.addListener(
191
+ new android.animation.Animator.AnimatorListener({
192
+ onAnimationStart() {},
193
+ onAnimationRepeat() {},
194
+ onAnimationCancel() {
195
+ active = false
196
+ },
197
+ onAnimationEnd() {
198
+ if (!active) return
199
+ active = false
200
+ callback()
201
+ },
202
+ }),
203
+ )
204
+ marker.start()
205
+ return () => {
206
+ active = false
207
+ marker.cancel()
208
+ }
209
+ },
100
210
  }
101
211
  }
@@ -8,11 +8,18 @@ export interface Driver {
8
8
  /** Milliseconds, on the clock the platform's animations run on. */
9
9
  now(): number
10
10
  /** Starts the track at `startedAt + track.delay` and calls `onEnd` once, however it ends. */
11
- play(view: View, track: Track, startedAt: number, onEnd: () => void): unknown
11
+ play(
12
+ view: View,
13
+ track: Track,
14
+ startedAt: number,
15
+ onEnd: (finished?: boolean) => void,
16
+ ): unknown
12
17
  stop(view: View, handle: unknown): void
18
+ /** Track time, including its delay, when the native clock differs from `now()`. */
19
+ elapsed?(handle: unknown): number
13
20
  /** The value on screen, when the platform knows it better than the track does. */
14
21
  presented(view: View, property: Property): number | undefined
15
- /** Calls back after `ms` on the same clock, and returns a function that cancels it. */
22
+ /** Calls back after `ms` of animation time, respecting platform speed settings. */
16
23
  after(ms: number, callback: () => void): () => void
17
24
  }
18
25
 
@@ -0,0 +1,432 @@
1
+ import { Color, type View } from '@nativescript/core'
2
+ import { colorProperty } from '@nativescript/core/ui/styling/style-properties'
3
+ import { formattedTextProperty, textProperty } from '@nativescript/core/ui/text-base'
4
+ import { readColor, type ColorProperty } from '../color.js'
5
+ import type { Track } from '../track.js'
6
+
7
+ type TextView = View & {
8
+ text?: string
9
+ formattedText?: {
10
+ toString(): string
11
+ style: object
12
+ spans: { length: number; getItem(index: number): { text: string; style: object } }
13
+ }
14
+ }
15
+ type PaintLayer = {
16
+ layer: CALayer
17
+ path: 'backgroundColor' | 'borderColor' | 'fillColor'
18
+ }
19
+ type NativeView = UIView & { borderLayer?: CAShapeLayer }
20
+ interface ColorHandle {
21
+ stop(): void
22
+ presented(): number | undefined
23
+ }
24
+ const playing = new WeakMap<View, Map<string, ColorHandle>>()
25
+ const labelOf = (view: View) => {
26
+ const native = view.nativeViewProtected
27
+ return native instanceof UILabel
28
+ ? native
29
+ : native instanceof UIButton
30
+ ? native.titleLabel
31
+ : undefined
32
+ }
33
+
34
+ export function presentedIOSColor(
35
+ view: View,
36
+ property: ColorProperty,
37
+ ): number | undefined {
38
+ const current = playing.get(view)?.get(property)?.presented()
39
+ if (current !== undefined) return current
40
+ if (property === 'color' && !view.color) {
41
+ const label = labelOf(view)
42
+ if (label?.textColor) return Color.fromIosColor(label.textColor).argb >>> 0
43
+ }
44
+ return readColor(view, property)
45
+ }
46
+
47
+ /** Glyph entrances and color masks cannot own the same UILabel paint at once. */
48
+ export function stopIOSColorText(view: View) {
49
+ playing.get(view)?.get('color')?.stop()
50
+ }
51
+
52
+ /** Core Animation paints a solid layer through an exact UILabel alpha mask. */
53
+ export function playIOSColor(
54
+ view: View,
55
+ track: Track,
56
+ startedAt: number,
57
+ onEnd: (finished?: boolean) => void,
58
+ ): ColorHandle {
59
+ const property = track.property as ColorProperty
60
+ let target: PaintLayer | undefined
61
+ let restore: (() => void) | undefined
62
+ let stopped = false
63
+ let queued = false
64
+ let attached = false
65
+ let generation = 0
66
+ let textGeometry: string | undefined
67
+ const native = view.nativeViewProtected as NativeView
68
+ const label = property === 'color' ? labelOf(view) : undefined
69
+ const key = `jolt:${property}`
70
+ const listeners: [
71
+ object & {
72
+ on(name: string, callback: () => void): void
73
+ off(name: string, callback: () => void): void
74
+ },
75
+ string,
76
+ () => void,
77
+ ][] = []
78
+ const listen = (
79
+ object: typeof view | typeof view.style,
80
+ name: string,
81
+ callback: () => void,
82
+ ) => {
83
+ object.on(name, callback)
84
+ listeners.push([object, name, callback])
85
+ }
86
+ const cleanup = () => {
87
+ if (stopped) return
88
+ stopped = true
89
+ generation++
90
+ for (const [object, name, callback] of listeners) object.off(name, callback)
91
+ target?.layer.removeAnimationForKey(key)
92
+ restore?.()
93
+ if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
94
+ }
95
+ const cancel = () => {
96
+ if (stopped) return
97
+ cleanup()
98
+ onEnd(false)
99
+ }
100
+ const scheduleCancel = () => {
101
+ if (stopped || queued) return
102
+ queued = true
103
+ void Promise.resolve().then(() => {
104
+ queued = false
105
+ if (!stopped) cancel()
106
+ })
107
+ }
108
+ const handle: ColorHandle = {
109
+ stop: cancel,
110
+ presented() {
111
+ if (!target) return track.values[0]
112
+ const layer = target.layer.presentationLayer()
113
+ // Before CA commits its first frame, the model already contains the target.
114
+ if (!layer) return track.valueAt(CACurrentMediaTime() * 1000 - startedAt)
115
+ const color = (layer as unknown as Record<string, interop.Pointer | null>)[
116
+ target.path
117
+ ]
118
+ return color
119
+ ? Color.fromIosColor(UIColor.colorWithCGColor(color)).argb >>> 0
120
+ : undefined
121
+ },
122
+ }
123
+ let entries = playing.get(view)
124
+ if (!entries) playing.set(view, (entries = new Map()))
125
+ entries.set(property, handle)
126
+ listen(view, 'unloaded', cancel)
127
+ for (const name of property === 'borderColor'
128
+ ? [
129
+ 'borderTopColorChange',
130
+ 'borderRightColorChange',
131
+ 'borderBottomColorChange',
132
+ 'borderLeftColorChange',
133
+ ]
134
+ : [`${property}Change`])
135
+ listen(view.style, name, scheduleCancel)
136
+ if (property === 'backgroundColor')
137
+ listen(view.style, 'backgroundImageChange', scheduleCancel)
138
+
139
+ const start = () => {
140
+ if (stopped || attached) return
141
+ if (property === 'color') {
142
+ if (
143
+ !label ||
144
+ !label.window ||
145
+ label.bounds.size.width <= 0 ||
146
+ label.bounds.size.height <= 0
147
+ )
148
+ return
149
+ let paint: ReturnType<typeof textPaint>
150
+ try {
151
+ paint = textPaint(view as TextView, label)
152
+ } catch {
153
+ return cancel()
154
+ }
155
+ if (!paint) return cancel()
156
+ target = { layer: paint.layer, path: 'backgroundColor' }
157
+ restore = paint.restore
158
+ } else {
159
+ // Core rasterizes an image together with its background into a pattern.
160
+ // Replacing that CGColor would erase the image.
161
+ if (
162
+ property === 'backgroundColor' &&
163
+ typeof view.backgroundImage === 'string' &&
164
+ view.backgroundImage
165
+ )
166
+ return cancel()
167
+ target =
168
+ property === 'borderColor' && native.borderLayer
169
+ ? { layer: native.borderLayer, path: 'fillColor' }
170
+ : { layer: native.layer, path: property }
171
+ }
172
+ attached = true
173
+ if (label) textGeometry = geometry(label)
174
+ attach()
175
+ }
176
+ const attach = () => {
177
+ if (!target || stopped) return
178
+ const instance = ++generation
179
+ const { layer, path } = target
180
+ CATransaction.begin()
181
+ CATransaction.setDisableActions(true)
182
+ ;(layer as unknown as Record<string, interop.Pointer>)[path] = new Color(
183
+ track.to,
184
+ ).ios.CGColor
185
+ CATransaction.commit()
186
+ const animation = CAKeyframeAnimation.animationWithKeyPath(path)
187
+ animation.values = NSArray.arrayWithArray(
188
+ track.values.map((color) => new Color(color).ios.CGColor),
189
+ )
190
+ animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
191
+ 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
+ if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
199
+ const maximum = Math.min(
200
+ 120,
201
+ native.window?.screen.maximumFramesPerSecond ??
202
+ UIScreen.mainScreen.maximumFramesPerSecond,
203
+ )
204
+ animation.preferredFrameRateRange = CAFrameRateRangeMake(
205
+ Math.min(80, maximum),
206
+ maximum,
207
+ maximum,
208
+ )
209
+ }
210
+ if (track.repeat > 0) {
211
+ animation.autoreverses = track.reverse
212
+ animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
213
+ }
214
+ CATransaction.begin()
215
+ if (track.repeat !== Infinity)
216
+ CATransaction.setCompletionBlock(() => {
217
+ if (!stopped && instance === generation) {
218
+ cleanup()
219
+ onEnd()
220
+ }
221
+ })
222
+ layer.addAnimationForKey(animation, key)
223
+ CATransaction.commit()
224
+ }
225
+
226
+ if (property === 'color') {
227
+ for (const name of ['textChange', 'formattedTextChange', 'isEnabledChange'])
228
+ listen(view, name, scheduleCancel)
229
+ for (const name of [
230
+ 'fontInternalChange',
231
+ 'textTransformChange',
232
+ 'whiteSpaceChange',
233
+ 'textOverflowChange',
234
+ 'maxLinesChange',
235
+ 'textStrokeChange',
236
+ 'directionChange',
237
+ 'visibilityChange',
238
+ 'paddingLeftChange',
239
+ 'paddingRightChange',
240
+ 'paddingTopChange',
241
+ 'paddingBottomChange',
242
+ 'textAlignmentChange',
243
+ 'letterSpacingChange',
244
+ 'textDecorationChange',
245
+ 'textShadowChange',
246
+ 'lineHeightChange',
247
+ ])
248
+ listen(view.style, name, scheduleCancel)
249
+ listen(view, 'layoutChanged', () => {
250
+ if (attached) {
251
+ // Core can relayout after rebuilding an identical attributed string.
252
+ // Only changed glyph geometry invalidates the captured mask.
253
+ if (label && geometry(label) !== textGeometry) scheduleCancel()
254
+ } else start()
255
+ })
256
+ const formatted = (view as TextView).formattedText
257
+ if (formatted) {
258
+ const object = formatted as unknown as typeof view
259
+ listen(object, 'propertyChange', scheduleCancel)
260
+ }
261
+ } else if (property === 'borderColor') {
262
+ // Core may rebuild its border layer after a background or geometry change.
263
+ // Reattach on the same native timeline, without cancelling sibling colors.
264
+ let pending = false
265
+ const followBorder = () => {
266
+ if (stopped || pending) return
267
+ pending = true
268
+ void Promise.resolve().then(() => {
269
+ pending = false
270
+ if (stopped || !attached || !target) return
271
+ const next: PaintLayer = native.borderLayer
272
+ ? { layer: native.borderLayer, path: 'fillColor' }
273
+ : { layer: native.layer, path: 'borderColor' }
274
+ if (next.layer === target.layer && next.path === target.path) return
275
+ const previous = target
276
+ target = next
277
+ generation++
278
+ previous.layer.removeAnimationForKey(key)
279
+ attach()
280
+ })
281
+ }
282
+ listen(view, 'layoutChanged', followBorder)
283
+ listen(view.style, 'backgroundInternalChange', followBorder)
284
+ }
285
+ start()
286
+ return handle
287
+ }
288
+
289
+ function textPaint(
290
+ view: TextView,
291
+ native: UILabel,
292
+ ): { layer: CALayer; restore(): void } | undefined {
293
+ const owner = view.nativeViewProtected
294
+ const original = native.attributedText
295
+ const text = original?.string ?? native.text ?? ''
296
+ const bounds = native.bounds
297
+ const scale = native.window?.screen.scale ?? UIScreen.mainScreen.scale
298
+ if (
299
+ !text ||
300
+ Array.from(text).some((char) => {
301
+ const cp = char.codePointAt(0)!
302
+ return (
303
+ (cp >= 0x1f000 && cp <= 0x1faff) ||
304
+ (cp >= 0x2600 && cp <= 0x27bf) ||
305
+ cp === 0x200d ||
306
+ cp === 0x20e3 ||
307
+ cp === 0xfe0f
308
+ )
309
+ }) ||
310
+ (native.shadowColor && CGColorGetAlpha(native.shadowColor.CGColor) > 0) ||
311
+ bounds.size.width * bounds.size.height * scale * scale > 4_000_000
312
+ )
313
+ return undefined
314
+ const source = original
315
+ ? NSMutableAttributedString.alloc().initWithAttributedString(original)
316
+ : NSMutableAttributedString.alloc().initWithString(text)
317
+ const full = { location: 0, length: source.length }
318
+ let unsupported = false
319
+ for (const attribute of [NSAttachmentAttributeName, NSShadowAttributeName])
320
+ source.enumerateAttributeInRangeOptionsUsingBlock(
321
+ attribute,
322
+ full,
323
+ 0 as NSAttributedStringEnumerationOptions,
324
+ (value) => {
325
+ if (attribute === NSShadowAttributeName) {
326
+ const color = (value as NSShadow | null)?.shadowColor as UIColor | null
327
+ if (color && CGColorGetAlpha(color.CGColor) > 0) unsupported = true
328
+ } else if (value != null) unsupported = true
329
+ },
330
+ )
331
+ if (unsupported) return undefined
332
+ const ranges: NSRange[] = []
333
+ const ownColor = (style: object) =>
334
+ ((style as Record<symbol, number>)[colorProperty.sourceKey] ?? 0) >= 2
335
+ const formatted = view.formattedText
336
+ if (formatted) {
337
+ let offset = 0
338
+ for (let i = 0; i < formatted.spans.length; i++) {
339
+ const span = formatted.spans.getItem(i)
340
+ if (!ownColor(formatted.style) && !ownColor(span.style))
341
+ ranges.push({ location: offset, length: span.text.length })
342
+ offset += span.text.length
343
+ }
344
+ } else ranges.push(full)
345
+ const maskText = NSMutableAttributedString.alloc().initWithAttributedString(source)
346
+ const hidden = NSMutableAttributedString.alloc().initWithAttributedString(source)
347
+ maskText.addAttributeValueRange(NSFontAttributeName, native.font, full)
348
+ // Keep per-span fonts, weights and paragraph metrics exactly as UILabel uses them.
349
+ if (original)
350
+ original.enumerateAttributeInRangeOptionsUsingBlock(
351
+ NSFontAttributeName,
352
+ full,
353
+ 0 as NSAttributedStringEnumerationOptions,
354
+ (font, range) => {
355
+ if (font) maskText.addAttributeValueRange(NSFontAttributeName, font, range)
356
+ },
357
+ )
358
+ for (const name of [
359
+ NSForegroundColorAttributeName,
360
+ NSBackgroundColorAttributeName,
361
+ NSUnderlineColorAttributeName,
362
+ NSStrikethroughColorAttributeName,
363
+ NSStrokeColorAttributeName,
364
+ ])
365
+ maskText.addAttributeValueRange(name, UIColor.clearColor, full)
366
+ for (const range of ranges)
367
+ for (const name of [
368
+ NSForegroundColorAttributeName,
369
+ NSUnderlineColorAttributeName,
370
+ NSStrikethroughColorAttributeName,
371
+ NSStrokeColorAttributeName,
372
+ ]) {
373
+ maskText.addAttributeValueRange(name, UIColor.whiteColor, range)
374
+ hidden.addAttributeValueRange(name, UIColor.clearColor, range)
375
+ }
376
+ let image: UIImage | null
377
+ try {
378
+ native.attributedText = maskText
379
+ UIGraphicsBeginImageContextWithOptions(bounds.size, false, scale)
380
+ try {
381
+ CGContextTranslateCTM(
382
+ UIGraphicsGetCurrentContext()!,
383
+ -bounds.origin.x,
384
+ -bounds.origin.y,
385
+ )
386
+ native.drawTextInRect(bounds)
387
+ image = UIGraphicsGetImageFromCurrentImageContext()
388
+ } finally {
389
+ UIGraphicsEndImageContext()
390
+ }
391
+ } finally {
392
+ if (original) native.attributedText = original
393
+ else native.text = text
394
+ }
395
+ if (!image?.CGImage) return undefined
396
+ const mask = CALayer.new()
397
+ mask.frame = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
398
+ mask.contents = image.CGImage
399
+ mask.contentsScale = scale
400
+ const layer = CALayer.new()
401
+ layer.frame = bounds
402
+ layer.name = 'jolt:color'
403
+ layer.mask = mask
404
+ native.attributedText = hidden
405
+ native.layer.addSublayer(layer)
406
+ return {
407
+ layer,
408
+ restore() {
409
+ layer.removeFromSuperlayer()
410
+ if (view.nativeViewProtected !== owner) return
411
+ const property = view.formattedText ? formattedTextProperty : textProperty
412
+ ;(view as unknown as Record<symbol, (value: unknown) => void>)[property.setNative]!(
413
+ view.formattedText ?? view.text,
414
+ )
415
+ },
416
+ }
417
+ }
418
+
419
+ function geometry(label: UILabel): string {
420
+ const bounds = label.bounds
421
+ const text = label.textRectForBoundsLimitedToNumberOfLines(bounds, label.numberOfLines)
422
+ return [
423
+ bounds.origin.x,
424
+ bounds.origin.y,
425
+ bounds.size.width,
426
+ bounds.size.height,
427
+ text.origin.x,
428
+ text.origin.y,
429
+ text.size.width,
430
+ text.size.height,
431
+ ].join(',')
432
+ }
package/src/driver/ios.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import type { View } from '@nativescript/core'
2
- import type { Property } from '../properties.js'
2
+ import type { NumericProperty } from '../properties.js'
3
+ import { isColorProperty } from '../color.js'
4
+ import { playIOSColor, presentedIOSColor } from './ios-color.js'
3
5
  import { after, type Driver } from './index.js'
4
6
 
5
7
  interface Handle {
6
8
  layers: CALayer[]
7
9
  key: string
8
10
  onStop?: () => void
11
+ color?: ReturnType<typeof playIOSColor>
9
12
  }
10
13
 
11
- const keyPaths: Record<Property, string> = {
14
+ const keyPaths: Record<NumericProperty, string> = {
12
15
  opacity: 'opacity',
13
16
  x: 'transform.translation.x',
14
17
  y: 'transform.translation.y',
@@ -49,6 +52,8 @@ export function createIOSDriver(): Driver {
49
52
  now: () => CACurrentMediaTime() * 1000,
50
53
 
51
54
  play(view, track, startedAt, onEnd): Handle {
55
+ if (isColorProperty(track.property))
56
+ return { layers: [], key: '', color: playIOSColor(view, track, startedAt, onEnd) }
52
57
  const layers = layersOf(view)
53
58
  const key = `jolt:${track.property}`
54
59
  const rotation = track.property.startsWith('rotate')
@@ -96,12 +101,17 @@ export function createIOSDriver(): Driver {
96
101
  stop(_view, handle) {
97
102
  const playing = handle as Handle
98
103
  const { layers, key, onStop } = playing
104
+ if (playing.color) {
105
+ playing.color.stop()
106
+ return
107
+ }
99
108
  playing.onStop = undefined
100
109
  for (const layer of layers) layer.removeAnimationForKey(key)
101
110
  onStop?.()
102
111
  },
103
112
 
104
- presented: () => undefined,
113
+ presented: (view, property) =>
114
+ isColorProperty(property) ? presentedIOSColor(view, property) : undefined,
105
115
 
106
116
  after,
107
117
  }