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

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 (201) hide show
  1. package/README.md +134 -4
  2. package/dist/animate.d.ts +7 -4
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +58 -13
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +3 -0
  15. package/dist/driver/android-color.d.ts.map +1 -1
  16. package/dist/driver/android-color.js +20 -18
  17. package/dist/driver/android-color.js.map +1 -1
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +30 -36
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +9 -0
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +2 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -1
  34. package/dist/driver/ios-color.js +19 -8
  35. package/dist/driver/ios-color.js.map +1 -1
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +47 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +76 -40
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +64 -3
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +243 -19
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +12 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +5 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/items.d.ts +20 -0
  64. package/dist/items.d.ts.map +1 -0
  65. package/dist/items.js +109 -0
  66. package/dist/items.js.map +1 -0
  67. package/dist/keyboard.d.ts +17 -0
  68. package/dist/keyboard.d.ts.map +1 -0
  69. package/dist/keyboard.js +116 -0
  70. package/dist/keyboard.js.map +1 -0
  71. package/dist/label.d.ts +7 -0
  72. package/dist/label.d.ts.map +1 -0
  73. package/dist/label.js +60 -0
  74. package/dist/label.js.map +1 -0
  75. package/dist/liquid.d.ts.map +1 -1
  76. package/dist/liquid.js +2 -1
  77. package/dist/liquid.js.map +1 -1
  78. package/dist/loader.d.ts.map +1 -1
  79. package/dist/loader.js +2 -1
  80. package/dist/loader.js.map +1 -1
  81. package/dist/particles.d.ts.map +1 -1
  82. package/dist/particles.js +4 -3
  83. package/dist/particles.js.map +1 -1
  84. package/dist/path.d.ts +12 -0
  85. package/dist/path.d.ts.map +1 -0
  86. package/dist/path.js +90 -0
  87. package/dist/path.js.map +1 -0
  88. package/dist/playback-group.d.ts +6 -0
  89. package/dist/playback-group.d.ts.map +1 -0
  90. package/dist/playback-group.js +141 -0
  91. package/dist/playback-group.js.map +1 -0
  92. package/dist/playback.d.ts +25 -0
  93. package/dist/playback.d.ts.map +1 -0
  94. package/dist/playback.js +35 -0
  95. package/dist/playback.js.map +1 -0
  96. package/dist/press.d.ts.map +1 -1
  97. package/dist/press.js +3 -3
  98. package/dist/press.js.map +1 -1
  99. package/dist/properties.d.ts +2 -1
  100. package/dist/properties.d.ts.map +1 -1
  101. package/dist/properties.js +14 -1
  102. package/dist/properties.js.map +1 -1
  103. package/dist/reveal.d.ts.map +1 -1
  104. package/dist/reveal.js +3 -2
  105. package/dist/reveal.js.map +1 -1
  106. package/dist/roll-diff.d.ts +22 -0
  107. package/dist/roll-diff.d.ts.map +1 -0
  108. package/dist/roll-diff.js +36 -0
  109. package/dist/roll-diff.js.map +1 -0
  110. package/dist/roll.d.ts +29 -0
  111. package/dist/roll.d.ts.map +1 -0
  112. package/dist/roll.js +442 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/scroll-linked.d.ts +10 -0
  115. package/dist/scroll-linked.d.ts.map +1 -0
  116. package/dist/scroll-linked.js +58 -0
  117. package/dist/scroll-linked.js.map +1 -0
  118. package/dist/sequence.d.ts +10 -5
  119. package/dist/sequence.d.ts.map +1 -1
  120. package/dist/sequence.js +208 -14
  121. package/dist/sequence.js.map +1 -1
  122. package/dist/shimmer.d.ts.map +1 -1
  123. package/dist/shimmer.js +2 -1
  124. package/dist/shimmer.js.map +1 -1
  125. package/dist/svg-path.d.ts +14 -0
  126. package/dist/svg-path.d.ts.map +1 -0
  127. package/dist/svg-path.js +281 -0
  128. package/dist/svg-path.js.map +1 -0
  129. package/dist/symbol-effect.d.ts.map +1 -1
  130. package/dist/symbol-effect.js +4 -3
  131. package/dist/symbol-effect.js.map +1 -1
  132. package/dist/text-layout.d.ts +8 -0
  133. package/dist/text-layout.d.ts.map +1 -1
  134. package/dist/text-layout.js +8 -2
  135. package/dist/text-layout.js.map +1 -1
  136. package/dist/text.d.ts +1 -1
  137. package/dist/text.d.ts.map +1 -1
  138. package/dist/text.js +60 -64
  139. package/dist/text.js.map +1 -1
  140. package/dist/track.d.ts +2 -0
  141. package/dist/track.d.ts.map +1 -1
  142. package/dist/track.js +72 -16
  143. package/dist/track.js.map +1 -1
  144. package/dist/transition.d.ts.map +1 -1
  145. package/dist/transition.js +4 -1
  146. package/dist/transition.js.map +1 -1
  147. package/dist/types.d.ts +22 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/dist/vue/index.d.ts +89 -0
  150. package/dist/vue/index.d.ts.map +1 -0
  151. package/dist/vue/index.js +134 -0
  152. package/dist/vue/index.js.map +1 -0
  153. package/package.json +15 -3
  154. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +2 -2
  155. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  156. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  157. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  158. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  159. package/platforms/android/proguard-rules.pro +7 -0
  160. package/src/android-plugin.d.ts +64 -0
  161. package/src/animate.ts +96 -33
  162. package/src/config.ts +46 -0
  163. package/src/decoration.ts +180 -0
  164. package/src/driver/android-color.ts +22 -20
  165. package/src/driver/android-decoration.ts +182 -0
  166. package/src/driver/android-playback.ts +157 -0
  167. package/src/driver/android.ts +57 -43
  168. package/src/driver/index.ts +15 -0
  169. package/src/driver/ios-color.ts +19 -10
  170. package/src/driver/ios-decoration.ts +332 -0
  171. package/src/driver/ios-playback.ts +51 -0
  172. package/src/driver/ios-shadow.ts +50 -0
  173. package/src/driver/ios.ts +87 -46
  174. package/src/engine.ts +267 -22
  175. package/src/haptics.ts +25 -2
  176. package/src/index.ts +12 -1
  177. package/src/items.ts +140 -0
  178. package/src/keyboard.ts +132 -0
  179. package/src/label.ts +76 -0
  180. package/src/liquid.ts +2 -1
  181. package/src/loader.ts +2 -1
  182. package/src/particles.ts +4 -4
  183. package/src/path.ts +124 -0
  184. package/src/playback-group.ts +143 -0
  185. package/src/playback.ts +62 -0
  186. package/src/press.ts +3 -5
  187. package/src/properties.ts +18 -2
  188. package/src/reveal.ts +3 -3
  189. package/src/roll-diff.ts +57 -0
  190. package/src/roll.ts +515 -0
  191. package/src/scroll-linked.ts +73 -0
  192. package/src/sequence.ts +204 -14
  193. package/src/shimmer.ts +2 -1
  194. package/src/svg-path.ts +355 -0
  195. package/src/symbol-effect.ts +4 -3
  196. package/src/text-layout.ts +14 -2
  197. package/src/text.ts +62 -81
  198. package/src/track.ts +98 -25
  199. package/src/transition.ts +4 -1
  200. package/src/types.ts +23 -1
  201. package/src/vue/index.ts +189 -0
