@rafaelaugusto/jolt 0.1.0-beta.2 → 0.1.0-beta.3
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 +60 -6
- package/dist/animate.d.ts +3 -1
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +65 -1
- package/dist/animate.js.map +1 -1
- 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.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +95 -5
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +3 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +6 -3
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- 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/properties.d.ts +1 -1
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +1 -1
- 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 +264 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +25 -3
- 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.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/animate.ts +75 -1
- package/src/decay.ts +45 -0
- package/src/driver/android.ts +112 -6
- package/src/driver/index.ts +3 -1
- package/src/engine.ts +8 -4
- package/src/index.ts +7 -1
- package/src/loader.ts +201 -0
- package/src/properties.ts +1 -1
- package/src/reveal.ts +374 -0
- package/src/text-layout.ts +216 -0
- package/src/text.ts +296 -0
- package/src/track.ts +42 -3
- package/src/transition.ts +6 -0
- package/src/types.ts +13 -0
|
@@ -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
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Application, Label, isIOS } from '@nativescript/core'
|
|
2
|
+
import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
|
|
3
|
+
import { toView } from './animate.js'
|
|
4
|
+
import type { Animation } from './engine.js'
|
|
5
|
+
import { textLayer } from './text-layout.js'
|
|
6
|
+
import type { Target } from './types.js'
|
|
7
|
+
|
|
8
|
+
export interface TextOptions {
|
|
9
|
+
/** Composed letters (including emoji), or words. Defaults to 'letter'. */
|
|
10
|
+
by?: 'letter' | 'word'
|
|
11
|
+
/** Milliseconds per piece. Defaults to 400. */
|
|
12
|
+
duration?: number
|
|
13
|
+
/** Milliseconds between pieces. Defaults to 35. */
|
|
14
|
+
stagger?: number
|
|
15
|
+
/** Milliseconds before the first piece. Defaults to zero. */
|
|
16
|
+
delay?: number
|
|
17
|
+
/** Initial vertical offset in DIPs. Defaults to 12. */
|
|
18
|
+
y?: number
|
|
19
|
+
}
|
|
20
|
+
type Options = Required<TextOptions>
|
|
21
|
+
const running = new WeakMap<Label, TextAnimation>()
|
|
22
|
+
const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
|
|
23
|
+
let Delegate: typeof NSObject | undefined
|
|
24
|
+
const styleEvents = [
|
|
25
|
+
'fontInternalChange',
|
|
26
|
+
'colorChange',
|
|
27
|
+
'textAlignmentChange',
|
|
28
|
+
'textDecorationChange',
|
|
29
|
+
'textTransformChange',
|
|
30
|
+
'letterSpacingChange',
|
|
31
|
+
'lineHeightChange',
|
|
32
|
+
'textShadowChange',
|
|
33
|
+
'textStrokeChange',
|
|
34
|
+
'whiteSpaceChange',
|
|
35
|
+
'textOverflowChange',
|
|
36
|
+
'maxLinesChange',
|
|
37
|
+
'paddingLeftChange',
|
|
38
|
+
'paddingRightChange',
|
|
39
|
+
'paddingTopChange',
|
|
40
|
+
'paddingBottomChange',
|
|
41
|
+
'visibilityChange',
|
|
42
|
+
'directionChange',
|
|
43
|
+
'borderLeftWidthChange',
|
|
44
|
+
'borderRightWidthChange',
|
|
45
|
+
'borderTopWidthChange',
|
|
46
|
+
'borderBottomWidthChange',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
|
|
50
|
+
export function animateText(target: Target, options: TextOptions = {}): Animation {
|
|
51
|
+
const config: Options = {
|
|
52
|
+
by: options.by ?? 'letter',
|
|
53
|
+
duration: options.duration ?? 400,
|
|
54
|
+
stagger: options.stagger ?? 35,
|
|
55
|
+
delay: options.delay ?? 0,
|
|
56
|
+
y: options.y ?? 12,
|
|
57
|
+
}
|
|
58
|
+
if (config.by !== 'letter' && config.by !== 'word')
|
|
59
|
+
throw new TypeError('Text grouping must be letter or word.')
|
|
60
|
+
for (const key of ['duration', 'stagger', 'delay'] as const) {
|
|
61
|
+
if (!Number.isFinite(config[key]) || config[key] < 0)
|
|
62
|
+
throw new RangeError(`Text ${key} must be a nonnegative finite number.`)
|
|
63
|
+
}
|
|
64
|
+
if (!Number.isFinite(config.y)) throw new RangeError('Text y must be finite.')
|
|
65
|
+
const view = toView(target)
|
|
66
|
+
if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
|
|
67
|
+
running.get(view)?.stop()
|
|
68
|
+
return new TextAnimation(view, config)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function completion(
|
|
72
|
+
callback: (finished: boolean) => void,
|
|
73
|
+
): NSObject & CAAnimationDelegate {
|
|
74
|
+
if (!Delegate) {
|
|
75
|
+
const base = NSObject as typeof NSObject & {
|
|
76
|
+
extend(
|
|
77
|
+
methods: object,
|
|
78
|
+
options: { protocols: unknown[]; name: string },
|
|
79
|
+
): typeof NSObject
|
|
80
|
+
}
|
|
81
|
+
Delegate = base.extend(
|
|
82
|
+
{
|
|
83
|
+
animationDidStopFinished(
|
|
84
|
+
this: NSObject,
|
|
85
|
+
_animation: CAAnimation,
|
|
86
|
+
finished: boolean,
|
|
87
|
+
) {
|
|
88
|
+
callbacks.get(this)?.(finished)
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{ protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
const delegate = Delegate.new()
|
|
95
|
+
callbacks.set(delegate, callback)
|
|
96
|
+
return delegate
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class TextAnimation implements Animation {
|
|
100
|
+
readonly finished: Promise<boolean>
|
|
101
|
+
private resolve!: (finished: boolean) => void
|
|
102
|
+
private readonly formatted: Label['formattedText']
|
|
103
|
+
private settled = false
|
|
104
|
+
private queued = false
|
|
105
|
+
private native: UILabel | undefined
|
|
106
|
+
private bounds: CGRect | undefined
|
|
107
|
+
private layer: CALayer | undefined
|
|
108
|
+
private originalText: string | null = null
|
|
109
|
+
private originalAttributed: NSAttributedString | null = null
|
|
110
|
+
private accessibility: string | null = null
|
|
111
|
+
private temporaryAccessibility: string | undefined
|
|
112
|
+
private delegate: NSObject | undefined
|
|
113
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly view: Label,
|
|
117
|
+
private readonly options: Options,
|
|
118
|
+
) {
|
|
119
|
+
this.finished = new Promise((resolve) => {
|
|
120
|
+
this.resolve = resolve
|
|
121
|
+
})
|
|
122
|
+
this.formatted = view.formattedText
|
|
123
|
+
running.set(view, this)
|
|
124
|
+
if (!isIOS) {
|
|
125
|
+
this.end(false)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
|
|
129
|
+
this.end(true)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
view.on('loaded', this.schedule)
|
|
133
|
+
view.on('layoutChanged', this.layoutChanged)
|
|
134
|
+
view.on('unloaded', this.stop)
|
|
135
|
+
view.on('textChange', this.changed)
|
|
136
|
+
view.on('formattedTextChange', this.changed)
|
|
137
|
+
this.formatted?.on('propertyChange', this.changed)
|
|
138
|
+
for (const event of styleEvents) view.style.on(event, this.changed)
|
|
139
|
+
this.schedule()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
then<A = boolean, B = never>(
|
|
143
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
144
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
145
|
+
): Promise<A | B> {
|
|
146
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
147
|
+
}
|
|
148
|
+
/** Cancels the entrance and restores the full text. */
|
|
149
|
+
stop = () => this.end(false)
|
|
150
|
+
private changed = () => this.end(false, true)
|
|
151
|
+
private motionChanged = () => {
|
|
152
|
+
if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
|
|
153
|
+
}
|
|
154
|
+
private layoutChanged = () => {
|
|
155
|
+
const native = this.view.nativeViewProtected as UILabel | undefined
|
|
156
|
+
const a = this.bounds
|
|
157
|
+
const b = native?.bounds
|
|
158
|
+
if (
|
|
159
|
+
this.layer &&
|
|
160
|
+
(native !== this.native ||
|
|
161
|
+
!native?.window ||
|
|
162
|
+
!b ||
|
|
163
|
+
!a ||
|
|
164
|
+
a.origin.x !== b.origin.x ||
|
|
165
|
+
a.origin.y !== b.origin.y ||
|
|
166
|
+
a.size.width !== b.size.width ||
|
|
167
|
+
a.size.height !== b.size.height)
|
|
168
|
+
)
|
|
169
|
+
return this.stop()
|
|
170
|
+
this.schedule()
|
|
171
|
+
}
|
|
172
|
+
private schedule = () => {
|
|
173
|
+
if (this.settled || this.queued) return
|
|
174
|
+
this.queued = true
|
|
175
|
+
void Promise.resolve().then(() => {
|
|
176
|
+
this.queued = false
|
|
177
|
+
if (this.settled || this.layer || !this.view.isLoaded) return
|
|
178
|
+
try {
|
|
179
|
+
this.start()
|
|
180
|
+
} catch {
|
|
181
|
+
this.end(false)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
private start() {
|
|
186
|
+
if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
|
|
187
|
+
return this.end(true)
|
|
188
|
+
if (this.view.visibility !== 'visible') return this.end(false)
|
|
189
|
+
const native = this.view.nativeViewProtected as UILabel | undefined
|
|
190
|
+
if (!(native instanceof UILabel) || !native.window) return
|
|
191
|
+
if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
|
|
192
|
+
const prepared = textLayer(native, this.options.by)
|
|
193
|
+
if (!prepared) return this.end(false)
|
|
194
|
+
if (!prepared.layer?.sublayers?.count) return this.end(true)
|
|
195
|
+
if (
|
|
196
|
+
!Number.isFinite(
|
|
197
|
+
this.options.delay +
|
|
198
|
+
this.options.duration +
|
|
199
|
+
(prepared.layer.sublayers.count - 1) * this.options.stagger,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return this.end(false)
|
|
203
|
+
this.native = native
|
|
204
|
+
this.bounds = native.bounds
|
|
205
|
+
this.layer = prepared.layer
|
|
206
|
+
this.originalText = native.text
|
|
207
|
+
this.originalAttributed = native.attributedText
|
|
208
|
+
this.accessibility = native.accessibilityLabel
|
|
209
|
+
this.temporaryAccessibility = this.accessibility ?? prepared.text
|
|
210
|
+
const { duration, delay, stagger, y } = this.options
|
|
211
|
+
const start = CACurrentMediaTime()
|
|
212
|
+
CATransaction.begin()
|
|
213
|
+
CATransaction.setDisableActions(true)
|
|
214
|
+
try {
|
|
215
|
+
native.text = ''
|
|
216
|
+
native.accessibilityLabel = this.temporaryAccessibility
|
|
217
|
+
native.layer.addSublayer(this.layer)
|
|
218
|
+
const pieces = this.layer.sublayers!
|
|
219
|
+
for (let i = 0; i < pieces.count; i++) {
|
|
220
|
+
const piece = pieces.objectAtIndex(i)
|
|
221
|
+
const fade = CABasicAnimation.animationWithKeyPath('opacity')
|
|
222
|
+
fade.fromValue = 0
|
|
223
|
+
fade.toValue = 1
|
|
224
|
+
const move = CABasicAnimation.animationWithKeyPath('transform.translation.y')
|
|
225
|
+
move.fromValue = y
|
|
226
|
+
move.toValue = 0
|
|
227
|
+
fade.duration = move.duration = duration / 1000
|
|
228
|
+
const group = CAAnimationGroup.new()
|
|
229
|
+
group.animations = NSArray.arrayWithArray([fade, move])
|
|
230
|
+
group.duration = duration / 1000
|
|
231
|
+
group.beginTime = start + (delay + i * stagger) / 1000
|
|
232
|
+
group.fillMode = kCAFillModeBackwards
|
|
233
|
+
group.timingFunction = CAMediaTimingFunction.functionWithName(
|
|
234
|
+
kCAMediaTimingFunctionEaseOut,
|
|
235
|
+
)
|
|
236
|
+
if (i === pieces.count - 1)
|
|
237
|
+
this.delegate = group.delegate = completion((finished) => this.end(finished))
|
|
238
|
+
piece.addAnimationForKey(group, 'jolt:text')
|
|
239
|
+
}
|
|
240
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
241
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
242
|
+
this.motionChanged,
|
|
243
|
+
)
|
|
244
|
+
} finally {
|
|
245
|
+
CATransaction.commit()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
private end(finished: boolean, refresh = false) {
|
|
249
|
+
if (this.settled) return
|
|
250
|
+
this.settled = true
|
|
251
|
+
const { view, native, layer } = this
|
|
252
|
+
view.off('loaded', this.schedule)
|
|
253
|
+
view.off('layoutChanged', this.layoutChanged)
|
|
254
|
+
view.off('unloaded', this.stop)
|
|
255
|
+
view.off('textChange', this.changed)
|
|
256
|
+
view.off('formattedTextChange', this.changed)
|
|
257
|
+
this.formatted?.off('propertyChange', this.changed)
|
|
258
|
+
for (const event of styleEvents) view.style.off(event, this.changed)
|
|
259
|
+
if (running.get(view) === this) running.delete(view)
|
|
260
|
+
if (this.observer)
|
|
261
|
+
Application.ios.removeNotificationObserver(
|
|
262
|
+
this.observer,
|
|
263
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
264
|
+
)
|
|
265
|
+
if (this.delegate) callbacks.delete(this.delegate)
|
|
266
|
+
if (native && layer) {
|
|
267
|
+
CATransaction.begin()
|
|
268
|
+
CATransaction.setDisableActions(true)
|
|
269
|
+
try {
|
|
270
|
+
// Restore only the native text we suppressed; app changes win.
|
|
271
|
+
if (!native.text && !native.attributedText?.length) {
|
|
272
|
+
if (refresh && view.nativeViewProtected === native) {
|
|
273
|
+
const property = view.formattedText ? formattedTextProperty : textProperty
|
|
274
|
+
;(view as unknown as Record<symbol, (value: unknown) => void>)[
|
|
275
|
+
property.setNative
|
|
276
|
+
]!(view.formattedText ?? view.text)
|
|
277
|
+
} else if (this.originalAttributed)
|
|
278
|
+
native.attributedText = this.originalAttributed
|
|
279
|
+
else native.text = this.originalText
|
|
280
|
+
}
|
|
281
|
+
if (native.accessibilityLabel === this.temporaryAccessibility)
|
|
282
|
+
native.accessibilityLabel = this.accessibility
|
|
283
|
+
layer.removeFromSuperlayer()
|
|
284
|
+
const pieces = layer.sublayers
|
|
285
|
+
if (pieces)
|
|
286
|
+
for (let i = 0; i < pieces.count; i++)
|
|
287
|
+
pieces.objectAtIndex(i).removeAnimationForKey('jolt:text')
|
|
288
|
+
} finally {
|
|
289
|
+
CATransaction.commit()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.native = this.layer = this.delegate = this.observer = undefined
|
|
293
|
+
this.originalAttributed = null
|
|
294
|
+
this.resolve(finished)
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/track.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { linear } from './easing.js'
|
|
2
|
+
import { solveDecay } from './decay.js'
|
|
2
3
|
import { precision, type Property } from './properties.js'
|
|
3
|
-
import { solveSpring } from './spring.js'
|
|
4
|
+
import { solveSpring, springPhysics } from './spring.js'
|
|
4
5
|
import type { Curve, ResolvedTransition } from './transition.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -37,6 +38,7 @@ interface Motion {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const SAMPLES_PER_SECOND = 60
|
|
41
|
+
const MAX_DECAY_SAMPLES = 6000
|
|
40
42
|
const ARRIVAL_TOLERANCE = 0.05
|
|
41
43
|
|
|
42
44
|
export function createTrack(
|
|
@@ -48,7 +50,9 @@ export function createTrack(
|
|
|
48
50
|
const motion =
|
|
49
51
|
curve.type === 'spring'
|
|
50
52
|
? spring(property, keyframes, curve, velocity)
|
|
51
|
-
:
|
|
53
|
+
: curve.type === 'decay'
|
|
54
|
+
? decay(property, keyframes[0]!, curve)
|
|
55
|
+
: tween(keyframes, curve)
|
|
52
56
|
const { duration } = motion
|
|
53
57
|
const first = motion.values[0]!
|
|
54
58
|
const last = motion.values.at(-1)!
|
|
@@ -75,7 +79,11 @@ export function createTrack(
|
|
|
75
79
|
values: motion.values,
|
|
76
80
|
to: !finite || endsBackwards ? first : last,
|
|
77
81
|
end,
|
|
78
|
-
arrival:
|
|
82
|
+
arrival:
|
|
83
|
+
(curve.type === 'spring' || (curve.type === 'decay' && curve.snap !== undefined)) &&
|
|
84
|
+
repeat === 0
|
|
85
|
+
? delay + arrival(motion)
|
|
86
|
+
: end,
|
|
79
87
|
valueAt: (elapsed) => motion.position(localTime(elapsed)[0]),
|
|
80
88
|
velocityAt: (elapsed) => {
|
|
81
89
|
if (elapsed <= delay || elapsed >= end) return 0
|
|
@@ -85,6 +93,37 @@ export function createTrack(
|
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
function decay(
|
|
97
|
+
property: Property,
|
|
98
|
+
from: number,
|
|
99
|
+
curve: Extract<Curve, { type: 'decay' }>,
|
|
100
|
+
): Motion {
|
|
101
|
+
const motion = solveDecay(from, curve.velocity, curve.friction, precision[property])
|
|
102
|
+
if (curve.snap) {
|
|
103
|
+
const to = curve.snap.reduce((closest, stop) =>
|
|
104
|
+
Math.abs(stop - motion.to) < Math.abs(closest - motion.to) ? stop : closest,
|
|
105
|
+
)
|
|
106
|
+
return spring(
|
|
107
|
+
property,
|
|
108
|
+
[from, to],
|
|
109
|
+
{ type: 'spring', physics: springPhysics({}), velocity: curve.velocity },
|
|
110
|
+
0,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const steps = Math.max(
|
|
115
|
+
1,
|
|
116
|
+
Math.min(MAX_DECAY_SAMPLES, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND)),
|
|
117
|
+
)
|
|
118
|
+
const times: number[] = []
|
|
119
|
+
const values: number[] = []
|
|
120
|
+
for (let i = 0; i <= steps; i++) {
|
|
121
|
+
times.push(i / steps)
|
|
122
|
+
values.push(motion.position((i / steps) * motion.duration))
|
|
123
|
+
}
|
|
124
|
+
return { ...motion, times, values }
|
|
125
|
+
}
|
|
126
|
+
|
|
88
127
|
function arrival({ times, values, duration }: Motion) {
|
|
89
128
|
const target = values.at(-1)!
|
|
90
129
|
const tolerance =
|
package/src/transition.ts
CHANGED
|
@@ -52,6 +52,12 @@ export type Curve =
|
|
|
52
52
|
times: readonly number[] | undefined
|
|
53
53
|
}
|
|
54
54
|
| { type: 'spring'; physics: SpringPhysics; velocity: number | undefined }
|
|
55
|
+
| {
|
|
56
|
+
type: 'decay'
|
|
57
|
+
friction: number
|
|
58
|
+
velocity: number
|
|
59
|
+
snap: readonly number[] | undefined
|
|
60
|
+
}
|
|
55
61
|
|
|
56
62
|
export interface ResolvedTransition {
|
|
57
63
|
curve: Curve
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface Keyframes {
|
|
|
25
25
|
export type Target =
|
|
26
26
|
View | { readonly nativeView: View } | { readonly nativeElement: View }
|
|
27
27
|
|
|
28
|
+
/** Translation velocities in DIPs per second. Omitted axes keep their current motion. */
|
|
29
|
+
export interface DecayVelocity {
|
|
30
|
+
x?: number
|
|
31
|
+
y?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DecayOptions {
|
|
35
|
+
/** Velocity multiplier per millisecond, between 0 and 1 exclusively. Defaults to 0.998. */
|
|
36
|
+
friction?: number
|
|
37
|
+
/** Spring to the closest stop to the projected landing, shared or set per axis. */
|
|
38
|
+
snap?: readonly number[] | { x?: readonly number[]; y?: readonly number[] }
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
export interface Stagger {
|
|
29
42
|
/** Milliseconds between one target and the next. */
|
|
30
43
|
each: number
|