@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.
Files changed (67) hide show
  1. package/README.md +60 -6
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +65 -1
  5. package/dist/animate.js.map +1 -1
  6. package/dist/decay.d.ts +11 -0
  7. package/dist/decay.d.ts.map +1 -0
  8. package/dist/decay.js +22 -0
  9. package/dist/decay.js.map +1 -0
  10. package/dist/driver/android.d.ts +1 -1
  11. package/dist/driver/android.d.ts.map +1 -1
  12. package/dist/driver/android.js +95 -5
  13. package/dist/driver/android.js.map +1 -1
  14. package/dist/driver/index.d.ts +3 -1
  15. package/dist/driver/index.d.ts.map +1 -1
  16. package/dist/driver/index.js.map +1 -1
  17. package/dist/engine.d.ts +1 -1
  18. package/dist/engine.d.ts.map +1 -1
  19. package/dist/engine.js +6 -3
  20. package/dist/engine.js.map +1 -1
  21. package/dist/index.d.ts +7 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/loader.d.ts +19 -0
  26. package/dist/loader.d.ts.map +1 -0
  27. package/dist/loader.js +172 -0
  28. package/dist/loader.js.map +1 -0
  29. package/dist/properties.d.ts +1 -1
  30. package/dist/properties.d.ts.map +1 -1
  31. package/dist/properties.js +1 -1
  32. package/dist/properties.js.map +1 -1
  33. package/dist/reveal.d.ts +25 -0
  34. package/dist/reveal.d.ts.map +1 -0
  35. package/dist/reveal.js +293 -0
  36. package/dist/reveal.js.map +1 -0
  37. package/dist/text-layout.d.ts +22 -0
  38. package/dist/text-layout.d.ts.map +1 -0
  39. package/dist/text-layout.js +179 -0
  40. package/dist/text-layout.js.map +1 -0
  41. package/dist/text.d.ts +17 -0
  42. package/dist/text.d.ts.map +1 -0
  43. package/dist/text.js +264 -0
  44. package/dist/text.js.map +1 -0
  45. package/dist/track.d.ts.map +1 -1
  46. package/dist/track.js +25 -3
  47. package/dist/track.js.map +1 -1
  48. package/dist/transition.d.ts +5 -0
  49. package/dist/transition.d.ts.map +1 -1
  50. package/dist/transition.js.map +1 -1
  51. package/dist/types.d.ts +14 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/animate.ts +75 -1
  55. package/src/decay.ts +45 -0
  56. package/src/driver/android.ts +112 -6
  57. package/src/driver/index.ts +3 -1
  58. package/src/engine.ts +8 -4
  59. package/src/index.ts +7 -1
  60. package/src/loader.ts +201 -0
  61. package/src/properties.ts +1 -1
  62. package/src/reveal.ts +374 -0
  63. package/src/text-layout.ts +216 -0
  64. package/src/text.ts +296 -0
  65. package/src/track.ts +42 -3
  66. package/src/transition.ts +6 -0
  67. 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
- : tween(keyframes, curve)
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: curve.type === 'spring' && repeat === 0 ? delay + arrival(motion) : end,
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