react-native-divkit 1.7.0 → 1.8.1

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 (48) hide show
  1. package/README.md +17 -16
  2. package/dist/DivKit.d.ts.map +1 -1
  3. package/dist/DivKit.js +109 -1
  4. package/dist/DivKit.js.map +1 -1
  5. package/dist/components/pager/utils.d.ts.map +1 -1
  6. package/dist/components/pager/utils.js +17 -4
  7. package/dist/components/pager/utils.js.map +1 -1
  8. package/dist/components/state/DivState.d.ts +11 -12
  9. package/dist/components/state/DivState.d.ts.map +1 -1
  10. package/dist/components/state/DivState.js +263 -35
  11. package/dist/components/state/DivState.js.map +1 -1
  12. package/dist/components/utilities/Background.d.ts.map +1 -1
  13. package/dist/components/utilities/Background.js +4 -3
  14. package/dist/components/utilities/Background.js.map +1 -1
  15. package/dist/components/utilities/Outer.d.ts.map +1 -1
  16. package/dist/components/utilities/Outer.js +172 -76
  17. package/dist/components/utilities/Outer.js.map +1 -1
  18. package/dist/context/DivStateScopeContext.d.ts +18 -0
  19. package/dist/context/DivStateScopeContext.d.ts.map +1 -0
  20. package/dist/context/DivStateScopeContext.js +7 -0
  21. package/dist/context/DivStateScopeContext.js.map +1 -0
  22. package/dist/hooks/useAppearanceTransition.d.ts +86 -0
  23. package/dist/hooks/useAppearanceTransition.d.ts.map +1 -0
  24. package/dist/hooks/useAppearanceTransition.js +490 -0
  25. package/dist/hooks/useAppearanceTransition.js.map +1 -0
  26. package/dist/hooks/useChangeBoundsTransition.d.ts +46 -0
  27. package/dist/hooks/useChangeBoundsTransition.d.ts.map +1 -0
  28. package/dist/hooks/useChangeBoundsTransition.js +151 -0
  29. package/dist/hooks/useChangeBoundsTransition.js.map +1 -0
  30. package/dist/utils/configureChangeBoundsLayout.d.ts +11 -0
  31. package/dist/utils/configureChangeBoundsLayout.d.ts.map +1 -0
  32. package/dist/utils/configureChangeBoundsLayout.js +65 -0
  33. package/dist/utils/configureChangeBoundsLayout.js.map +1 -0
  34. package/dist/utils/flattenTransition.d.ts +5 -0
  35. package/dist/utils/flattenTransition.d.ts.map +1 -0
  36. package/dist/utils/flattenTransition.js +27 -0
  37. package/dist/utils/flattenTransition.js.map +1 -0
  38. package/package.json +2 -1
  39. package/src/DivKit.tsx +125 -2
  40. package/src/components/pager/utils.ts +18 -4
  41. package/src/components/state/DivState.tsx +308 -39
  42. package/src/components/utilities/Background.tsx +4 -3
  43. package/src/components/utilities/Outer.tsx +188 -73
  44. package/src/context/DivStateScopeContext.tsx +23 -0
  45. package/src/hooks/useAppearanceTransition.ts +621 -0
  46. package/src/hooks/useChangeBoundsTransition.ts +193 -0
  47. package/src/utils/configureChangeBoundsLayout.ts +74 -0
  48. package/src/utils/flattenTransition.ts +36 -0