@@ -1,6 +1,12 @@
1
1
  /** TextKit lays out the complete string before any glyphs are separated. */
2
2
  type Insets = { top: number; left: number; bottom: number; right: number }
3
3
  type NativeLabel = UILabel & { padding?: Insets; borderThickness?: Insets }
4
+ /** The characters one piece draws, as UTF-16 offsets into the label text. */
5
+ export interface TextPiece {
6
+ text: string
7
+ location: number
8
+ length: number
9
+ }
4
10
  const MAX_PIECES = 256
5
11
  const MAX_PIXELS = 4_000_000
6
12
 
@@ -30,7 +36,7 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
30
36
  if (native.adjustsFontSizeToFitWidth) return undefined
31
37
  const original = native.attributedText
32
38
  const text = original?.string ?? native.text ?? ''
33
- if (!text.trim()) return { text, layer: undefined }
39
+ if (!text.trim()) return { text, layer: undefined, pieces: [] as TextPiece[] }
34
40
  const string = NSString.stringWithString(text)
35
41
  const full = { location: 0, length: string.length }
36
42
  const attributed = original
@@ -169,11 +175,17 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
169
175
  )
170
176
  if (attachments) return undefined
171
177
  const shadowColor = native.shadowColor
178
+ const drawn: TextPiece[] = []
172
179
  try {
173
180
  native.shadowColor = null
174
181
  for (const { range, rect } of pieces) {
175
182
  const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
176
183
  const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
184
+ drawn.push({
185
+ text: string.substringWithRange(characters),
186
+ location: characters.location,
187
+ length: characters.length,
188
+ })
177
189
  attributed.enumerateAttributesInRangeOptionsUsingBlock(
178
190
  characters,
179
191
  0 as NSAttributedStringEnumerationOptions,
@@ -212,5 +224,5 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
212
224
  else native.text = text
213
225
  native.shadowColor = shadowColor
214
226
  }
215
- return { text, layer }
227
+ return { text, layer, pieces: drawn }
216
228
  }
package/src/text.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { stopIOSColorText } from './driver/ios-color.js'
2
- import { Application, Label, isIOS } from '@nativescript/core'
2
+ import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
3
3
  import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
4
4
  import { toView } from './animate.js'
5
+ import { reducedMotion, timeScale } from './config.js'
5
6
  import type { Animation } from './engine.js'
7
+ import { completion, forgetCompletion, watchLabel } from './label.js'
6
8
  import { textLayer } from './text-layout.js'
7
9
  import type { Target } from './types.js'
8
10
 
@@ -20,34 +22,8 @@ export interface TextOptions {
20
22
  }
21
23
  type Options = Required<TextOptions>
22
24
  const running = new WeakMap<Label, TextAnimation>()
23
- const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
24
- let Delegate: typeof NSObject | undefined
25
- const styleEvents = [
26
- 'fontInternalChange',
27
- 'colorChange',
28
- 'textAlignmentChange',
29
- 'textDecorationChange',
30
- 'textTransformChange',
31
- 'letterSpacingChange',
32
- 'lineHeightChange',
33
- 'textShadowChange',
34
- 'textStrokeChange',
35
- 'whiteSpaceChange',
36
- 'textOverflowChange',
37
- 'maxLinesChange',
38
- 'paddingLeftChange',
39
- 'paddingRightChange',
40
- 'paddingTopChange',
41
- 'paddingBottomChange',
42
- 'visibilityChange',
43
- 'directionChange',
44
- 'borderLeftWidthChange',
45
- 'borderRightWidthChange',
46
- 'borderTopWidthChange',
47
- 'borderBottomWidthChange',
48
- ]
49
25
 
50
- /** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
26
+ /** Reveals the text of a Label without splitting its layout or accessibility element. */
51
27
  export function animateText(target: Target, options: TextOptions = {}): Animation {
52
28
  const config: Options = {
53
29
  by: options.by ?? 'letter',
@@ -69,41 +45,14 @@ export function animateText(target: Target, options: TextOptions = {}): Animatio
69
45
  return new TextAnimation(view, config)
70
46
  }
71
47
 
72
- function completion(
73
- callback: (finished: boolean) => void,
74
- ): NSObject & CAAnimationDelegate {
75
- if (!Delegate) {
76
- const base = NSObject as typeof NSObject & {
77
- extend(
78
- methods: object,
79
- options: { protocols: unknown[]; name: string },
80
- ): typeof NSObject
81
- }
82
- Delegate = base.extend(
83
- {
84
- animationDidStopFinished(
85
- this: NSObject,
86
- _animation: CAAnimation,
87
- finished: boolean,
88
- ) {
89
- callbacks.get(this)?.(finished)
90
- },
91
- },
92
- { protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
93
- )
94
- }
95
- const delegate = Delegate.new()
96
- callbacks.set(delegate, callback)
97
- return delegate
98
- }
99
-
100
48
  class TextAnimation implements Animation {
101
49
  readonly finished: Promise<boolean>
102
50
  private resolve!: (finished: boolean) => void
103
- private readonly formatted: Label['formattedText']
104
51
  private settled = false
105
52
  private queued = false
106
- private native: UILabel | undefined
53
+ private native: UILabel | android.widget.TextView | undefined
54
+ private reveal: org.rafaelaugusto.jolt.TextReveal | undefined
55
+ private unwatch: (() => void) | undefined
107
56
  private bounds: CGRect | undefined
108
57
  private layer: CALayer | undefined
109
58
  private originalText: string | null = null
@@ -121,23 +70,19 @@ class TextAnimation implements Animation {
121
70
  this.resolve = resolve
122
71
  })
123
72
  if (isIOS) stopIOSColorText(view)
124
- this.formatted = view.formattedText
125
73
  running.set(view, this)
126
- if (!isIOS) {
74
+ if (!isIOS && !isAndroid) {
127
75
  this.end(false)
128
76
  return
129
77
  }
130
- if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
78
+ if (options.duration === 0 || reducedMotion()) {
131
79
  this.end(true)
132
80
  return
133
81
  }
134
82
  view.on('loaded', this.schedule)
135
83
  view.on('layoutChanged', this.layoutChanged)
136
84
  view.on('unloaded', this.stop)
137
- view.on('textChange', this.changed)
138
- view.on('formattedTextChange', this.changed)
139
- this.formatted?.on('propertyChange', this.changed)
140
- for (const event of styleEvents) view.style.on(event, this.changed)
85
+ this.unwatch = watchLabel(view, this.changed)
141
86
  this.schedule()
142
87
  }
143
88
 
@@ -151,9 +96,14 @@ class TextAnimation implements Animation {
151
96
  stop = () => this.end(false)
152
97
  private changed = () => this.end(false, true)
153
98
  private motionChanged = () => {
154
- if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
99
+ if (reducedMotion()) this.end(true)
155
100
  }
156
101
  private layoutChanged = () => {
102
+ if (isAndroid) {
103
+ if (!this.reveal) this.schedule()
104
+ else if (this.view.nativeViewProtected !== this.native) this.stop()
105
+ return
106
+ }
157
107
  const native = this.view.nativeViewProtected as UILabel | undefined
158
108
  const a = this.bounds
159
109
  const b = native?.bounds
@@ -176,7 +126,7 @@ class TextAnimation implements Animation {
176
126
  this.queued = true
177
127
  void Promise.resolve().then(() => {
178
128
  this.queued = false
179
- if (this.settled || this.layer || !this.view.isLoaded) return
129
+ if (this.settled || this.layer || this.reveal || !this.view.isLoaded) return
180
130
  try {
181
131
  this.start()
182
132
  } catch {
@@ -185,21 +135,21 @@ class TextAnimation implements Animation {
185
135
  })
186
136
  }
187
137
  private start() {
188
- if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
189
- return this.end(true)
138
+ if (this.options.duration === 0 || reducedMotion()) return this.end(true)
190
139
  if (this.view.visibility !== 'visible') return this.end(false)
140
+ if (isAndroid) return this.startAndroid()
191
141
  const native = this.view.nativeViewProtected as UILabel | undefined
192
142
  if (!(native instanceof UILabel) || !native.window) return
193
143
  if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
194
144
  const prepared = textLayer(native, this.options.by)
195
145
  if (!prepared) return this.end(false)
196
146
  if (!prepared.layer?.sublayers?.count) return this.end(true)
147
+ const slowdown = 1 / timeScale()
148
+ const duration = this.options.duration * slowdown
149
+ const stagger = this.options.stagger * slowdown
150
+ const delay = this.options.delay * slowdown
197
151
  if (
198
- !Number.isFinite(
199
- this.options.delay +
200
- this.options.duration +
201
- (prepared.layer.sublayers.count - 1) * this.options.stagger,
202
- )
152
+ !Number.isFinite(delay + duration + (prepared.layer.sublayers.count - 1) * stagger)
203
153
  )
204
154
  return this.end(false)
205
155
  this.native = native
@@ -209,7 +159,7 @@ class TextAnimation implements Animation {
209
159
  this.originalAttributed = native.attributedText
210
160
  this.accessibility = native.accessibilityLabel
211
161
  this.temporaryAccessibility = this.accessibility ?? prepared.text
212
- const { duration, delay, stagger, y } = this.options
162
+ const { y } = this.options
213
163
  const start = CACurrentMediaTime()
214
164
  CATransaction.begin()
215
165
  CATransaction.setDisableActions(true)
@@ -247,24 +197,55 @@ class TextAnimation implements Animation {
247
197
  CATransaction.commit()
248
198
  }
249
199
  }
200
+ private startAndroid() {
201
+ const native = this.view.nativeViewProtected as android.widget.TextView | undefined
202
+ if (!(native instanceof android.widget.TextView)) return this.end(false)
203
+ const { by, duration, stagger, delay, y } = this.options
204
+ let reveal: org.rafaelaugusto.jolt.TextReveal
205
+ try {
206
+ reveal = new org.rafaelaugusto.jolt.TextReveal(
207
+ native,
208
+ by === 'word',
209
+ Math.round(duration),
210
+ Math.round(stagger),
211
+ Math.round(delay),
212
+ y * Utils.layout.getDisplayDensity(),
213
+ new org.rafaelaugusto.jolt.TextReveal.Callback({
214
+ onEnd: (finished: boolean) => {
215
+ if (this.reveal !== reveal) return
216
+ this.reveal = undefined
217
+ this.end(finished)
218
+ },
219
+ }),
220
+ )
221
+ } catch {
222
+ // A JS-only update cannot install the native plugin. Keep the label as it is.
223
+ return this.end(false)
224
+ }
225
+ if (!reveal.isSupported()) return this.end(false)
226
+ this.native = native
227
+ this.reveal = reveal
228
+ reveal.start()
229
+ }
250
230
  private end(finished: boolean, refresh = false) {
251
231
  if (this.settled) return
252
232
  this.settled = true
253
- const { view, native, layer } = this
233
+ const { view, layer } = this
234
+ const native = this.native as UILabel | undefined
235
+ const reveal = this.reveal
236
+ this.reveal = undefined
237
+ reveal?.cancel()
254
238
  view.off('loaded', this.schedule)
255
239
  view.off('layoutChanged', this.layoutChanged)
256
240
  view.off('unloaded', this.stop)
257
- view.off('textChange', this.changed)
258
- view.off('formattedTextChange', this.changed)
259
- this.formatted?.off('propertyChange', this.changed)
260
- for (const event of styleEvents) view.style.off(event, this.changed)
241
+ this.unwatch?.()
261
242
  if (running.get(view) === this) running.delete(view)
262
243
  if (this.observer)
263
244
  Application.ios.removeNotificationObserver(
264
245
  this.observer,
265
246
  UIAccessibilityReduceMotionStatusDidChangeNotification,
266
247
  )
267
- if (this.delegate) callbacks.delete(this.delegate)
248
+ if (this.delegate) forgetCompletion(this.delegate)
268
249
  if (native && layer) {
269
250
  CATransaction.begin()
270
251
  CATransaction.setDisableActions(true)
package/src/track.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { clampDecoration, isDecorationProperty } from './decoration.js'
1
2
  import { linear } from './easing.js'
2
3
  import { solveDecay } from './decay.js'
3
4
  import { precision, type Property } from './properties.js'
4
5
  import { solveSpring, springPhysics } from './spring.js'
5
6
  import type { Curve, ResolvedTransition } from './transition.js'
6
7
  import { colorMixer, isColorProperty, type ColorProperty } from './color.js'
8
+ import { reducedMotion, timeScale } from './config.js'
7
9
 
8
10
  /**
9
11
  * One property animated over time. The curve is computed here and sampled,
@@ -49,13 +51,22 @@ export function createTrack(
49
51
  velocity: number,
50
52
  ): Track {
51
53
  if (isColorProperty(property)) return colorTrack(property, keyframes, transition)
52
- const { curve, delay, repeat, reverse } = transition
53
- const motion =
54
+ const { curve, repeat, reverse } = transition
55
+ if (property !== 'opacity' && reducedMotion())
56
+ return instantTrack(
57
+ property,
58
+ restingValue(property, keyframes, curve, repeat, reverse),
59
+ )
60
+ const slowdown = 1 / timeScale()
61
+ const delay = transition.delay * slowdown
62
+ const motion = slow(
54
63
  curve.type === 'spring'
55
- ? spring(property, keyframes, curve, velocity)
64
+ ? spring(property, keyframes, curve, velocity * slowdown, slowdown)
56
65
  : curve.type === 'decay'
57
- ? decay(property, keyframes[0]!, curve)
58
- : tween(keyframes, curve)
66
+ ? decay(property, keyframes[0]!, curve, slowdown)
67
+ : tween(keyframes, curve, slowdown),
68
+ slowdown,
69
+ )
59
70
  const { duration } = motion
60
71
  const first = motion.values[0]!
61
72
  const last = motion.values.at(-1)!
@@ -72,7 +83,7 @@ export function createTrack(
72
83
  return reverse && iteration % 2 === 1 ? [duration - local, -1] : [local, 1]
73
84
  }
74
85
 
75
- return {
86
+ const track: Track = {
76
87
  property,
77
88
  delay,
78
89
  duration,
@@ -94,6 +105,57 @@ export function createTrack(
94
105
  return motion.velocity(time) * direction
95
106
  },
96
107
  }
108
+ if (!isDecorationProperty(property)) return track
109
+ return {
110
+ ...track,
111
+ values: track.values.map((value) => clampDecoration(property, value)),
112
+ to: clampDecoration(property, track.to),
113
+ valueAt: (elapsed) => clampDecoration(property, track.valueAt(elapsed)),
114
+ }
115
+ }
116
+
117
+ /** Already at its end: the engine writes the value and plays nothing. */
118
+ export function instantTrack(property: Property, value: number): Track {
119
+ const to = isDecorationProperty(property) ? clampDecoration(property, value) : value
120
+ return {
121
+ property,
122
+ delay: 0,
123
+ duration: 0,
124
+ repeat: 0,
125
+ reverse: false,
126
+ times: [0, 1],
127
+ values: [to, to],
128
+ to,
129
+ end: 0,
130
+ arrival: 0,
131
+ valueAt: () => to,
132
+ velocityAt: () => 0,
133
+ }
134
+ }
135
+
136
+ function restingValue(
137
+ property: Property,
138
+ keyframes: readonly number[],
139
+ curve: Curve,
140
+ repeat: number,
141
+ reverse: boolean,
142
+ ) {
143
+ const first = keyframes[0]!
144
+ if (!Number.isFinite(repeat) || (reverse && repeat % 2 === 1)) return first
145
+ if (curve.type !== 'decay') return keyframes.at(-1)!
146
+ const landing = solveDecay(
147
+ first,
148
+ curve.velocity,
149
+ curve.friction,
150
+ precision[property],
151
+ ).to
152
+ return curve.snap ? closest(curve.snap, landing) : landing
153
+ }
154
+
155
+ function closest(stops: readonly number[], target: number) {
156
+ return stops.reduce((best, stop) =>
157
+ Math.abs(stop - target) < Math.abs(best - target) ? stop : best,
158
+ )
97
159
  }
98
160
 
99
161
  function colorTrack(
@@ -148,28 +210,46 @@ function colorTrack(
148
210
  }
149
211
  }
150
212
 
213
+ /** Plays the same trajectory over a longer or shorter time. */
214
+ function slow(motion: Motion, factor: number): Motion {
215
+ if (factor === 1) return motion
216
+ return {
217
+ ...motion,
218
+ duration: motion.duration * factor,
219
+ position: (time) => motion.position(time / factor),
220
+ velocity: (time) => motion.velocity(time / factor) / factor,
221
+ }
222
+ }
223
+
224
+ /** Keyframes per second of playback, so slowed curves stay smooth. */
225
+ function samples(duration: number, density: number) {
226
+ return Math.max(
227
+ 1,
228
+ Math.min(
229
+ MAX_DECAY_SAMPLES,
230
+ Math.ceil(((duration * density) / 1000) * SAMPLES_PER_SECOND),
231
+ ),
232
+ )
233
+ }
234
+
151
235
  function decay(
152
236
  property: Property,
153
237
  from: number,
154
238
  curve: Extract<Curve, { type: 'decay' }>,
239
+ density: number,
155
240
  ): Motion {
156
241
  const motion = solveDecay(from, curve.velocity, curve.friction, precision[property])
157
242
  if (curve.snap) {
158
- const to = curve.snap.reduce((closest, stop) =>
159
- Math.abs(stop - motion.to) < Math.abs(closest - motion.to) ? stop : closest,
160
- )
161
243
  return spring(
162
244
  property,
163
- [from, to],
245
+ [from, closest(curve.snap, motion.to)],
164
246
  { type: 'spring', physics: springPhysics({}), velocity: curve.velocity },
165
247
  0,
248
+ density,
166
249
  )
167
250
  }
168
251
 
169
- const steps = Math.max(
170
- 1,
171
- Math.min(MAX_DECAY_SAMPLES, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND)),
172
- )
252
+ const steps = samples(motion.duration, density)
173
253
  const times: number[] = []
174
254
  const values: number[] = []
175
255
  for (let i = 0; i <= steps; i++) {
@@ -193,6 +273,7 @@ function spring(
193
273
  [from, to]: readonly number[],
194
274
  curve: Extract<Curve, { type: 'spring' }>,
195
275
  velocity: number,
276
+ density: number,
196
277
  ): Motion {
197
278
  const motion = solveSpring(
198
279
  curve.physics,
@@ -201,7 +282,7 @@ function spring(
201
282
  curve.velocity ?? velocity,
202
283
  precision[property],
203
284
  )
204
- const steps = Math.max(1, Math.ceil((motion.duration / 1000) * SAMPLES_PER_SECOND))
285
+ const steps = samples(motion.duration, density)
205
286
  const times: number[] = []
206
287
  const values: number[] = []
207
288
  for (let i = 0; i <= steps; i++) {
@@ -214,6 +295,7 @@ function spring(
214
295
  function tween(
215
296
  keyframes: readonly number[],
216
297
  curve: Extract<Curve, { type: 'tween' }>,
298
+ density: number,
217
299
  ): Motion {
218
300
  const { duration, ease } = curve
219
301
  const offsets = curve.times ?? keyframes.map((_, i) => i / (keyframes.length - 1))
@@ -237,16 +319,7 @@ function tween(
237
319
  const from = keyframes[i]!
238
320
  const to = keyframes[i + 1]!
239
321
  const easing = ease[i]!
240
- const steps =
241
- easing === linear || from === to
242
- ? 1
243
- : Math.max(
244
- 1,
245
- Math.min(
246
- MAX_DECAY_SAMPLES,
247
- Math.ceil(((span * duration) / 1000) * SAMPLES_PER_SECOND),
248
- ),
249
- )
322
+ const steps = easing === linear || from === to ? 1 : samples(span * duration, density)
250
323
  for (let step = 1; step <= steps; step++) {
251
324
  times.push(start + (span * step) / steps)
252
325
  values.push(step === steps ? to : from + (to - from) * easing(step / steps))
package/src/transition.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveEasing, type Easing, type EasingFunction } from './easing.js'
2
2
  import type { Property } from './properties.js'
3
+ import { isDecorationProperty } from './decoration.js'
3
4
  import { isColorProperty } from './color.js'
4
5
  import { springPhysics, type SpringPhysics } from './spring.js'
5
6
 
@@ -106,6 +107,7 @@ export function resolveTransition(
106
107
  : hasCurve(transition) ||
107
108
  property === 'opacity' ||
108
109
  isColorProperty(property) ||
110
+ isDecorationProperty(property) ||
109
111
  keyframes > 2
110
112
  ? 'tween'
111
113
  : 'spring')
@@ -135,7 +137,8 @@ export function resolveTransition(
135
137
  if (
136
138
  duration < 0 ||
137
139
  Number.isNaN(duration) ||
138
- (isColorProperty(property) && !Number.isFinite(duration))
140
+ ((isColorProperty(property) || isDecorationProperty(property)) &&
141
+ !Number.isFinite(duration))
139
142
  ) {
140
143
  throw new RangeError(`Invalid duration: ${duration}.`)
141
144
  }
package/src/types.ts CHANGED
@@ -7,10 +7,29 @@ import type { ColorKeyframes } from './color.js'
7
7
  export type KeyframeValue = number | readonly (number | null)[]
8
8
 
9
9
  export interface Keyframes {
10
+ /** One continuous SVG path, expressed as translation coordinates in DIPs. */
11
+ path?: string
12
+ /** Face the path's forward tangent (+X artwork). Requires path. */
13
+ orient?: boolean
10
14
  backgroundColor?: ColorKeyframes
11
15
  /** Uniform starting edge colors; widths and radii may differ. */
12
16
  borderColor?: ColorKeyframes
13
17
  color?: ColorKeyframes
18
+ /** Uniform circular corners, in DIPs. */
19
+ borderRadius?: KeyframeValue
20
+ /** Uniform visual border width, in DIPs. Content layout uses the final width. */
21
+ borderWidth?: KeyframeValue
22
+ /** iOS: opacity of one existing boxShadow, from 0 to 1. */
23
+ shadowOpacity?: KeyframeValue
24
+ /** iOS: native blur radius in DIPs (half Core's boxShadow blurRadius). */
25
+ shadowRadius?: KeyframeValue
26
+ /** iOS: offset of one existing boxShadow, in DIPs. */
27
+ shadowOffsetX?: KeyframeValue
28
+ shadowOffsetY?: KeyframeValue
29
+ /** Android: base elevation in DIPs. */
30
+ elevation?: KeyframeValue
31
+ /** Android: dynamic Z offset in DIPs. Native state changes may replace it. */
32
+ translationZ?: KeyframeValue
14
33
  opacity?: KeyframeValue
15
34
  /** Same as `translateX`, in DIPs. */
16
35
  x?: KeyframeValue
@@ -26,6 +45,9 @@ export interface Keyframes {
26
45
  rotateY?: KeyframeValue
27
46
  }
28
47
 
48
+ /** Properties that can be timed or stopped independently. A path owns x/y and optionally rotate. */
49
+ export type KeyframeProperty = Exclude<keyof Keyframes, 'path' | 'orient'>
50
+
29
51
  /** A view, or anything that holds one: a NativeScript-Vue element, an Angular `ElementRef`. */
30
52
  export type Target =
31
53
  View | { readonly nativeView: View } | { readonly nativeElement: View }
@@ -51,7 +73,7 @@ export interface Stagger {
51
73
  }
52
74
 
53
75
  export type AnimateOptions = Transition & {
54
- [K in keyof Keyframes]?: Transition | SpringName
76
+ [K in KeyframeProperty | 'path']?: Transition | SpringName
55
77
  } & {
56
78
  /** Offset translations/rotations and multiply scale/opacity by their values at the start. */
57
79
  relative?: boolean