@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.7
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 +122 -5
- package/dist/animate.d.ts +4 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +48 -11
- package/dist/animate.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +7 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +2 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.js +1 -1
- package/dist/driver/ios-color.js.map +1 -1
- package/dist/driver/ios-decoration.js +1 -1
- package/dist/driver/ios-decoration.js.map +1 -1
- package/dist/driver/ios-playback.d.ts +1 -1
- package/dist/driver/ios-playback.d.ts.map +1 -1
- package/dist/driver/ios-playback.js +14 -0
- package/dist/driver/ios-playback.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +1 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +16 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +33 -1
- package/dist/engine.js.map +1 -1
- package/dist/image-effect-native.d.ts +26 -0
- package/dist/image-effect-native.d.ts.map +1 -0
- package/dist/image-effect-native.js +73 -0
- package/dist/image-effect-native.js.map +1 -0
- package/dist/image-effect.d.ts +50 -0
- package/dist/image-effect.d.ts.map +1 -0
- package/dist/image-effect.js +290 -0
- package/dist/image-effect.js.map +1 -0
- package/dist/image-owner.d.ts +3 -0
- package/dist/image-owner.d.ts.map +1 -0
- package/dist/image-owner.js +11 -0
- package/dist/image-owner.js.map +1 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +17 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +116 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/liquid.d.ts.map +1 -1
- package/dist/liquid.js +6 -1
- package/dist/liquid.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -1
- package/dist/loader.js.map +1 -1
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +4 -3
- package/dist/particles.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts.map +1 -1
- package/dist/playback-group.js +29 -1
- package/dist/playback-group.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -3
- package/dist/press.js.map +1 -1
- package/dist/reveal.d.ts.map +1 -1
- package/dist/reveal.js +3 -2
- package/dist/reveal.js.map +1 -1
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +202 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts.map +1 -1
- package/dist/shimmer.js +2 -1
- package/dist/shimmer.js.map +1 -1
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts.map +1 -1
- package/dist/symbol-effect.js +4 -3
- package/dist/symbol-effect.js.map +1 -1
- package/dist/text-layout.d.ts +8 -0
- package/dist/text-layout.d.ts.map +1 -1
- package/dist/text-layout.js +8 -2
- package/dist/text-layout.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +60 -64
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +2 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +62 -15
- package/dist/track.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +89 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +134 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/zoom-native.d.ts +30 -0
- package/dist/zoom-native.d.ts.map +1 -0
- package/dist/zoom-native.js +56 -0
- package/dist/zoom-native.js.map +1 -0
- package/dist/zoomable.d.ts +48 -0
- package/dist/zoomable.d.ts.map +1 -0
- package/dist/zoomable.js +249 -0
- package/dist/zoomable.js.map +1 -0
- package/package.json +16 -3
- package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +10 -0
- package/platforms/ios/build.xcconfig +1 -0
- package/platforms/ios/src/JoltImageEffect.h +25 -0
- package/platforms/ios/src/JoltImageEffect.mm +242 -0
- package/platforms/ios/src/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +5 -0
- package/src/android-plugin.d.ts +142 -0
- package/src/animate.ts +80 -30
- package/src/config.ts +46 -0
- package/src/driver/android.ts +14 -0
- package/src/driver/index.ts +2 -0
- package/src/driver/ios-color.ts +1 -1
- package/src/driver/ios-decoration.ts +1 -1
- package/src/driver/ios-playback.ts +15 -1
- package/src/driver/ios.ts +2 -0
- package/src/engine.ts +47 -2
- package/src/image-effect-native.ts +129 -0
- package/src/image-effect.ts +336 -0
- package/src/image-owner.ts +12 -0
- package/src/index.ts +20 -1
- package/src/ios-plugin.d.ts +59 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +132 -0
- package/src/label.ts +76 -0
- package/src/liquid.ts +6 -1
- package/src/loader.ts +2 -1
- package/src/particles.ts +4 -4
- package/src/path.ts +124 -0
- package/src/playback-group.ts +34 -1
- package/src/press.ts +3 -5
- package/src/reveal.ts +3 -3
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +199 -14
- package/src/shimmer.ts +2 -1
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +4 -3
- package/src/text-layout.ts +14 -2
- package/src/text.ts +62 -81
- package/src/track.ts +89 -24
- package/src/types.ts +8 -1
- package/src/vue/index.ts +189 -0
- package/src/zoom-native.ts +124 -0
- package/src/zoomable.ts +330 -0
package/src/playback-group.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
arrivalOf,
|
|
3
|
+
arrivalTimingOf,
|
|
4
|
+
setArrival,
|
|
5
|
+
setArrivalTiming,
|
|
6
|
+
type ViewAnimation,
|
|
7
|
+
} from './engine.js'
|
|
2
8
|
import { getDriver } from './driver/index.js'
|
|
3
9
|
import { createClock, type PlaybackClock } from './playback.js'
|
|
4
10
|
import { validateProgress, type PlaybackAnimation } from './playback.js'
|
|
@@ -104,6 +110,33 @@ export function playbackGroup(children: readonly ViewAnimation[]): PlaybackAnima
|
|
|
104
110
|
then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
|
|
105
111
|
}
|
|
106
112
|
setArrival(animation, Promise.all(children.map(arrivalOf)))
|
|
113
|
+
const listeners = new Set<() => void>()
|
|
114
|
+
const timings = children.map((child) => arrivalTimingOf(child)!)
|
|
115
|
+
const notify = () => {
|
|
116
|
+
for (const callback of [...listeners]) callback()
|
|
117
|
+
}
|
|
118
|
+
const unsubscribe = timings.map((timing) => timing.subscribe(notify))
|
|
119
|
+
setArrivalTiming(animation, {
|
|
120
|
+
read() {
|
|
121
|
+
const states = timings.map((timing) => timing.read())
|
|
122
|
+
return {
|
|
123
|
+
arrived: settled || states.every((state) => state.arrived),
|
|
124
|
+
paused: paused || states.some((state) => state.paused && !state.arrived),
|
|
125
|
+
remaining: states.some((state) => !state.arrived && state.remaining === undefined)
|
|
126
|
+
? undefined
|
|
127
|
+
: Math.max(0, ...states.map((state) => (state.arrived ? 0 : state.remaining!))),
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
subscribe(callback) {
|
|
131
|
+
listeners.add(callback)
|
|
132
|
+
return () => listeners.delete(callback)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
void finished.then(() => {
|
|
136
|
+
unsubscribe.forEach((off) => off())
|
|
137
|
+
notify()
|
|
138
|
+
listeners.clear()
|
|
139
|
+
})
|
|
107
140
|
ensureClock()
|
|
108
141
|
finishIfIdle()
|
|
109
142
|
return animation
|
package/src/press.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
|
|
2
2
|
import { isColorProperty } from './color.js'
|
|
3
|
-
import { animate, toView } from './animate.js'
|
|
4
|
-
import {
|
|
3
|
+
import { animate, startingValues, toView } from './animate.js'
|
|
4
|
+
import { readValue } from './properties.js'
|
|
5
5
|
import type { SpringName, Transition } from './transition.js'
|
|
6
6
|
import type { AnimateOptions, Keyframes, Target } from './types.js'
|
|
7
7
|
|
|
@@ -23,9 +23,7 @@ export function press(
|
|
|
23
23
|
options?: AnimateOptions | SpringName,
|
|
24
24
|
): () => void {
|
|
25
25
|
const view = toView(target)
|
|
26
|
-
const properties =
|
|
27
|
-
key === 'scale' ? ['scaleX', 'scaleY'] : isProperty(key) ? [key] : [],
|
|
28
|
-
)
|
|
26
|
+
const properties = [...startingValues(pressed, options).keys()]
|
|
29
27
|
let rest: Keyframes | undefined
|
|
30
28
|
|
|
31
29
|
const onTouch = (event: EventData) => {
|
package/src/reveal.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { toView } from './animate.js'
|
|
|
3
3
|
import { resolveEasing } from './easing.js'
|
|
4
4
|
import type { Animation } from './engine.js'
|
|
5
5
|
import type { Target } from './types.js'
|
|
6
|
+
import { reducedMotion } from './config.js'
|
|
6
7
|
|
|
7
8
|
export interface RevealOptions {
|
|
8
9
|
/** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
|
|
@@ -243,14 +244,13 @@ class CircularAnimation implements Animation {
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
private motionChanged = () => {
|
|
246
|
-
if (
|
|
247
|
+
if (reducedMotion()) this.end(true)
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
private update() {
|
|
250
251
|
if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
|
|
251
252
|
return this.end(true, true)
|
|
252
|
-
if (this.duration === 0 ||
|
|
253
|
-
return this.end(true)
|
|
253
|
+
if (this.duration === 0 || reducedMotion()) return this.end(true)
|
|
254
254
|
if (this.waitingForLoad || !this.view.isLoaded) return
|
|
255
255
|
const native = this.view.nativeViewProtected as MaskedView | undefined
|
|
256
256
|
if (this.shape) {
|
package/src/roll-diff.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface Grapheme {
|
|
2
|
+
text: string
|
|
3
|
+
/** UTF-16 offsets, the units NSString locations and Java char indices use. */
|
|
4
|
+
start: number
|
|
5
|
+
end: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RollChange {
|
|
9
|
+
/** The grapheme that rolls in, placed in the new text. */
|
|
10
|
+
next: Grapheme
|
|
11
|
+
/** The grapheme it replaces at the same position from the end, when there is one. */
|
|
12
|
+
previous: Grapheme | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RollDiff {
|
|
16
|
+
/** Positions that differ, from the last one backwards. */
|
|
17
|
+
changes: RollChange[]
|
|
18
|
+
/** Old graphemes to the left of the new text's first one; they roll out alone. */
|
|
19
|
+
removed: Grapheme[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const segmenter =
|
|
23
|
+
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
24
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
25
|
+
: undefined
|
|
26
|
+
|
|
27
|
+
export function graphemes(text: string): Grapheme[] {
|
|
28
|
+
if (segmenter)
|
|
29
|
+
return [...segmenter.segment(text)].map(({ segment, index }) => ({
|
|
30
|
+
text: segment,
|
|
31
|
+
start: index,
|
|
32
|
+
end: index + segment.length,
|
|
33
|
+
}))
|
|
34
|
+
const pieces: Grapheme[] = []
|
|
35
|
+
let offset = 0
|
|
36
|
+
for (const character of text) {
|
|
37
|
+
pieces.push({ text: character, start: offset, end: offset + character.length })
|
|
38
|
+
offset += character.length
|
|
39
|
+
}
|
|
40
|
+
return pieces
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Aligns both texts on their last grapheme, so the units digit stays put when the count grows. */
|
|
44
|
+
export function diffFromEnd(previous: string, next: string): RollDiff {
|
|
45
|
+
const before = graphemes(previous)
|
|
46
|
+
const after = graphemes(next)
|
|
47
|
+
const changes: RollChange[] = []
|
|
48
|
+
const removed: Grapheme[] = []
|
|
49
|
+
const count = Math.max(before.length, after.length)
|
|
50
|
+
for (let offset = 1; offset <= count; offset++) {
|
|
51
|
+
const old = before[before.length - offset]
|
|
52
|
+
const piece = after[after.length - offset]
|
|
53
|
+
if (!piece) removed.push(old!)
|
|
54
|
+
else if (!old || old.text !== piece.text) changes.push({ next: piece, previous: old })
|
|
55
|
+
}
|
|
56
|
+
return { changes, removed }
|
|
57
|
+
}
|
package/src/roll.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import type { Animation } from './engine.js'
|
|
4
|
+
import { reducedMotion } from './config.js'
|
|
5
|
+
import { completion, forgetCompletion, watchLabel } from './label.js'
|
|
6
|
+
import { diffFromEnd, type Grapheme } from './roll-diff.js'
|
|
7
|
+
import { textLayer, type TextPiece } from './text-layout.js'
|
|
8
|
+
import { createTrack } from './track.js'
|
|
9
|
+
import { resolveTransition } from './transition.js'
|
|
10
|
+
import type { Target } from './types.js'
|
|
11
|
+
|
|
12
|
+
export interface RollOptions {
|
|
13
|
+
/** Turns a number into the text to show. Defaults to `String`. */
|
|
14
|
+
format?: (value: number) => string
|
|
15
|
+
/** How long the spring feels, in milliseconds. Defaults to 600. */
|
|
16
|
+
duration?: number
|
|
17
|
+
/** Spring bounce, from -1 to 1. Defaults to 0.2. */
|
|
18
|
+
bounce?: number
|
|
19
|
+
/** Milliseconds between changed positions, counted from the right. Defaults to zero. */
|
|
20
|
+
stagger?: number
|
|
21
|
+
/** Milliseconds before the first position moves. Defaults to zero. */
|
|
22
|
+
delay?: number
|
|
23
|
+
/** `auto` rolls up to a higher number and down to a lower one. Defaults to `auto`. */
|
|
24
|
+
direction?: 'auto' | 'up' | 'down'
|
|
25
|
+
/** Tabular figures on the native label, so every digit takes the same width. Defaults to true. */
|
|
26
|
+
tabular?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Options {
|
|
30
|
+
duration: number
|
|
31
|
+
bounce: number
|
|
32
|
+
stagger: number
|
|
33
|
+
delay: number
|
|
34
|
+
direction: 1 | -1
|
|
35
|
+
tabular: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const running = new WeakMap<Label, RollAnimation>()
|
|
39
|
+
const lastValues = new WeakMap<Label, number>()
|
|
40
|
+
const MAX_POSITIONS = 256
|
|
41
|
+
const directions = ['auto', 'up', 'down']
|
|
42
|
+
const paintKeys = () => [
|
|
43
|
+
NSForegroundColorAttributeName,
|
|
44
|
+
NSStrokeColorAttributeName,
|
|
45
|
+
NSUnderlineColorAttributeName,
|
|
46
|
+
NSStrikethroughColorAttributeName,
|
|
47
|
+
NSShadowAttributeName,
|
|
48
|
+
]
|
|
49
|
+
// CoreText layout features: kNumberSpacingType and kMonospacedNumbersSelector.
|
|
50
|
+
const NUMBER_SPACING = 6
|
|
51
|
+
const MONOSPACED_NUMBERS = 0
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Changes a Label's text and rolls the digits that differ, each one sliding
|
|
55
|
+
* out while its replacement slides in. The label holds the final text from the
|
|
56
|
+
* start; only the drawing moves.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* rollTo(balance, 1249.9, { format: (n) => n.toFixed(2) })
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function rollTo(
|
|
63
|
+
target: Target,
|
|
64
|
+
value: number | string,
|
|
65
|
+
options: RollOptions = {},
|
|
66
|
+
): Animation {
|
|
67
|
+
const config = resolveOptions(options)
|
|
68
|
+
const view = toView(target)
|
|
69
|
+
if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
|
|
70
|
+
const text = typeof value === 'string' ? value : (options.format ?? String)(value)
|
|
71
|
+
if (typeof text !== 'string') throw new TypeError('Roll format must return a string.')
|
|
72
|
+
running.get(view)?.stop()
|
|
73
|
+
const previous = currentText(view)
|
|
74
|
+
const direction = resolveDirection(options.direction, view, value, previous)
|
|
75
|
+
if (typeof value === 'number') lastValues.set(view, value)
|
|
76
|
+
else lastValues.delete(view)
|
|
77
|
+
if (view.formattedText) view.formattedText = undefined as never
|
|
78
|
+
view.text = text
|
|
79
|
+
return new RollAnimation(view, previous, text, { ...config, direction })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveOptions(options: RollOptions): Omit<Options, 'direction'> {
|
|
83
|
+
const duration = options.duration ?? 600
|
|
84
|
+
const bounce = options.bounce ?? 0.2
|
|
85
|
+
const stagger = options.stagger ?? 0
|
|
86
|
+
const delay = options.delay ?? 0
|
|
87
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
88
|
+
throw new RangeError('Roll duration must be a positive finite number.')
|
|
89
|
+
if (!Number.isFinite(bounce) || bounce < -1 || bounce > 1)
|
|
90
|
+
throw new RangeError('Roll bounce must be between -1 and 1.')
|
|
91
|
+
if (!Number.isFinite(stagger) || stagger < 0)
|
|
92
|
+
throw new RangeError('Roll stagger must be a nonnegative finite number.')
|
|
93
|
+
if (!Number.isFinite(delay) || delay < 0)
|
|
94
|
+
throw new RangeError('Roll delay must be a nonnegative finite number.')
|
|
95
|
+
if (options.direction !== undefined && !directions.includes(options.direction))
|
|
96
|
+
throw new TypeError('Roll direction must be auto, up or down.')
|
|
97
|
+
if (options.format !== undefined && typeof options.format !== 'function')
|
|
98
|
+
throw new TypeError('Roll format must be a function.')
|
|
99
|
+
return { duration, bounce, stagger, delay, tabular: options.tabular ?? true }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveDirection(
|
|
103
|
+
option: RollOptions['direction'],
|
|
104
|
+
view: Label,
|
|
105
|
+
value: number | string,
|
|
106
|
+
previous: string,
|
|
107
|
+
): 1 | -1 {
|
|
108
|
+
if (option === 'up') return 1
|
|
109
|
+
if (option === 'down') return -1
|
|
110
|
+
if (typeof value !== 'number') return 1
|
|
111
|
+
const before = lastValues.get(view) ?? Number(previous)
|
|
112
|
+
return Number.isFinite(before) && value < before ? -1 : 1
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function currentText(view: Label): string {
|
|
116
|
+
const native = view.nativeViewProtected as unknown
|
|
117
|
+
if (isIOS && native instanceof UILabel)
|
|
118
|
+
return native.attributedText?.string ?? native.text ?? ''
|
|
119
|
+
if (isAndroid && native instanceof android.widget.TextView)
|
|
120
|
+
return native.getText()?.toString() ?? ''
|
|
121
|
+
return view.text ?? ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function springTrack(shift: number, { duration, bounce }: Options) {
|
|
125
|
+
return createTrack(
|
|
126
|
+
'y',
|
|
127
|
+
[shift, 0],
|
|
128
|
+
resolveTransition({ type: 'spring', duration, bounce }, 'y', 2),
|
|
129
|
+
0,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fadeTrack({ duration }: Options) {
|
|
134
|
+
return createTrack(
|
|
135
|
+
'opacity',
|
|
136
|
+
[0, 1],
|
|
137
|
+
resolveTransition(
|
|
138
|
+
{ type: 'tween', duration: duration / 2, ease: 'easeOut' },
|
|
139
|
+
'opacity',
|
|
140
|
+
2,
|
|
141
|
+
),
|
|
142
|
+
0,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pieceAt(pieces: readonly TextPiece[], grapheme: Grapheme) {
|
|
147
|
+
const index = pieces.findIndex(
|
|
148
|
+
({ location, length }) =>
|
|
149
|
+
grapheme.start >= location && grapheme.start < location + length,
|
|
150
|
+
)
|
|
151
|
+
return index === -1 ? undefined : index
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function layersOf(layer: CALayer | undefined): CALayer[] {
|
|
155
|
+
const sublayers = layer?.sublayers
|
|
156
|
+
if (!sublayers) return []
|
|
157
|
+
const layers: CALayer[] = []
|
|
158
|
+
for (let i = 0; i < sublayers.count; i++) layers.push(sublayers.objectAtIndex(i))
|
|
159
|
+
return layers
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isClear(text: NSAttributedString) {
|
|
163
|
+
if (!text.length) return true
|
|
164
|
+
const color = text.attributeAtIndexEffectiveRange(
|
|
165
|
+
NSForegroundColorAttributeName,
|
|
166
|
+
0,
|
|
167
|
+
null,
|
|
168
|
+
) as UIColor | null
|
|
169
|
+
return !color || color.isEqual(UIColor.clearColor)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function tabularFont(font: UIFont): UIFont {
|
|
173
|
+
const feature = NSDictionary.dictionaryWithObjectsForKeys(
|
|
174
|
+
[NUMBER_SPACING, MONOSPACED_NUMBERS],
|
|
175
|
+
[UIFontFeatureTypeIdentifierKey, UIFontFeatureSelectorIdentifierKey],
|
|
176
|
+
)
|
|
177
|
+
const descriptor = font.fontDescriptor.fontDescriptorByAddingAttributes(
|
|
178
|
+
NSDictionary.dictionaryWithObjectForKey(
|
|
179
|
+
[feature],
|
|
180
|
+
UIFontDescriptorFeatureSettingsAttribute,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
return UIFont.fontWithDescriptorSize(descriptor, font.pointSize)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class RollAnimation implements Animation {
|
|
187
|
+
readonly finished: Promise<boolean>
|
|
188
|
+
private resolve!: (finished: boolean) => void
|
|
189
|
+
private settled = false
|
|
190
|
+
private queued = false
|
|
191
|
+
private started = false
|
|
192
|
+
private unwatch: (() => void) | undefined
|
|
193
|
+
private native: UILabel | undefined
|
|
194
|
+
private container: CALayer | undefined
|
|
195
|
+
private pieces: CALayer[] = []
|
|
196
|
+
private attributed: NSAttributedString | null = null
|
|
197
|
+
private hidden: NSAttributedString | null = null
|
|
198
|
+
private color: UIColor | undefined
|
|
199
|
+
private shadow: UIColor | null = null
|
|
200
|
+
private delegate: NSObject | undefined
|
|
201
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
202
|
+
private roll: org.rafaelaugusto.jolt.NumberRoll | undefined
|
|
203
|
+
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly view: Label,
|
|
206
|
+
private readonly previous: string,
|
|
207
|
+
private readonly next: string,
|
|
208
|
+
private readonly options: Options,
|
|
209
|
+
) {
|
|
210
|
+
this.finished = new Promise((resolve) => {
|
|
211
|
+
this.resolve = resolve
|
|
212
|
+
})
|
|
213
|
+
running.set(view, this)
|
|
214
|
+
const { changes, removed } = diffFromEnd(previous, next)
|
|
215
|
+
if (!changes.length && !removed.length) {
|
|
216
|
+
this.end(true)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (!isIOS && !isAndroid) {
|
|
220
|
+
this.end(false)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (reducedMotion()) {
|
|
224
|
+
this.end(true)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
view.on('loaded', this.schedule)
|
|
228
|
+
view.on('unloaded', this.stop)
|
|
229
|
+
if (isIOS) view.on('layoutChanged', this.layoutChanged)
|
|
230
|
+
this.unwatch = watchLabel(view, this.changed)
|
|
231
|
+
this.schedule()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
then<A = boolean, B = never>(
|
|
235
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
236
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
237
|
+
): Promise<A | B> {
|
|
238
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
239
|
+
}
|
|
240
|
+
/** Cancels the roll. The label keeps its final text. */
|
|
241
|
+
stop = () => this.end(false)
|
|
242
|
+
private changed = () => this.end(false)
|
|
243
|
+
private motionChanged = () => {
|
|
244
|
+
if (reducedMotion()) this.end(true)
|
|
245
|
+
}
|
|
246
|
+
private layoutChanged = () => {
|
|
247
|
+
if (!this.container) return this.schedule()
|
|
248
|
+
const native = this.view.nativeViewProtected
|
|
249
|
+
if (native !== this.native || !this.native?.window) return this.stop()
|
|
250
|
+
this.container.frame = this.native.bounds
|
|
251
|
+
}
|
|
252
|
+
private schedule = () => {
|
|
253
|
+
if (this.settled || this.queued) return
|
|
254
|
+
this.queued = true
|
|
255
|
+
void Promise.resolve().then(() => {
|
|
256
|
+
this.queued = false
|
|
257
|
+
if (this.settled || this.started || !this.view.isLoaded) return
|
|
258
|
+
try {
|
|
259
|
+
this.start()
|
|
260
|
+
} catch {
|
|
261
|
+
this.end(false)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
private start() {
|
|
266
|
+
if (reducedMotion()) return this.end(true)
|
|
267
|
+
if (this.view.visibility !== 'visible') return this.end(false)
|
|
268
|
+
if (isIOS) this.startIOS()
|
|
269
|
+
else this.startAndroid()
|
|
270
|
+
}
|
|
271
|
+
private startIOS() {
|
|
272
|
+
const native = this.view.nativeViewProtected as UILabel | undefined
|
|
273
|
+
if (!(native instanceof UILabel) || !native.window) return
|
|
274
|
+
if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
|
|
275
|
+
if (this.options.tabular) native.font = tabularFont(native.font)
|
|
276
|
+
const attributed = native.attributedText
|
|
277
|
+
let before: ReturnType<typeof textLayer>
|
|
278
|
+
try {
|
|
279
|
+
if (attributed) {
|
|
280
|
+
const copy =
|
|
281
|
+
NSMutableAttributedString.alloc().initWithAttributedString(attributed)
|
|
282
|
+
copy.replaceCharactersInRangeWithString(
|
|
283
|
+
{ location: 0, length: copy.length },
|
|
284
|
+
this.previous,
|
|
285
|
+
)
|
|
286
|
+
native.attributedText = copy
|
|
287
|
+
} else native.text = this.previous
|
|
288
|
+
before = textLayer(native, 'letter')
|
|
289
|
+
} finally {
|
|
290
|
+
if (attributed) native.attributedText = attributed
|
|
291
|
+
else native.text = this.next
|
|
292
|
+
}
|
|
293
|
+
const after = textLayer(native, 'letter')
|
|
294
|
+
if (!before || !after) return this.end(false)
|
|
295
|
+
this.started = true
|
|
296
|
+
|
|
297
|
+
const { changes, removed } = diffFromEnd(this.previous, this.next)
|
|
298
|
+
const entering = new Map<number, number>()
|
|
299
|
+
const exiting = new Map<number, number>()
|
|
300
|
+
const assign = (
|
|
301
|
+
map: Map<number, number>,
|
|
302
|
+
pieces: readonly TextPiece[],
|
|
303
|
+
grapheme: Grapheme,
|
|
304
|
+
step: number,
|
|
305
|
+
) => {
|
|
306
|
+
const index = pieceAt(pieces, grapheme)
|
|
307
|
+
if (index !== undefined && !map.has(index)) map.set(index, step)
|
|
308
|
+
}
|
|
309
|
+
changes.forEach(({ next, previous }, step) => {
|
|
310
|
+
assign(entering, after.pieces, next, step)
|
|
311
|
+
if (previous) assign(exiting, before.pieces, previous, step)
|
|
312
|
+
})
|
|
313
|
+
removed.forEach((grapheme, index) =>
|
|
314
|
+
assign(exiting, before.pieces, grapheme, changes.length + index),
|
|
315
|
+
)
|
|
316
|
+
if (!entering.size && !exiting.size) return this.end(true)
|
|
317
|
+
if (entering.size + exiting.size > MAX_POSITIONS) return this.end(false)
|
|
318
|
+
const last = Math.max(...entering.values(), ...exiting.values())
|
|
319
|
+
|
|
320
|
+
const { delay, stagger, direction } = this.options
|
|
321
|
+
const shift = native.font.lineHeight
|
|
322
|
+
const spring = springTrack(shift, this.options)
|
|
323
|
+
const fade = fadeTrack(this.options)
|
|
324
|
+
const container = CALayer.new()
|
|
325
|
+
container.name = 'jolt:roll'
|
|
326
|
+
container.frame = native.bounds
|
|
327
|
+
container.masksToBounds = true
|
|
328
|
+
const oldPieces = layersOf(before.layer)
|
|
329
|
+
const newPieces = layersOf(after.layer)
|
|
330
|
+
const start = CACurrentMediaTime()
|
|
331
|
+
this.native = native
|
|
332
|
+
this.container = container
|
|
333
|
+
CATransaction.begin()
|
|
334
|
+
CATransaction.setDisableActions(true)
|
|
335
|
+
try {
|
|
336
|
+
this.hide(native)
|
|
337
|
+
oldPieces.forEach((piece, index) => {
|
|
338
|
+
if (!exiting.has(index)) piece.removeFromSuperlayer()
|
|
339
|
+
})
|
|
340
|
+
if (before.layer) container.addSublayer(before.layer)
|
|
341
|
+
if (after.layer) container.addSublayer(after.layer)
|
|
342
|
+
native.layer.addSublayer(container)
|
|
343
|
+
const roll = (piece: CALayer, step: number, incoming: boolean) => {
|
|
344
|
+
if (!incoming) {
|
|
345
|
+
piece.opacity = 0
|
|
346
|
+
piece.transform = CATransform3DMakeTranslation(0, -direction * shift, 0)
|
|
347
|
+
}
|
|
348
|
+
const move = CAKeyframeAnimation.animationWithKeyPath('transform.translation.y')
|
|
349
|
+
move.values = NSArray.arrayWithArray(
|
|
350
|
+
spring.values.map((value) => direction * (incoming ? value : value - shift)),
|
|
351
|
+
)
|
|
352
|
+
move.keyTimes = NSArray.arrayWithArray(spring.times as number[])
|
|
353
|
+
move.duration = spring.duration / 1000
|
|
354
|
+
const opacity = CAKeyframeAnimation.animationWithKeyPath('opacity')
|
|
355
|
+
opacity.values = NSArray.arrayWithArray(
|
|
356
|
+
fade.values.map((value) => (incoming ? value : 1 - value)),
|
|
357
|
+
)
|
|
358
|
+
opacity.keyTimes = NSArray.arrayWithArray(fade.times as number[])
|
|
359
|
+
opacity.duration = fade.duration / 1000
|
|
360
|
+
const group = CAAnimationGroup.new()
|
|
361
|
+
group.animations = NSArray.arrayWithArray([opacity, move])
|
|
362
|
+
group.duration = Math.max(spring.duration, fade.duration) / 1000
|
|
363
|
+
group.beginTime = start + (delay + step * stagger) / 1000
|
|
364
|
+
group.fillMode = kCAFillModeBackwards
|
|
365
|
+
if (step === last && !this.delegate)
|
|
366
|
+
this.delegate = group.delegate = completion((finished) => this.end(finished))
|
|
367
|
+
piece.addAnimationForKey(group, 'jolt:roll')
|
|
368
|
+
this.pieces.push(piece)
|
|
369
|
+
}
|
|
370
|
+
for (const [index, step] of exiting) roll(oldPieces[index]!, step, false)
|
|
371
|
+
for (const [index, step] of entering) roll(newPieces[index]!, step, true)
|
|
372
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
373
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
374
|
+
this.motionChanged,
|
|
375
|
+
)
|
|
376
|
+
} finally {
|
|
377
|
+
CATransaction.commit()
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
private hide(native: UILabel) {
|
|
381
|
+
this.color = native.textColor
|
|
382
|
+
// UILabel synthesizes an attributed string for plain text, so this is the usual path.
|
|
383
|
+
const attributed = native.attributedText
|
|
384
|
+
if (attributed) {
|
|
385
|
+
const hidden =
|
|
386
|
+
NSMutableAttributedString.alloc().initWithAttributedString(attributed)
|
|
387
|
+
const full = { location: 0, length: hidden.length }
|
|
388
|
+
const shadow = NSShadow.new()
|
|
389
|
+
shadow.shadowColor = UIColor.clearColor
|
|
390
|
+
for (const key of paintKeys())
|
|
391
|
+
hidden.addAttributeValueRange(
|
|
392
|
+
key,
|
|
393
|
+
key === NSShadowAttributeName ? shadow : UIColor.clearColor,
|
|
394
|
+
full,
|
|
395
|
+
)
|
|
396
|
+
this.attributed = attributed
|
|
397
|
+
this.hidden = hidden
|
|
398
|
+
native.attributedText = hidden
|
|
399
|
+
} else native.textColor = UIColor.clearColor
|
|
400
|
+
this.shadow = native.shadowColor
|
|
401
|
+
native.shadowColor = null
|
|
402
|
+
}
|
|
403
|
+
private show(native: UILabel) {
|
|
404
|
+
// UIKit hands back a normalized copy of the attributed text and keeps the
|
|
405
|
+
// clear color for text the app sets later, so "still ours" means the text
|
|
406
|
+
// is still clear. A color the app painted meanwhile wins.
|
|
407
|
+
const current = native.attributedText
|
|
408
|
+
if (!current || isClear(current)) {
|
|
409
|
+
if (current && this.attributed && current.string === this.hidden?.string) {
|
|
410
|
+
const restored =
|
|
411
|
+
NSMutableAttributedString.alloc().initWithAttributedString(current)
|
|
412
|
+
const full = { location: 0, length: restored.length }
|
|
413
|
+
for (const key of paintKeys()) {
|
|
414
|
+
restored.removeAttributeRange(key, full)
|
|
415
|
+
this.attributed.enumerateAttributeInRangeOptionsUsingBlock(
|
|
416
|
+
key,
|
|
417
|
+
full,
|
|
418
|
+
0 as NSAttributedStringEnumerationOptions,
|
|
419
|
+
(value, range) => {
|
|
420
|
+
if (value != null) restored.addAttributeValueRange(key, value, range)
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
native.attributedText = restored
|
|
425
|
+
}
|
|
426
|
+
if (this.color && native.textColor?.isEqual(UIColor.clearColor))
|
|
427
|
+
native.textColor = this.color
|
|
428
|
+
}
|
|
429
|
+
if (!native.shadowColor) native.shadowColor = this.shadow
|
|
430
|
+
}
|
|
431
|
+
private startAndroid() {
|
|
432
|
+
const native = this.view.nativeViewProtected as android.widget.TextView | undefined
|
|
433
|
+
if (!(native instanceof android.widget.TextView)) return this.end(false)
|
|
434
|
+
const { changes } = diffFromEnd(this.previous, this.next)
|
|
435
|
+
if (!changes.length) return this.end(true)
|
|
436
|
+
if (changes.length > MAX_POSITIONS) return this.end(false)
|
|
437
|
+
this.started = true
|
|
438
|
+
const density = Utils.layout.getDisplayDensity()
|
|
439
|
+
const shift = native.getLineHeight()
|
|
440
|
+
const spring = springTrack(shift / density, this.options)
|
|
441
|
+
const fade = fadeTrack(this.options)
|
|
442
|
+
const starts = Array.create('int', changes.length)
|
|
443
|
+
const ends = Array.create('int', changes.length)
|
|
444
|
+
const previous = Array.create(java.lang.String, changes.length)
|
|
445
|
+
changes.forEach(({ next, previous: old }, index) => {
|
|
446
|
+
starts[index] = next.start
|
|
447
|
+
ends[index] = next.end
|
|
448
|
+
previous[index] = old?.text ?? ''
|
|
449
|
+
})
|
|
450
|
+
const times = Array.create('float', spring.times.length)
|
|
451
|
+
const values = Array.create('float', spring.values.length)
|
|
452
|
+
spring.times.forEach((time, index) => (times[index] = time))
|
|
453
|
+
spring.values.forEach((value, index) => (values[index] = value * density))
|
|
454
|
+
const { delay, stagger, direction, tabular } = this.options
|
|
455
|
+
let roll: org.rafaelaugusto.jolt.NumberRoll
|
|
456
|
+
try {
|
|
457
|
+
roll = new org.rafaelaugusto.jolt.NumberRoll(
|
|
458
|
+
native,
|
|
459
|
+
starts,
|
|
460
|
+
ends,
|
|
461
|
+
previous,
|
|
462
|
+
direction,
|
|
463
|
+
times,
|
|
464
|
+
values,
|
|
465
|
+
spring.duration,
|
|
466
|
+
Math.round(delay),
|
|
467
|
+
Math.round(stagger),
|
|
468
|
+
shift,
|
|
469
|
+
fade.duration,
|
|
470
|
+
tabular,
|
|
471
|
+
new org.rafaelaugusto.jolt.NumberRoll.Callback({
|
|
472
|
+
onEnd: (finished) => this.end(finished),
|
|
473
|
+
}),
|
|
474
|
+
)
|
|
475
|
+
} catch {
|
|
476
|
+
// A JS-only update cannot install the native plugin. The label shows the final text.
|
|
477
|
+
return this.end(false)
|
|
478
|
+
}
|
|
479
|
+
if (!roll.isSupported()) return this.end(false)
|
|
480
|
+
this.roll = roll
|
|
481
|
+
roll.start()
|
|
482
|
+
}
|
|
483
|
+
private end(finished: boolean) {
|
|
484
|
+
if (this.settled) return
|
|
485
|
+
this.settled = true
|
|
486
|
+
const { view, native, container } = this
|
|
487
|
+
view.off('loaded', this.schedule)
|
|
488
|
+
view.off('unloaded', this.stop)
|
|
489
|
+
view.off('layoutChanged', this.layoutChanged)
|
|
490
|
+
this.unwatch?.()
|
|
491
|
+
if (running.get(view) === this) running.delete(view)
|
|
492
|
+
if (this.observer)
|
|
493
|
+
Application.ios.removeNotificationObserver(
|
|
494
|
+
this.observer,
|
|
495
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
496
|
+
)
|
|
497
|
+
if (this.delegate) forgetCompletion(this.delegate)
|
|
498
|
+
if (native && container) {
|
|
499
|
+
CATransaction.begin()
|
|
500
|
+
CATransaction.setDisableActions(true)
|
|
501
|
+
try {
|
|
502
|
+
this.show(native)
|
|
503
|
+
container.removeFromSuperlayer()
|
|
504
|
+
for (const piece of this.pieces) piece.removeAnimationForKey('jolt:roll')
|
|
505
|
+
} finally {
|
|
506
|
+
CATransaction.commit()
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
this.roll?.cancel()
|
|
510
|
+
this.pieces = []
|
|
511
|
+
this.native = this.container = this.delegate = this.observer = this.roll = undefined
|
|
512
|
+
this.attributed = this.hidden = null
|
|
513
|
+
this.resolve(finished)
|
|
514
|
+
}
|
|
515
|
+
}
|