@@ -0,0 +1,621 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Animated, Dimensions, Easing, EasingFunction } from 'react-native';
3
+ import type { MaybeMissing } from '../expressions/json';
4
+ import type {
5
+ AnyTransition,
6
+ AppearanceTransition,
7
+ FadeTransition,
8
+ ScaleTransition,
9
+ SlideTransition,
10
+ Visibility
11
+ } from '../types/base';
12
+ import type { Interpolation } from '../../typings/common';
13
+ import { flattenAppearanceTransition } from '../utils/flattenTransition';
14
+
15
+ function interpolationToEasing(interpolator: Interpolation | undefined): EasingFunction {
16
+ switch (interpolator) {
17
+ case 'linear': return Easing.linear;
18
+ case 'ease': return Easing.ease;
19
+ case 'ease_in': return Easing.in(Easing.ease);
20
+ case 'ease_out': return Easing.out(Easing.ease);
21
+ case 'ease_in_out': return Easing.inOut(Easing.ease);
22
+ case 'spring': return Easing.inOut(Easing.ease);
23
+ default: return Easing.inOut(Easing.ease);
24
+ }
25
+ }
26
+
27
+ interface NormalizedTransition {
28
+ hasFade: boolean;
29
+ hasScale: boolean;
30
+ hasSlide: boolean;
31
+
32
+ fadeAlpha: number;
33
+ scaleValue: number;
34
+ scalePivotX: number;
35
+ scalePivotY: number;
36
+ slideEdge: 'left' | 'top' | 'right' | 'bottom';
37
+ slideDistance: number | null;
38
+
39
+ fadeDuration: number;
40
+ fadeDelay: number;
41
+ fadeEasing: EasingFunction;
42
+
43
+ scaleDuration: number;
44
+ scaleDelay: number;
45
+ scaleEasing: EasingFunction;
46
+
47
+ slideDuration: number;
48
+ slideDelay: number;
49
+ slideEasing: EasingFunction;
50
+
51
+ totalDuration: number;
52
+ }
53
+
54
+ function normalize(
55
+ transition: MaybeMissing<AppearanceTransition> | undefined
56
+ ): NormalizedTransition {
57
+ const res: NormalizedTransition = {
58
+ hasFade: false,
59
+ hasScale: false,
60
+ hasSlide: false,
61
+ fadeAlpha: 0,
62
+ scaleValue: 0,
63
+ scalePivotX: 0.5,
64
+ scalePivotY: 0.5,
65
+ slideEdge: 'bottom',
66
+ slideDistance: null,
67
+ fadeDuration: 0,
68
+ fadeDelay: 0,
69
+ fadeEasing: Easing.inOut(Easing.ease),
70
+ scaleDuration: 0,
71
+ scaleDelay: 0,
72
+ scaleEasing: Easing.inOut(Easing.ease),
73
+ slideDuration: 0,
74
+ slideDelay: 0,
75
+ slideEasing: Easing.inOut(Easing.ease),
76
+ totalDuration: 0
77
+ };
78
+
79
+ if (!transition) return res;
80
+
81
+ const items = flattenAppearanceTransition(transition);
82
+ for (const itAny of items) {
83
+ const it = itAny as MaybeMissing<AnyTransition>;
84
+ const duration = Math.max(0, (it as any).duration ?? 300);
85
+ const delay = Math.max(0, (it as any).start_delay ?? 0);
86
+ const easing = interpolationToEasing((it as any).interpolator);
87
+ res.totalDuration = Math.max(res.totalDuration, duration + delay);
88
+
89
+ if (it.type === 'fade') {
90
+ res.hasFade = true;
91
+ const fade = it as MaybeMissing<FadeTransition>;
92
+ res.fadeAlpha = typeof fade.alpha === 'number' ? fade.alpha : 0;
93
+ res.fadeDuration = duration;
94
+ res.fadeDelay = delay;
95
+ res.fadeEasing = easing;
96
+ } else if (it.type === 'scale') {
97
+ res.hasScale = true;
98
+ const sc = it as MaybeMissing<ScaleTransition>;
99
+ res.scaleValue = typeof sc.scale === 'number' ? sc.scale : 0;
100
+ res.scalePivotX = typeof sc.pivot_x === 'number' ? sc.pivot_x : 0.5;
101
+ res.scalePivotY = typeof sc.pivot_y === 'number' ? sc.pivot_y : 0.5;
102
+ res.scaleDuration = duration;
103
+ res.scaleDelay = delay;
104
+ res.scaleEasing = easing;
105
+ } else if (it.type === 'slide') {
106
+ res.hasSlide = true;
107
+ const sl = it as MaybeMissing<SlideTransition>;
108
+ res.slideEdge = (sl.edge ?? 'bottom') as 'left' | 'top' | 'right' | 'bottom';
109
+ const dim: any = sl.distance;
110
+ const distVal = dim && typeof dim.value === 'number' ? dim.value : null;
111
+ res.slideDistance = distVal;
112
+ res.slideDuration = duration;
113
+ res.slideDelay = delay;
114
+ res.slideEasing = easing;
115
+ }
116
+ }
117
+
118
+ return res;
119
+ }
120
+
121
+ function slideOffsetFor(edge: 'left' | 'top' | 'right' | 'bottom', distance: number | null) {
122
+ const win = Dimensions.get('window');
123
+ const dx = distance ?? win.width;
124
+ const dy = distance ?? win.height;
125
+ switch (edge) {
126
+ case 'left': return { tx: -dx, ty: 0 };
127
+ case 'right': return { tx: dx, ty: 0 };
128
+ case 'top': return { tx: 0, ty: -dy };
129
+ case 'bottom':
130
+ default: return { tx: 0, ty: dy };
131
+ }
132
+ }
133
+
134
+ export interface AppearanceTransitionOptions {
135
+ visibility: Visibility;
136
+ transitionIn?: MaybeMissing<AppearanceTransition>;
137
+ transitionOut?: MaybeMissing<AppearanceTransition>;
138
+ /**
139
+ * When false, transitions are skipped (used during initial mount when visibility starts as 'visible'
140
+ * and you don't want a flicker). Default: true.
141
+ */
142
+ enabled?: boolean;
143
+ /**
144
+ * 'visibility' (default) — transitions are driven by the visibility prop changing.
145
+ * 'imperative' — visibility changes do not auto-play; the consumer must call playOut/playIn.
146
+ * 'auto-in' — like 'imperative' but transition_in is played automatically on first mount.
147
+ */
148
+ mode?: 'visibility' | 'imperative' | 'auto-in';
149
+ /**
150
+ * Called once right before the wrapper collapses layout (target visibility 'gone'
151
+ * and out animation just finished). Use to queue LayoutAnimation for parents.
152
+ */
153
+ onBeforeCollapse?: () => void;
154
+ /**
155
+ * Called once right before the wrapper mounts after being collapsed
156
+ * (visibility goes back to 'visible'). Use to queue LayoutAnimation for parents.
157
+ */
158
+ onBeforeExpand?: () => void;
159
+ /**
160
+ * Measured width of the element (from onLayout). Used to emulate off-center pivot for scale
161
+ * via translate-scale-translate. If absent, scale is applied from the view center (pivot 0.5/0.5).
162
+ */
163
+ layoutWidth?: number;
164
+ /** Measured height of the element. */
165
+ layoutHeight?: number;
166
+ }
167
+
168
+ type AnyTransformValue = number | Animated.AnimatedInterpolation<number> | Animated.Value;
169
+
170
+ export interface AppearanceTransitionResult {
171
+ /**
172
+ * True while the children should be present in the tree.
173
+ * Goes false only AFTER transition_out completed and target visibility is 'gone'/'invisible'.
174
+ */
175
+ rendered: boolean;
176
+ /** True after transition_out completed; the wrapper should collapse layout (return null). */
177
+ collapsed: boolean;
178
+ /** Animated opacity value (or constant). */
179
+ opacity: Animated.Value | number;
180
+ /**
181
+ * Ready-to-use transform array for Animated.View. Combines slide translate, scale, and
182
+ * pivot translate-scale-translate compensation. Empty array if no transition produces transforms.
183
+ */
184
+ transform: Array<{ translateX?: AnyTransformValue; translateY?: AnyTransformValue; scale?: AnyTransformValue }>;
185
+ /**
186
+ * Imperatively play transition_out (without affecting rendered/collapsed state).
187
+ * Resolves when animation finishes (or immediately if no transition_out is specified).
188
+ */
189
+ playOut: () => Promise<void>;
190
+ /**
191
+ * Imperatively reset values to transition_in start, then animate to identity.
192
+ * Resolves when animation finishes (or immediately if no transition_in is specified).
193
+ */
194
+ playIn: () => Promise<void>;
195
+ /** Whether transition_out is specified (helps consumers decide whether to wait for playOut). */
196
+ hasTransitionOut: boolean;
197
+ /** Whether transition_in is specified. */
198
+ hasTransitionIn: boolean;
199
+ }
200
+
201
+ /**
202
+ * Hook that drives transition_in / transition_out for an Outer wrapper.
203
+ *
204
+ * - On first mount: NO transition_in is played (matches Web Outer.svelte behavior with
205
+ * isVisibilityInited). Use mode='auto-in' if you do want a first-mount play (DivState
206
+ * uses this for newly-mounted children of a switched state).
207
+ * - On change visible→gone/invisible with a transition_out: animates identity → end values,
208
+ * then collapses.
209
+ * - On change gone/invisible→visible: re-mounts and plays transition_in.
210
+ *
211
+ * Only fade/scale/slide are supported here (AppearanceTransition).
212
+ */
213
+ export function useAppearanceTransition(
214
+ opts: AppearanceTransitionOptions
215
+ ): AppearanceTransitionResult {
216
+ const {
217
+ visibility,
218
+ transitionIn,
219
+ transitionOut,
220
+ enabled = true,
221
+ mode = 'visibility',
222
+ onBeforeCollapse,
223
+ onBeforeExpand,
224
+ layoutWidth,
225
+ layoutHeight
226
+ } = opts;
227
+ const onBeforeCollapseRef = useRef(onBeforeCollapse);
228
+ const onBeforeExpandRef = useRef(onBeforeExpand);
229
+ onBeforeCollapseRef.current = onBeforeCollapse;
230
+ onBeforeExpandRef.current = onBeforeExpand;
231
+
232
+ const inSpec = useMemo(() => normalize(transitionIn), [transitionIn]);
233
+ const outSpec = useMemo(() => normalize(transitionOut), [transitionOut]);
234
+
235
+ // Refs to Animated values — created once
236
+ const opacity = useRef(new Animated.Value(visibility === 'visible' ? 1 : 0)).current;
237
+ const scale = useRef(new Animated.Value(1)).current;
238
+ const slideTx = useRef(new Animated.Value(0)).current;
239
+ const slideTy = useRef(new Animated.Value(0)).current;
240
+
241
+ const prevVisibilityRef = useRef<Visibility>(visibility);
242
+ const isFirstRunRef = useRef(true);
243
+ const inFlightRef = useRef<Animated.CompositeAnimation | null>(null);
244
+
245
+ const [rendered, setRendered] = useState<boolean>(visibility !== 'gone');
246
+ const [collapsed, setCollapsed] = useState<boolean>(visibility === 'gone');
247
+
248
+ // Active scale spec for transform composition (pivot + endpoint).
249
+ // Updated when we start an "in" or "out" scale animation; used to build interpolated transform.
250
+ const [activeScale, setActiveScale] = useState<{
251
+ value: number;
252
+ pivotX: number;
253
+ pivotY: number;
254
+ } | null>(() => {
255
+ if (visibility === 'visible' && (inSpec.hasScale)) {
256
+ return { value: inSpec.scaleValue, pivotX: inSpec.scalePivotX, pivotY: inSpec.scalePivotY };
257
+ }
258
+ return null;
259
+ });
260
+
261
+ useEffect(() => {
262
+ const prev = prevVisibilityRef.current;
263
+ prevVisibilityRef.current = visibility;
264
+
265
+ // Imperative mode: visibility prop is ignored for transitions — consumer drives via playOut/playIn.
266
+ // Only sync rendered/collapsed for static defaults; never auto-play.
267
+ if (mode === 'imperative') {
268
+ if (isFirstRunRef.current) {
269
+ isFirstRunRef.current = false;
270
+ if (visibility === 'visible') {
271
+ opacity.setValue(1);
272
+ scale.setValue(1);
273
+ slideTx.setValue(0);
274
+ slideTy.setValue(0);
275
+ }
276
+ }
277
+ return;
278
+ }
279
+
280
+ // auto-in mode: play transition_in on first mount, then ignore visibility changes
281
+ // (the consumer — e.g. DivState — drives transition_out programmatically).
282
+ if (mode === 'auto-in' && !isFirstRunRef.current) {
283
+ return;
284
+ }
285
+
286
+ if (isFirstRunRef.current) {
287
+ isFirstRunRef.current = false;
288
+ // Matches Web Outer.svelte: на первом монтаже не играем transition_in —
289
+ // он запускается только при последующем изменении visibility (см. isVisibilityInited).
290
+ if (visibility === 'visible') {
291
+ opacity.setValue(1);
292
+ scale.setValue(1);
293
+ slideTx.setValue(0);
294
+ slideTy.setValue(0);
295
+ } else {
296
+ opacity.setValue(0);
297
+ }
298
+ return;
299
+ }
300
+
301
+ // visible → gone/invisible
302
+ if (prev === 'visible' && visibility !== 'visible') {
303
+ if (enabled && (outSpec.hasFade || outSpec.hasScale || outSpec.hasSlide)) {
304
+ setRendered(true);
305
+ setCollapsed(false);
306
+ if (outSpec.hasScale) {
307
+ setActiveScale({ value: outSpec.scaleValue, pivotX: outSpec.scalePivotX, pivotY: outSpec.scalePivotY });
308
+ }
309
+ runOut(visibility);
310
+ } else {
311
+ opacity.setValue(visibility === 'invisible' ? 0 : 0);
312
+ if (visibility === 'gone') {
313
+ onBeforeCollapseRef.current?.();
314
+ setRendered(false);
315
+ setCollapsed(true);
316
+ }
317
+ }
318
+ return;
319
+ }
320
+
321
+ // gone/invisible → visible
322
+ if (prev !== 'visible' && visibility === 'visible') {
323
+ if (prev === 'gone') {
324
+ onBeforeExpandRef.current?.();
325
+ }
326
+ setRendered(true);
327
+ setCollapsed(false);
328
+ if (enabled && (inSpec.hasFade || inSpec.hasScale || inSpec.hasSlide)) {
329
+ if (inSpec.hasFade) opacity.setValue(inSpec.fadeAlpha);
330
+ else opacity.setValue(1);
331
+ if (inSpec.hasScale) {
332
+ scale.setValue(inSpec.scaleValue);
333
+ setActiveScale({ value: inSpec.scaleValue, pivotX: inSpec.scalePivotX, pivotY: inSpec.scalePivotY });
334
+ } else {
335
+ scale.setValue(1);
336
+ setActiveScale(null);
337
+ }
338
+ if (inSpec.hasSlide) {
339
+ const off = slideOffsetFor(inSpec.slideEdge, inSpec.slideDistance);
340
+ slideTx.setValue(off.tx);
341
+ slideTy.setValue(off.ty);
342
+ } else {
343
+ slideTx.setValue(0);
344
+ slideTy.setValue(0);
345
+ }
346
+ runIn();
347
+ } else {
348
+ opacity.setValue(1);
349
+ scale.setValue(1);
350
+ slideTx.setValue(0);
351
+ slideTy.setValue(0);
352
+ setActiveScale(null);
353
+ }
354
+ return;
355
+ }
356
+
357
+ if (prev !== 'visible' && visibility !== 'visible') {
358
+ setRendered(visibility !== 'gone');
359
+ setCollapsed(visibility === 'gone');
360
+ }
361
+ // eslint-disable-next-line react-hooks/exhaustive-deps
362
+ }, [visibility, inSpec, outSpec, enabled, mode]);
363
+
364
+ function stopInFlight() {
365
+ if (inFlightRef.current) {
366
+ inFlightRef.current.stop();
367
+ inFlightRef.current = null;
368
+ }
369
+ }
370
+
371
+ /** Build the list of timings for a transition_in (target = identity). */
372
+ function buildInAnimations(): Animated.CompositeAnimation[] {
373
+ const anims: Animated.CompositeAnimation[] = [];
374
+ if (inSpec.hasFade) {
375
+ anims.push(Animated.timing(opacity, {
376
+ toValue: 1, duration: inSpec.fadeDuration, delay: inSpec.fadeDelay,
377
+ easing: inSpec.fadeEasing, useNativeDriver: true
378
+ }));
379
+ }
380
+ if (inSpec.hasScale) {
381
+ anims.push(Animated.timing(scale, {
382
+ toValue: 1, duration: inSpec.scaleDuration, delay: inSpec.scaleDelay,
383
+ easing: inSpec.scaleEasing, useNativeDriver: true
384
+ }));
385
+ }
386
+ if (inSpec.hasSlide) {
387
+ anims.push(Animated.timing(slideTx, {
388
+ toValue: 0, duration: inSpec.slideDuration, delay: inSpec.slideDelay,
389
+ easing: inSpec.slideEasing, useNativeDriver: true
390
+ }));
391
+ anims.push(Animated.timing(slideTy, {
392
+ toValue: 0, duration: inSpec.slideDuration, delay: inSpec.slideDelay,
393
+ easing: inSpec.slideEasing, useNativeDriver: true
394
+ }));
395
+ }
396
+ return anims;
397
+ }
398
+
399
+ /** Build the list of timings for a transition_out (target = end values). */
400
+ function buildOutAnimations(): Animated.CompositeAnimation[] {
401
+ const anims: Animated.CompositeAnimation[] = [];
402
+ if (outSpec.hasFade) {
403
+ anims.push(Animated.timing(opacity, {
404
+ toValue: outSpec.fadeAlpha, duration: outSpec.fadeDuration, delay: outSpec.fadeDelay,
405
+ easing: outSpec.fadeEasing, useNativeDriver: true
406
+ }));
407
+ }
408
+ if (outSpec.hasScale) {
409
+ anims.push(Animated.timing(scale, {
410
+ toValue: outSpec.scaleValue, duration: outSpec.scaleDuration, delay: outSpec.scaleDelay,
411
+ easing: outSpec.scaleEasing, useNativeDriver: true
412
+ }));
413
+ }
414
+ if (outSpec.hasSlide) {
415
+ const off = slideOffsetFor(outSpec.slideEdge, outSpec.slideDistance);
416
+ anims.push(Animated.timing(slideTx, {
417
+ toValue: off.tx, duration: outSpec.slideDuration, delay: outSpec.slideDelay,
418
+ easing: outSpec.slideEasing, useNativeDriver: true
419
+ }));
420
+ anims.push(Animated.timing(slideTy, {
421
+ toValue: off.ty, duration: outSpec.slideDuration, delay: outSpec.slideDelay,
422
+ easing: outSpec.slideEasing, useNativeDriver: true
423
+ }));
424
+ }
425
+ return anims;
426
+ }
427
+
428
+ function runIn(): Promise<void> {
429
+ stopInFlight();
430
+ const anims = buildInAnimations();
431
+ if (anims.length === 0) return Promise.resolve();
432
+ const comp = Animated.parallel(anims);
433
+ inFlightRef.current = comp;
434
+ return new Promise<void>(resolve => {
435
+ comp.start(({ finished }) => {
436
+ if (inFlightRef.current === comp) inFlightRef.current = null;
437
+ if (finished) {
438
+ if (inSpec.hasFade) opacity.setValue(1);
439
+ if (inSpec.hasScale) scale.setValue(1);
440
+ if (inSpec.hasSlide) {
441
+ slideTx.setValue(0);
442
+ slideTy.setValue(0);
443
+ }
444
+ }
445
+ resolve();
446
+ });
447
+ });
448
+ }
449
+
450
+ function runOut(target: Visibility): Promise<void> {
451
+ stopInFlight();
452
+ const anims = buildOutAnimations();
453
+ if (anims.length === 0) {
454
+ if (target === 'gone') {
455
+ onBeforeCollapseRef.current?.();
456
+ setRendered(false);
457
+ setCollapsed(true);
458
+ }
459
+ return Promise.resolve();
460
+ }
461
+ const comp = Animated.parallel(anims);
462
+ inFlightRef.current = comp;
463
+ return new Promise<void>(resolve => {
464
+ comp.start(({ finished }) => {
465
+ if (inFlightRef.current === comp) inFlightRef.current = null;
466
+ if (!finished) {
467
+ resolve();
468
+ return;
469
+ }
470
+ if (target === 'gone') {
471
+ onBeforeCollapseRef.current?.();
472
+ setRendered(false);
473
+ setCollapsed(true);
474
+ }
475
+ resolve();
476
+ });
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Imperative API: play transition_out without touching rendered/collapsed state.
482
+ * Caller decides whether to unmount the element after the promise resolves.
483
+ */
484
+ const playOut = useCallback((): Promise<void> => {
485
+ stopInFlight();
486
+ const anims = buildOutAnimations();
487
+ if (anims.length === 0) return Promise.resolve();
488
+ const comp = Animated.parallel(anims);
489
+ inFlightRef.current = comp;
490
+ if (outSpec.hasScale) {
491
+ setActiveScale({
492
+ value: outSpec.scaleValue,
493
+ pivotX: outSpec.scalePivotX,
494
+ pivotY: outSpec.scalePivotY
495
+ });
496
+ }
497
+ return new Promise<void>(resolve => {
498
+ comp.start(() => {
499
+ if (inFlightRef.current === comp) inFlightRef.current = null;
500
+ resolve();
501
+ });
502
+ });
503
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
+ }, [outSpec]);
505
+
506
+ /**
507
+ * Imperative API: reset values to transition_in start, then animate to identity.
508
+ */
509
+ const playIn = useCallback((): Promise<void> => {
510
+ stopInFlight();
511
+ // Reset values to start
512
+ if (inSpec.hasFade) opacity.setValue(inSpec.fadeAlpha);
513
+ if (inSpec.hasScale) {
514
+ scale.setValue(inSpec.scaleValue);
515
+ setActiveScale({
516
+ value: inSpec.scaleValue,
517
+ pivotX: inSpec.scalePivotX,
518
+ pivotY: inSpec.scalePivotY
519
+ });
520
+ }
521
+ if (inSpec.hasSlide) {
522
+ const off = slideOffsetFor(inSpec.slideEdge, inSpec.slideDistance);
523
+ slideTx.setValue(off.tx);
524
+ slideTy.setValue(off.ty);
525
+ }
526
+ const anims = buildInAnimations();
527
+ if (anims.length === 0) return Promise.resolve();
528
+ const comp = Animated.parallel(anims);
529
+ inFlightRef.current = comp;
530
+ return new Promise<void>(resolve => {
531
+ comp.start(({ finished }) => {
532
+ if (inFlightRef.current === comp) inFlightRef.current = null;
533
+ if (finished) {
534
+ if (inSpec.hasFade) opacity.setValue(1);
535
+ if (inSpec.hasScale) scale.setValue(1);
536
+ if (inSpec.hasSlide) {
537
+ slideTx.setValue(0);
538
+ slideTy.setValue(0);
539
+ }
540
+ }
541
+ resolve();
542
+ });
543
+ });
544
+ // eslint-disable-next-line react-hooks/exhaustive-deps
545
+ }, [inSpec]);
546
+
547
+ const hasTransitionIn = inSpec.hasFade || inSpec.hasScale || inSpec.hasSlide;
548
+ const hasTransitionOut = outSpec.hasFade || outSpec.hasScale || outSpec.hasSlide;
549
+
550
+ useEffect(() => {
551
+ return () => {
552
+ if (inFlightRef.current) {
553
+ inFlightRef.current.stop();
554
+ inFlightRef.current = null;
555
+ }
556
+ };
557
+ }, []);
558
+
559
+ // Build the final transform array, combining slide translate + scale + pivot compensation.
560
+ const transform = useMemo(() => {
561
+ const out: Array<{ translateX?: AnyTransformValue; translateY?: AnyTransformValue; scale?: AnyTransformValue }> = [];
562
+ const hasAnySlide = inSpec.hasSlide || outSpec.hasSlide;
563
+ const hasAnyScale = inSpec.hasScale || outSpec.hasScale;
564
+ if (!hasAnySlide && !hasAnyScale) return out;
565
+
566
+ // Build translateX/Y from slide + pivot interpolation.
567
+ let txExpr: AnyTransformValue | null = hasAnySlide ? slideTx : null;
568
+ let tyExpr: AnyTransformValue | null = hasAnySlide ? slideTy : null;
569
+
570
+ if (
571
+ hasAnyScale &&
572
+ activeScale &&
573
+ (activeScale.pivotX !== 0.5 || activeScale.pivotY !== 0.5) &&
574
+ typeof layoutWidth === 'number' &&
575
+ typeof layoutHeight === 'number' &&
576
+ layoutWidth > 0 &&
577
+ layoutHeight > 0
578
+ ) {
579
+ // pivotTx = (pivotX - 0.5) * width * (1 - scale)
580
+ // Interpolate over [min(value,1), max(value,1)] mapped to corresponding endpoints.
581
+ const a = Math.min(activeScale.value, 1);
582
+ const b = Math.max(activeScale.value, 1);
583
+ if (a !== b && activeScale.pivotX !== 0.5) {
584
+ const offsetA = (activeScale.pivotX - 0.5) * layoutWidth * (1 - a);
585
+ const offsetB = (activeScale.pivotX - 0.5) * layoutWidth * (1 - b);
586
+ const pivotTx = scale.interpolate({
587
+ inputRange: [a, b],
588
+ outputRange: [offsetA, offsetB],
589
+ extrapolate: 'clamp'
590
+ });
591
+ txExpr = txExpr !== null ? Animated.add(txExpr as Animated.Animated, pivotTx) as AnyTransformValue : pivotTx;
592
+ }
593
+ if (a !== b && activeScale.pivotY !== 0.5) {
594
+ const offsetA = (activeScale.pivotY - 0.5) * layoutHeight * (1 - a);
595
+ const offsetB = (activeScale.pivotY - 0.5) * layoutHeight * (1 - b);
596
+ const pivotTy = scale.interpolate({
597
+ inputRange: [a, b],
598
+ outputRange: [offsetA, offsetB],
599
+ extrapolate: 'clamp'
600
+ });
601
+ tyExpr = tyExpr !== null ? Animated.add(tyExpr as Animated.Animated, pivotTy) as AnyTransformValue : pivotTy;
602
+ }
603
+ }
604
+
605
+ if (txExpr !== null) out.push({ translateX: txExpr });
606
+ if (tyExpr !== null) out.push({ translateY: tyExpr });
607
+ if (hasAnyScale) out.push({ scale });
608
+ return out;
609
+ }, [inSpec, outSpec, activeScale, layoutWidth, layoutHeight, slideTx, slideTy, scale]);
610
+
611
+ return {
612
+ rendered,
613
+ collapsed,
614
+ opacity,
615
+ transform,
616
+ playIn,
617
+ playOut,
618
+ hasTransitionIn,
619
+ hasTransitionOut
620
+ };
621
+ }