@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.
- package/README.md +112 -16
- package/dist/animate.d.ts +3 -1
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +89 -3
- package/dist/animate.js.map +1 -1
- package/dist/color.d.ts +11 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +73 -0
- package/dist/color.js.map +1 -0
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/driver/android-color.d.ts +17 -0
- package/dist/driver/android-color.d.ts.map +1 -0
- package/dist/driver/android-color.js +134 -0
- package/dist/driver/android-color.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +101 -5
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +4 -2
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +14 -0
- package/dist/driver/ios-color.d.ts.map +1 -0
- package/dist/driver/ios-color.js +381 -0
- package/dist/driver/ios-color.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +9 -1
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +15 -8
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/liquid-grid.d.ts +3 -0
- package/dist/liquid-grid.d.ts.map +1 -0
- package/dist/liquid-grid.js +24 -0
- package/dist/liquid-grid.js.map +1 -0
- package/dist/liquid.d.ts +18 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +229 -0
- package/dist/liquid.js.map +1 -0
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +172 -0
- package/dist/loader.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +6 -1
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +5 -3
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +13 -2
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +293 -0
- package/dist/reveal.js.map +1 -0
- package/dist/text-layout.d.ts +22 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +179 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +267 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts +1 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +63 -5
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +5 -0
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +8 -2
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
- package/platforms/android/proguard-rules.pro +6 -0
- package/src/animate.ts +105 -5
- package/src/color.ts +101 -0
- package/src/decay.ts +45 -0
- package/src/driver/android-color.ts +168 -0
- package/src/driver/android.ts +120 -10
- package/src/driver/index.ts +9 -2
- package/src/driver/ios-color.ts +432 -0
- package/src/driver/ios.ts +13 -3
- package/src/engine.ts +17 -9
- package/src/index.ts +10 -1
- package/src/liquid-grid.ts +28 -0
- package/src/liquid.ts +282 -0
- package/src/loader.ts +201 -0
- package/src/press.ts +8 -2
- package/src/properties.ts +16 -6
- package/src/reveal.ts +374 -0
- package/src/text-layout.ts +216 -0
- package/src/text.ts +298 -0
- package/src/track.ts +105 -5
- package/src/transition.ts +16 -2
- package/src/types.ts +18 -0
package/src/properties.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Color, type View } from '@nativescript/core'
|
|
2
|
+
import { isColorProperty, readColor, type ColorProperty } from './color.js'
|
|
2
3
|
|
|
3
|
-
export type
|
|
4
|
+
export type NumericProperty =
|
|
4
5
|
'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
|
|
6
|
+
export type Property = NumericProperty | ColorProperty
|
|
5
7
|
|
|
6
8
|
const viewKeys = {
|
|
7
9
|
opacity: 'opacity',
|
|
@@ -12,9 +14,9 @@ const viewKeys = {
|
|
|
12
14
|
rotate: 'rotate',
|
|
13
15
|
rotateX: 'rotateX',
|
|
14
16
|
rotateY: 'rotateY',
|
|
15
|
-
} as const satisfies Record<
|
|
17
|
+
} as const satisfies Record<NumericProperty, keyof View>
|
|
16
18
|
|
|
17
|
-
const defaults: Record<
|
|
19
|
+
const defaults: Record<NumericProperty, number> = {
|
|
18
20
|
opacity: 1,
|
|
19
21
|
x: 0,
|
|
20
22
|
y: 0,
|
|
@@ -25,8 +27,11 @@ const defaults: Record<Property, number> = {
|
|
|
25
27
|
rotateY: 0,
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** How close to its target a spring has to be to count as settled. */
|
|
30
|
+
/** How close to its target a spring or decay has to be to count as settled. */
|
|
29
31
|
export const precision: Record<Property, number> = {
|
|
32
|
+
backgroundColor: 0.002,
|
|
33
|
+
borderColor: 0.002,
|
|
34
|
+
color: 0.002,
|
|
30
35
|
opacity: 0.002,
|
|
31
36
|
x: 0.25,
|
|
32
37
|
y: 0.25,
|
|
@@ -38,14 +43,19 @@ export const precision: Record<Property, number> = {
|
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export function isProperty(key: string): key is Property {
|
|
41
|
-
return key in viewKeys
|
|
46
|
+
return key in viewKeys || isColorProperty(key)
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
export function readValue(view: View, property: Property): number {
|
|
50
|
+
if (isColorProperty(property)) return readColor(view, property)
|
|
45
51
|
const value = view[viewKeys[property]]
|
|
46
52
|
return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export function writeValue(view: View, property: Property, value: number) {
|
|
56
|
+
if (isColorProperty(property)) {
|
|
57
|
+
view[property] = new Color(value)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
50
60
|
view[viewKeys[property]] = value
|
|
51
61
|
}
|
package/src/reveal.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { Application, isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import { resolveEasing } from './easing.js'
|
|
4
|
+
import type { Animation } from './engine.js'
|
|
5
|
+
import type { Target } from './types.js'
|
|
6
|
+
|
|
7
|
+
export interface RevealOptions {
|
|
8
|
+
/** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
|
|
9
|
+
from?: { x: number; y: number }
|
|
10
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
11
|
+
duration?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ConcealOptions {
|
|
15
|
+
/** Circle destination in DIPs from the target's top-left corner. Defaults to its center. */
|
|
16
|
+
to?: { x: number; y: number }
|
|
17
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
18
|
+
duration?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Circle = { x: number; y: number; radius: number }
|
|
22
|
+
type MaskedView = UIView & { originalMask?: CALayer | null; maskType?: unknown }
|
|
23
|
+
type Snapshot = { circle: Circle; native: UIView; bounds: CGRect }
|
|
24
|
+
const running = new WeakMap<View, CircularAnimation>()
|
|
25
|
+
const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
|
|
26
|
+
const ease = resolveEasing('easeInOut')
|
|
27
|
+
const key = 'jolt:reveal'
|
|
28
|
+
let Delegate: typeof NSObject | undefined
|
|
29
|
+
|
|
30
|
+
/** Reveals an iOS view through an expanding circle, then removes the temporary mask. */
|
|
31
|
+
export function reveal(target: Target, options: RevealOptions = {}): Animation {
|
|
32
|
+
return start(target, true, options.from, options.duration ?? 400)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Contracts an iOS view to a point, then sets visibility to 'hidden' without collapsing layout. */
|
|
36
|
+
export function conceal(target: Target, options: ConcealOptions = {}): Animation {
|
|
37
|
+
return start(target, false, options.to, options.duration ?? 400)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function start(
|
|
41
|
+
target: Target,
|
|
42
|
+
opening: boolean,
|
|
43
|
+
point: { x: number; y: number } | undefined,
|
|
44
|
+
duration: number,
|
|
45
|
+
): Animation {
|
|
46
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
47
|
+
throw new RangeError('Reveal duration must be a nonnegative finite number.')
|
|
48
|
+
if (
|
|
49
|
+
point !== undefined &&
|
|
50
|
+
(!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
51
|
+
)
|
|
52
|
+
throw new TypeError('Reveal origin must have finite x and y coordinates.')
|
|
53
|
+
const view = toView(target)
|
|
54
|
+
const previous = running.get(view)
|
|
55
|
+
const snapshot = previous?.snapshot()
|
|
56
|
+
const original = previous?.originalVisibility ?? view.visibility
|
|
57
|
+
previous?.stop()
|
|
58
|
+
return new CircularAnimation(
|
|
59
|
+
view,
|
|
60
|
+
opening,
|
|
61
|
+
point && { ...point },
|
|
62
|
+
duration,
|
|
63
|
+
original,
|
|
64
|
+
snapshot,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameBounds(a: CGRect, b: CGRect) {
|
|
69
|
+
return (
|
|
70
|
+
a.origin.x === b.origin.x &&
|
|
71
|
+
a.origin.y === b.origin.y &&
|
|
72
|
+
a.size.width === b.size.width &&
|
|
73
|
+
a.size.height === b.size.height
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function path(circle: Circle): CAShapeLayer['path'] {
|
|
78
|
+
// Keep the same oval topology even at the closed endpoint.
|
|
79
|
+
const radius = Math.max(0.01, circle.radius)
|
|
80
|
+
return UIBezierPath.bezierPathWithOvalInRect(
|
|
81
|
+
CGRectMake(circle.x - radius, circle.y - radius, radius * 2, radius * 2),
|
|
82
|
+
).CGPath
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function completion(
|
|
86
|
+
callback: (finished: boolean) => void,
|
|
87
|
+
): NSObject & CAAnimationDelegate {
|
|
88
|
+
if (!Delegate) {
|
|
89
|
+
// Use the runtime subclass API so published JS needs no NativeClass transform.
|
|
90
|
+
const base = NSObject as typeof NSObject & {
|
|
91
|
+
extend(
|
|
92
|
+
methods: object,
|
|
93
|
+
options: { protocols: unknown[]; name: string },
|
|
94
|
+
): typeof NSObject
|
|
95
|
+
}
|
|
96
|
+
Delegate = base.extend(
|
|
97
|
+
{
|
|
98
|
+
animationDidStopFinished(
|
|
99
|
+
this: NSObject,
|
|
100
|
+
_animation: CAAnimation,
|
|
101
|
+
finished: boolean,
|
|
102
|
+
) {
|
|
103
|
+
callbacks.get(this)?.(finished)
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{ protocols: [CAAnimationDelegate], name: 'JoltCircularRevealDelegate' },
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const delegate = Delegate.new()
|
|
110
|
+
callbacks.set(delegate, callback)
|
|
111
|
+
return delegate
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class CircularAnimation implements Animation {
|
|
115
|
+
readonly finished: Promise<boolean>
|
|
116
|
+
private resolve!: (finished: boolean) => void
|
|
117
|
+
private settled = false
|
|
118
|
+
private queued = false
|
|
119
|
+
private changingVisibility = false
|
|
120
|
+
private waitingForLoad: boolean
|
|
121
|
+
private native: MaskedView | undefined
|
|
122
|
+
private bounds: CGRect | undefined
|
|
123
|
+
private wrapper: CALayer | undefined
|
|
124
|
+
private shape: CAShapeLayer | undefined
|
|
125
|
+
private originalMask: CALayer | null = null
|
|
126
|
+
private savedMask: CALayer | null = null
|
|
127
|
+
private delegate: NSObject | undefined
|
|
128
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
129
|
+
private from: Circle | undefined
|
|
130
|
+
private to: Circle | undefined
|
|
131
|
+
private startedAt = 0
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly view: View,
|
|
135
|
+
private readonly opening: boolean,
|
|
136
|
+
private readonly point: { x: number; y: number } | undefined,
|
|
137
|
+
private readonly duration: number,
|
|
138
|
+
readonly originalVisibility: View['visibility'],
|
|
139
|
+
private readonly inherited?: Snapshot,
|
|
140
|
+
) {
|
|
141
|
+
this.finished = new Promise((resolve) => {
|
|
142
|
+
this.resolve = resolve
|
|
143
|
+
})
|
|
144
|
+
this.waitingForLoad = !view.isLoaded
|
|
145
|
+
running.set(view, this)
|
|
146
|
+
if (!isIOS) {
|
|
147
|
+
this.end(false)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
view.on('loaded', this.loaded)
|
|
151
|
+
view.on('layoutChanged', this.layoutChanged)
|
|
152
|
+
view.on('unloaded', this.stop)
|
|
153
|
+
view.style.on('backgroundInternalChange', this.stop)
|
|
154
|
+
view.style.on('visibilityChange', this.visibilityChanged)
|
|
155
|
+
if (opening && view.visibility === 'collapse') this.visibility('hidden')
|
|
156
|
+
this.schedule()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
then<A = boolean, B = never>(
|
|
160
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
161
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
162
|
+
): Promise<A | B> {
|
|
163
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Cancels and restores visibility from before the interrupted reveal/conceal chain. */
|
|
167
|
+
stop = () => this.end(false)
|
|
168
|
+
|
|
169
|
+
snapshot(): Snapshot | undefined {
|
|
170
|
+
if (!this.shape || !this.native || !this.bounds || !this.from || !this.to) return
|
|
171
|
+
const presented = this.shape.presentationLayer() as CAShapeLayer | null
|
|
172
|
+
let circle: Circle
|
|
173
|
+
if (presented?.path) {
|
|
174
|
+
const box = CGPathGetBoundingBox(presented.path)
|
|
175
|
+
circle = {
|
|
176
|
+
x: box.origin.x + box.size.width / 2,
|
|
177
|
+
y: box.origin.y + box.size.height / 2,
|
|
178
|
+
radius: box.size.width / 2,
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
const progress = ease(
|
|
182
|
+
Math.max(
|
|
183
|
+
0,
|
|
184
|
+
Math.min(1, ((CACurrentMediaTime() - this.startedAt) * 1000) / this.duration),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
const lerp = (a: number, b: number) => a + (b - a) * progress
|
|
188
|
+
circle = {
|
|
189
|
+
x: lerp(this.from.x, this.to.x),
|
|
190
|
+
y: lerp(this.from.y, this.to.y),
|
|
191
|
+
radius: lerp(this.from.radius, this.to.radius),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { circle, native: this.native, bounds: this.bounds }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private visibility(value: View['visibility']) {
|
|
198
|
+
this.changingVisibility = true
|
|
199
|
+
try {
|
|
200
|
+
this.view.visibility = value
|
|
201
|
+
} finally {
|
|
202
|
+
this.changingVisibility = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private visibilityChanged = () => {
|
|
207
|
+
if (!this.changingVisibility) this.end(false, true)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private loaded = () => {
|
|
211
|
+
this.waitingForLoad = false
|
|
212
|
+
this.schedule()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private layoutChanged = () => {
|
|
216
|
+
if (this.shape) {
|
|
217
|
+
const native = this.view.nativeViewProtected as UIView | undefined
|
|
218
|
+
if (
|
|
219
|
+
native !== this.native ||
|
|
220
|
+
!native?.window ||
|
|
221
|
+
!sameBounds(native.layer.bounds, this.bounds!)
|
|
222
|
+
) {
|
|
223
|
+
this.stop()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.schedule()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private schedule = () => {
|
|
231
|
+
if (this.settled || this.queued) return
|
|
232
|
+
this.queued = true
|
|
233
|
+
void Promise.resolve().then(() => {
|
|
234
|
+
this.queued = false
|
|
235
|
+
if (this.settled) return
|
|
236
|
+
try {
|
|
237
|
+
this.update()
|
|
238
|
+
} catch (error) {
|
|
239
|
+
this.stop()
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private motionChanged = () => {
|
|
246
|
+
if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private update() {
|
|
250
|
+
if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
|
|
251
|
+
return this.end(true, true)
|
|
252
|
+
if (this.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
|
|
253
|
+
return this.end(true)
|
|
254
|
+
if (this.waitingForLoad || !this.view.isLoaded) return
|
|
255
|
+
const native = this.view.nativeViewProtected as MaskedView | undefined
|
|
256
|
+
if (this.shape) {
|
|
257
|
+
if (native !== this.native || native?.layer.mask !== this.wrapper) this.stop()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (!native?.window) return
|
|
261
|
+
const bounds = native.layer.bounds
|
|
262
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) return
|
|
263
|
+
const inherited =
|
|
264
|
+
this.inherited?.native === native && sameBounds(this.inherited.bounds, bounds)
|
|
265
|
+
? this.inherited.circle
|
|
266
|
+
: undefined
|
|
267
|
+
const x = this.point
|
|
268
|
+
? bounds.origin.x + this.point.x
|
|
269
|
+
: (inherited?.x ?? bounds.origin.x + bounds.size.width / 2)
|
|
270
|
+
const y = this.point
|
|
271
|
+
? bounds.origin.y + this.point.y
|
|
272
|
+
: (inherited?.y ?? bounds.origin.y + bounds.size.height / 2)
|
|
273
|
+
const radius =
|
|
274
|
+
Math.hypot(
|
|
275
|
+
Math.max(
|
|
276
|
+
Math.abs(x - bounds.origin.x),
|
|
277
|
+
Math.abs(x - bounds.origin.x - bounds.size.width),
|
|
278
|
+
),
|
|
279
|
+
Math.max(
|
|
280
|
+
Math.abs(y - bounds.origin.y),
|
|
281
|
+
Math.abs(y - bounds.origin.y - bounds.size.height),
|
|
282
|
+
),
|
|
283
|
+
) + 1
|
|
284
|
+
this.from = inherited ?? { x, y, radius: this.opening ? 0 : radius }
|
|
285
|
+
this.to = { x, y, radius: this.opening ? radius : 0 }
|
|
286
|
+
this.native = native
|
|
287
|
+
this.bounds = bounds
|
|
288
|
+
this.originalMask = native.layer.mask
|
|
289
|
+
this.savedMask =
|
|
290
|
+
native.maskType != null ? (native.originalMask ?? null) : this.originalMask
|
|
291
|
+
|
|
292
|
+
CATransaction.begin()
|
|
293
|
+
CATransaction.setDisableActions(true)
|
|
294
|
+
try {
|
|
295
|
+
// A plain container prevents Core from mistaking our circle for its own clip path.
|
|
296
|
+
const wrapper = (this.wrapper = CALayer.new())
|
|
297
|
+
const shape = (this.shape = CAShapeLayer.new())
|
|
298
|
+
wrapper.name = key
|
|
299
|
+
for (const layer of [wrapper, shape]) {
|
|
300
|
+
layer.bounds = bounds
|
|
301
|
+
layer.position = CGPointMake(
|
|
302
|
+
bounds.origin.x + bounds.size.width / 2,
|
|
303
|
+
bounds.origin.y + bounds.size.height / 2,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
shape.fillColor = UIColor.blackColor.CGColor
|
|
307
|
+
shape.path = path(this.to)
|
|
308
|
+
wrapper.addSublayer(shape)
|
|
309
|
+
// Same coordinate space as the original host, including bounds origins.
|
|
310
|
+
native.layer.mask = null
|
|
311
|
+
wrapper.mask = this.originalMask
|
|
312
|
+
native.layer.mask = wrapper
|
|
313
|
+
const animation = CABasicAnimation.animationWithKeyPath('path')
|
|
314
|
+
animation.fromValue = path(this.from)
|
|
315
|
+
animation.toValue = shape.path
|
|
316
|
+
animation.duration = this.duration / 1000
|
|
317
|
+
animation.timingFunction = CAMediaTimingFunction.functionWithName(
|
|
318
|
+
kCAMediaTimingFunctionEaseInEaseOut,
|
|
319
|
+
)
|
|
320
|
+
this.delegate = animation.delegate = completion((finished) => this.end(finished))
|
|
321
|
+
this.startedAt = CACurrentMediaTime()
|
|
322
|
+
shape.addAnimationForKey(animation, key)
|
|
323
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
324
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
325
|
+
this.motionChanged,
|
|
326
|
+
)
|
|
327
|
+
this.visibility('visible')
|
|
328
|
+
} finally {
|
|
329
|
+
CATransaction.commit()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private end(finished: boolean, keepVisibility = false) {
|
|
334
|
+
if (this.settled) return
|
|
335
|
+
this.settled = true
|
|
336
|
+
const { view, native, wrapper, shape } = this
|
|
337
|
+
view.off('loaded', this.loaded)
|
|
338
|
+
view.off('layoutChanged', this.layoutChanged)
|
|
339
|
+
view.off('unloaded', this.stop)
|
|
340
|
+
view.style.off('backgroundInternalChange', this.stop)
|
|
341
|
+
view.style.off('visibilityChange', this.visibilityChanged)
|
|
342
|
+
if (running.get(view) === this) running.delete(view)
|
|
343
|
+
if (this.observer)
|
|
344
|
+
Application.ios.removeNotificationObserver(
|
|
345
|
+
this.observer,
|
|
346
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
347
|
+
)
|
|
348
|
+
if (this.delegate) callbacks.delete(this.delegate)
|
|
349
|
+
if (native && wrapper) {
|
|
350
|
+
CATransaction.begin()
|
|
351
|
+
CATransaction.setDisableActions(true)
|
|
352
|
+
try {
|
|
353
|
+
// Hide before removing a closed mask so conceal never flashes its content.
|
|
354
|
+
if (!keepVisibility)
|
|
355
|
+
this.visibility(
|
|
356
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
357
|
+
)
|
|
358
|
+
shape?.removeAnimationForKey(key)
|
|
359
|
+
wrapper.mask = null
|
|
360
|
+
if (native.layer.mask === wrapper) native.layer.mask = this.originalMask
|
|
361
|
+
// Core can save the temporary mask while installing a new clip/border mask.
|
|
362
|
+
if (native.originalMask === wrapper) native.originalMask = this.savedMask
|
|
363
|
+
} finally {
|
|
364
|
+
CATransaction.commit()
|
|
365
|
+
}
|
|
366
|
+
} else if (isIOS && !keepVisibility) {
|
|
367
|
+
this.visibility(
|
|
368
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
this.native = this.wrapper = this.shape = this.delegate = this.observer = undefined
|
|
372
|
+
this.resolve(finished)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** TextKit lays out the complete string before any glyphs are separated. */
|
|
2
|
+
type Insets = { top: number; left: number; bottom: number; right: number }
|
|
3
|
+
type NativeLabel = UILabel & { padding?: Insets; borderThickness?: Insets }
|
|
4
|
+
const MAX_PIECES = 256
|
|
5
|
+
const MAX_PIXELS = 4_000_000
|
|
6
|
+
|
|
7
|
+
/** Ligatures and composed characters may share glyphs. Never draw a shared glyph twice. */
|
|
8
|
+
export function mergeGlyphRanges(ranges: readonly NSRange[]): NSRange[] {
|
|
9
|
+
const merged: NSRange[] = []
|
|
10
|
+
for (const range of ranges) {
|
|
11
|
+
if (!range.length) continue
|
|
12
|
+
const previous = merged.at(-1)
|
|
13
|
+
if (
|
|
14
|
+
previous &&
|
|
15
|
+
range.location < previous.location + previous.length &&
|
|
16
|
+
range.location + range.length > previous.location
|
|
17
|
+
) {
|
|
18
|
+
const end = Math.max(
|
|
19
|
+
previous.location + previous.length,
|
|
20
|
+
range.location + range.length,
|
|
21
|
+
)
|
|
22
|
+
previous.location = Math.min(previous.location, range.location)
|
|
23
|
+
previous.length = end - previous.location
|
|
24
|
+
} else merged.push({ location: range.location, length: range.length })
|
|
25
|
+
}
|
|
26
|
+
return merged
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
|
|
30
|
+
if (native.adjustsFontSizeToFitWidth) return undefined
|
|
31
|
+
const original = native.attributedText
|
|
32
|
+
const text = original?.string ?? native.text ?? ''
|
|
33
|
+
if (!text.trim()) return { text, layer: undefined }
|
|
34
|
+
const string = NSString.stringWithString(text)
|
|
35
|
+
const full = { location: 0, length: string.length }
|
|
36
|
+
const attributed = original
|
|
37
|
+
? NSMutableAttributedString.alloc().initWithAttributedString(original)
|
|
38
|
+
: NSMutableAttributedString.alloc().initWithString(text)
|
|
39
|
+
const paragraph = NSMutableParagraphStyle.new()
|
|
40
|
+
paragraph.alignment = native.textAlignment
|
|
41
|
+
paragraph.lineBreakMode = native.lineBreakMode
|
|
42
|
+
const defaults: [string, unknown][] = [
|
|
43
|
+
[NSFontAttributeName, native.font],
|
|
44
|
+
[NSForegroundColorAttributeName, native.textColor],
|
|
45
|
+
[NSParagraphStyleAttributeName, paragraph],
|
|
46
|
+
]
|
|
47
|
+
if (native.shadowColor) {
|
|
48
|
+
const shadow = NSShadow.new()
|
|
49
|
+
shadow.shadowColor = native.shadowColor
|
|
50
|
+
shadow.shadowOffset = native.shadowOffset
|
|
51
|
+
defaults.push([NSShadowAttributeName, shadow])
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of defaults) {
|
|
54
|
+
attributed.enumerateAttributeInRangeOptionsUsingBlock(
|
|
55
|
+
key,
|
|
56
|
+
full,
|
|
57
|
+
0 as NSAttributedStringEnumerationOptions,
|
|
58
|
+
(attribute, range) => {
|
|
59
|
+
if (attribute == null && value != null)
|
|
60
|
+
attributed.addAttributeValueRange(key, value, range)
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
const inset = (side: keyof Insets) =>
|
|
65
|
+
(native.padding?.[side] ?? 0) + (native.borderThickness?.[side] ?? 0)
|
|
66
|
+
const bounds = native.bounds
|
|
67
|
+
const width = bounds.size.width - inset('left') - inset('right')
|
|
68
|
+
const height = bounds.size.height - inset('top') - inset('bottom')
|
|
69
|
+
if (width <= 0 || height <= 0) return undefined
|
|
70
|
+
const storage = NSTextStorage.alloc().initWithAttributedString(attributed)
|
|
71
|
+
const manager = NSLayoutManager.new()
|
|
72
|
+
manager.usesFontLeading = false
|
|
73
|
+
const container = NSTextContainer.alloc().initWithSize(CGSizeMake(width, height))
|
|
74
|
+
container.lineFragmentPadding = 0
|
|
75
|
+
container.maximumNumberOfLines = native.numberOfLines
|
|
76
|
+
container.lineBreakMode = native.lineBreakMode
|
|
77
|
+
manager.addTextContainer(container)
|
|
78
|
+
storage.addLayoutManager(manager)
|
|
79
|
+
manager.ensureLayoutForTextContainer(container)
|
|
80
|
+
const visible = manager.glyphRangeForTextContainer(container)
|
|
81
|
+
if (visible.length !== manager.numberOfGlyphs) return undefined
|
|
82
|
+
// A truncation token is not an independently addressable character. Keep the real label.
|
|
83
|
+
for (let glyph = 0; glyph < manager.numberOfGlyphs;) {
|
|
84
|
+
if (manager.truncatedGlyphRangeInLineFragmentForGlyphAtIndex(glyph).length)
|
|
85
|
+
return undefined
|
|
86
|
+
const range = new interop.Reference<NSRange>()
|
|
87
|
+
manager.lineFragmentRectForGlyphAtIndexEffectiveRange(glyph, range)
|
|
88
|
+
const next = range.value.location + range.value.length
|
|
89
|
+
if (next <= glyph) return undefined
|
|
90
|
+
glyph = next
|
|
91
|
+
}
|
|
92
|
+
const characters: NSRange[] = []
|
|
93
|
+
string.enumerateSubstringsInRangeOptionsUsingBlock(
|
|
94
|
+
full,
|
|
95
|
+
by === 'letter' ? 2 : 3,
|
|
96
|
+
(substring, range) => {
|
|
97
|
+
if (substring?.trim())
|
|
98
|
+
characters.push({ location: range.location, length: range.length })
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
if (by === 'word') {
|
|
102
|
+
// Attach spaces and punctuation to the surrounding word, preserving every visible mark.
|
|
103
|
+
characters.forEach((range, index) => {
|
|
104
|
+
const start = index === 0 ? 0 : range.location
|
|
105
|
+
const end = characters[index + 1]?.location ?? full.length
|
|
106
|
+
range.location = start
|
|
107
|
+
range.length = end - start
|
|
108
|
+
})
|
|
109
|
+
if (!characters.length) characters.push(full)
|
|
110
|
+
}
|
|
111
|
+
const ranges = mergeGlyphRanges(
|
|
112
|
+
characters.map((range) =>
|
|
113
|
+
manager.glyphRangeForCharacterRangeActualCharacterRange(range, null),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
if (ranges.length > MAX_PIECES) return undefined
|
|
117
|
+
const used = manager.usedRectForTextContainer(container)
|
|
118
|
+
const offsetX = bounds.origin.x + inset('left')
|
|
119
|
+
const offsetY =
|
|
120
|
+
bounds.origin.y + inset('top') + (height - used.size.height) / 2 - used.origin.y
|
|
121
|
+
const scale = native.window?.screen.scale ?? UIScreen.mainScreen.scale
|
|
122
|
+
const padding = Math.max(4, native.font.pointSize * 0.1)
|
|
123
|
+
const pieces = ranges.map((range) => {
|
|
124
|
+
const glyph = manager.boundingRectForGlyphRangeInTextContainer(range, container)
|
|
125
|
+
return {
|
|
126
|
+
range,
|
|
127
|
+
rect: CGRectIntegral(
|
|
128
|
+
CGRectMake(
|
|
129
|
+
glyph.origin.x + offsetX - padding,
|
|
130
|
+
glyph.origin.y + offsetY - padding,
|
|
131
|
+
glyph.size.width + padding * 2,
|
|
132
|
+
glyph.size.height + padding * 2,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
if (
|
|
138
|
+
pieces.reduce(
|
|
139
|
+
(pixels, { rect }) => pixels + rect.size.width * rect.size.height * scale * scale,
|
|
140
|
+
0,
|
|
141
|
+
) > MAX_PIXELS
|
|
142
|
+
)
|
|
143
|
+
return undefined
|
|
144
|
+
const layer = CALayer.new()
|
|
145
|
+
layer.name = 'jolt:text'
|
|
146
|
+
layer.frame = bounds
|
|
147
|
+
const transparent = UIColor.clearColor
|
|
148
|
+
const noShadow = NSShadow.new()
|
|
149
|
+
noShadow.shadowColor = transparent
|
|
150
|
+
const hidden = NSMutableAttributedString.alloc().initWithAttributedString(attributed)
|
|
151
|
+
for (const key of [
|
|
152
|
+
NSForegroundColorAttributeName,
|
|
153
|
+
NSBackgroundColorAttributeName,
|
|
154
|
+
NSUnderlineColorAttributeName,
|
|
155
|
+
NSStrikethroughColorAttributeName,
|
|
156
|
+
NSStrokeColorAttributeName,
|
|
157
|
+
]) {
|
|
158
|
+
hidden.addAttributeValueRange(key, transparent, full)
|
|
159
|
+
}
|
|
160
|
+
hidden.addAttributeValueRange(NSShadowAttributeName, noShadow, full)
|
|
161
|
+
let attachments = false
|
|
162
|
+
attributed.enumerateAttributeInRangeOptionsUsingBlock(
|
|
163
|
+
NSAttachmentAttributeName,
|
|
164
|
+
full,
|
|
165
|
+
0 as NSAttributedStringEnumerationOptions,
|
|
166
|
+
(value) => {
|
|
167
|
+
if (value != null) attachments = true
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
if (attachments) return undefined
|
|
171
|
+
const shadowColor = native.shadowColor
|
|
172
|
+
try {
|
|
173
|
+
native.shadowColor = null
|
|
174
|
+
for (const { range, rect } of pieces) {
|
|
175
|
+
const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
|
|
176
|
+
const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
|
|
177
|
+
attributed.enumerateAttributesInRangeOptionsUsingBlock(
|
|
178
|
+
characters,
|
|
179
|
+
0 as NSAttributedStringEnumerationOptions,
|
|
180
|
+
(attributes, part) => fragment.setAttributesRange(attributes, part),
|
|
181
|
+
)
|
|
182
|
+
native.attributedText = fragment
|
|
183
|
+
// UILabel draws its own glyphs, preserving its baseline rounding and fallback-font metrics.
|
|
184
|
+
// Other glyphs keep their shaping/advances but have transparent paint.
|
|
185
|
+
UIGraphicsBeginImageContextWithOptions(rect.size, false, scale)
|
|
186
|
+
let image: UIImage | null
|
|
187
|
+
try {
|
|
188
|
+
CGContextTranslateCTM(
|
|
189
|
+
UIGraphicsGetCurrentContext()!,
|
|
190
|
+
-rect.origin.x,
|
|
191
|
+
-rect.origin.y,
|
|
192
|
+
)
|
|
193
|
+
native.drawTextInRect(bounds)
|
|
194
|
+
image = UIGraphicsGetImageFromCurrentImageContext()
|
|
195
|
+
} finally {
|
|
196
|
+
UIGraphicsEndImageContext()
|
|
197
|
+
}
|
|
198
|
+
if (!image?.CGImage) return undefined
|
|
199
|
+
const piece = CALayer.new()
|
|
200
|
+
piece.frame = CGRectMake(
|
|
201
|
+
rect.origin.x - bounds.origin.x,
|
|
202
|
+
rect.origin.y - bounds.origin.y,
|
|
203
|
+
rect.size.width,
|
|
204
|
+
rect.size.height,
|
|
205
|
+
)
|
|
206
|
+
piece.contents = image.CGImage
|
|
207
|
+
piece.contentsScale = scale
|
|
208
|
+
layer.addSublayer(piece)
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
if (original) native.attributedText = original
|
|
212
|
+
else native.text = text
|
|
213
|
+
native.shadowColor = shadowColor
|
|
214
|
+
}
|
|
215
|
+
return { text, layer }
|
|
216
|
+
}
|