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
@@ -1,77 +1,189 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
2
- import { View } from 'react-native';
1
+ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
+ import { Animated, Easing, View, ViewStyle, LayoutChangeEvent } from 'react-native';
3
3
  import type { ComponentContext } from '../../types/componentContext';
4
4
  import type { DivStateData, State } from '../../types/state';
5
+ import type { TransitionChange } from '../../types/base';
6
+ import type { MaybeMissing } from '../../expressions/json';
5
7
  import { Outer } from '../utilities/Outer';
6
8
  import { useStateContext } from '../../context/StateContext';
7
9
  import { useDivKitContext } from '../../context/DivKitContext';
10
+ import { DivStateScopeContext, type DivStateScopeValue } from '../../context/DivStateScopeContext';
11
+ import { LayoutParamsContext } from '../../context/LayoutParamsContext';
8
12
  import { wrapError } from '../../utils/wrapError';
13
+ import { flattenChangeTransition } from '../../utils/flattenTransition';
9
14
 
10
15
  export interface DivStateProps {
11
16
  componentContext: ComponentContext<DivStateData>;
12
17
  }
13
18
 
19
+ interface StagedStateChange {
20
+ targetStateId: string | undefined;
21
+ div: any;
22
+ }
23
+
24
+ interface BoundsFrame {
25
+ left: number;
26
+ top: number;
27
+ width: number;
28
+ height: number;
29
+ }
30
+
14
31
  /**
15
32
  * DivState component - renders different content based on state
16
- * MVP implementation with basic features:
33
+ *
34
+ * Supports:
17
35
  * - State selection by state_id
18
36
  * - Default state
19
- * - State switching via actions (set_state)
20
- * - State registration in StateContext
21
- * - State variable binding (state_id_variable)
37
+ * - State switching via actions (set_state) and via state_id_variable two-way binding
38
+ * - transition_change on the state container (smooth layout transitions for neighbours via
39
+ * configureChangeBoundsLayout)
40
+ * - Per-element transition_out for children declaring it in the OUTGOING state JSON
41
+ * (children register a playOut via DivStateScopeContext; DivState awaits them in parallel
42
+ * before mounting the new state). Transition_in for the INCOMING children plays automatically
43
+ * on mount via Outer's mode='auto-in'.
22
44
  *
23
- * Deferred for post-MVP:
24
- * - Transition animations (in/out/change)
25
- * - Animation timing and interpolation
26
- * - Clip to bounds
27
- * - Advanced state management
28
- * - Multiple concurrent state transitions
29
- *
30
- * Based on Web State.svelte
45
+ * Based on Web State.svelte (simplified — no per-element bbox tracking for transition_change
46
+ * within state subtree).
31
47
  */
32
48
  export function DivState({ componentContext }: DivStateProps) {
33
49
  const { json } = componentContext;
34
50
  const { getVariable } = useDivKitContext();
35
51
  const { registerState } = useStateContext();
36
52
 
37
- // Get state ID for registration
38
53
  const stateId = json.div_id || json.id;
39
54
 
40
- // Find default state
41
55
  const defaultStateId = useMemo(() => {
42
56
  if (json.default_state_id) {
43
57
  return json.default_state_id;
44
58
  }
45
- // If no default, use first state
46
59
  if (json.states && json.states.length > 0) {
47
60
  return json.states[0].state_id;
48
61
  }
49
62
  return undefined;
50
63
  }, [json.default_state_id, json.states]);
51
64
 
52
- // State management
53
65
  const [currentStateId, setCurrentStateId] = useState<string | undefined>(defaultStateId);
66
+ const [stagedStateChange, setStagedStateChange] = useState<StagedStateChange | null>(null);
67
+ const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
68
+ // True while we're awaiting transition_out of the previous state — we keep rendering the
69
+ // outgoing children during this window so their out-animations remain visible.
70
+ const [pendingStateId, setPendingStateId] = useState<string | undefined>(undefined);
71
+
72
+ // Registry of transition_out players from children inside this state's scope.
73
+ // The set is REPLACED each time the state swaps (because children unmount), so we don't need
74
+ // explicit clearing — old entries are pruned naturally by Outer's cleanup effect.
75
+ const outPlayersRef = useRef<Set<() => Promise<void>>>(new Set());
76
+ const scopeValue: DivStateScopeValue = useMemo(() => ({
77
+ registerTransitionOutPlayer(play: () => Promise<void>) {
78
+ outPlayersRef.current.add(play);
79
+ return () => {
80
+ outPlayersRef.current.delete(play);
81
+ };
82
+ }
83
+ }), []);
84
+
85
+ const transitionChange = (json as DivStateData).transition_change as MaybeMissing<TransitionChange> | undefined;
86
+ const stageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87
+ const animatedFrame = useRef({
88
+ left: new Animated.Value(0),
89
+ top: new Animated.Value(0),
90
+ width: new Animated.Value(0),
91
+ height: new Animated.Value(0),
92
+ }).current;
93
+
94
+ const applyStateChange = useCallback(async (newStateId: string | undefined) => {
95
+ if (newStateId === currentStateId || newStateId === pendingStateId) return;
96
+
97
+ const outPlayers = Array.from(outPlayersRef.current);
98
+ if (outPlayers.length > 0) {
99
+ setPendingStateId(newStateId);
100
+ try {
101
+ await Promise.all(outPlayers.map(p => p()));
102
+ } catch (err) {
103
+ componentContext.logError(wrapError(err as Error, {
104
+ additional: { phase: 'state_transition_out' }
105
+ }));
106
+ }
107
+ }
108
+
109
+ const nextState = json.states?.find(state => state.state_id === newStateId);
110
+ const previousState = json.states?.find(state => state.state_id === currentStateId);
111
+ const nextTransitionChange = (nextState?.div as any)?.transition_change as MaybeMissing<TransitionChange> | undefined;
112
+ const currentTransitionChange = (previousState?.div as any)?.transition_change as MaybeMissing<TransitionChange> | undefined;
113
+ const effectiveTransitionChange = nextTransitionChange || currentTransitionChange || transitionChange;
114
+ const duration = getChangeBoundsDuration(effectiveTransitionChange);
115
+
116
+ if (previousState?.div && nextState?.div && contentSize && duration > 0) {
117
+ const fromFrame = getChildFrame(previousState.div as any, contentSize);
118
+ const toFrame = getChildFrame(nextState.div as any, contentSize);
119
+
120
+ animatedFrame.left.setValue(fromFrame.left);
121
+ animatedFrame.top.setValue(fromFrame.top);
122
+ animatedFrame.width.setValue(fromFrame.width);
123
+ animatedFrame.height.setValue(fromFrame.height);
124
+ setPendingStateId(newStateId);
125
+ setStagedStateChange({
126
+ targetStateId: newStateId,
127
+ div: createOverlayDiv(previousState.div)
128
+ });
129
+
130
+ await new Promise<void>(resolve => {
131
+ if (stageTimerRef.current) {
132
+ clearTimeout(stageTimerRef.current);
133
+ }
134
+
135
+ Animated.parallel([
136
+ Animated.timing(animatedFrame.left, {
137
+ toValue: toFrame.left,
138
+ duration,
139
+ easing: Easing.inOut(Easing.ease),
140
+ useNativeDriver: false,
141
+ }),
142
+ Animated.timing(animatedFrame.top, {
143
+ toValue: toFrame.top,
144
+ duration,
145
+ easing: Easing.inOut(Easing.ease),
146
+ useNativeDriver: false,
147
+ }),
148
+ Animated.timing(animatedFrame.width, {
149
+ toValue: toFrame.width,
150
+ duration,
151
+ easing: Easing.inOut(Easing.ease),
152
+ useNativeDriver: false,
153
+ }),
154
+ Animated.timing(animatedFrame.height, {
155
+ toValue: toFrame.height,
156
+ duration,
157
+ easing: Easing.inOut(Easing.ease),
158
+ useNativeDriver: false,
159
+ }),
160
+ ]).start(() => {
161
+ resolve();
162
+ });
163
+ });
164
+ }
165
+
166
+ setCurrentStateId(newStateId);
167
+ setStagedStateChange(null);
168
+ setPendingStateId(undefined);
169
+ }, [currentStateId, pendingStateId, json.states, transitionChange, contentSize, animatedFrame, componentContext]);
54
170
 
55
171
  // Handle state_id_variable (two-way binding)
56
172
  const stateVariableName = json.state_id_variable;
57
173
  const stateVariable = stateVariableName ? getVariable(stateVariableName) : undefined;
58
174
 
59
- // Sync with state variable
60
175
  useEffect(() => {
61
176
  if (stateVariable) {
62
- // Subscribe to variable changes
63
177
  const unsubscribe = stateVariable.subscribe((value: unknown) => {
64
178
  if (typeof value === 'string' && value !== currentStateId) {
65
- setCurrentStateId(value);
179
+ void applyStateChange(value);
66
180
  }
67
181
  });
68
-
69
182
  return unsubscribe;
70
183
  }
71
184
  return undefined;
72
- }, [stateVariable, currentStateId]);
185
+ }, [stateVariable, currentStateId, applyStateChange]);
73
186
 
74
- // Update variable when state changes
75
187
  useEffect(() => {
76
188
  if (stateVariable && currentStateId) {
77
189
  const currentValue = stateVariable.getValue();
@@ -81,19 +193,17 @@ export function DivState({ componentContext }: DivStateProps) {
81
193
  }
82
194
  }, [stateVariable, currentStateId]);
83
195
 
84
- // Register state in context for set_state action
85
196
  useEffect(() => {
86
197
  if (stateId) {
87
198
  const unregister = registerState(stateId, async (newStateId: string) => {
88
- setCurrentStateId(newStateId);
199
+ await applyStateChange(newStateId);
89
200
  return undefined;
90
201
  });
91
202
  return unregister;
92
203
  }
93
204
  return undefined;
94
- }, [stateId, registerState]);
205
+ }, [stateId, registerState, applyStateChange]);
95
206
 
96
- // Validate states
97
207
  useEffect(() => {
98
208
  if (!json.states || json.states.length === 0) {
99
209
  componentContext.logError(wrapError(new Error('Empty "states" prop for div "state"')));
@@ -103,7 +213,15 @@ export function DivState({ componentContext }: DivStateProps) {
103
213
  }
104
214
  }, [json.states, stateId, componentContext]);
105
215
 
106
- // Find current state
216
+ useEffect(() => {
217
+ return () => {
218
+ if (stageTimerRef.current) {
219
+ clearTimeout(stageTimerRef.current);
220
+ stageTimerRef.current = null;
221
+ }
222
+ };
223
+ }, []);
224
+
107
225
  const currentState = useMemo((): State | undefined => {
108
226
  if (!json.states) return undefined;
109
227
  const found = json.states.find(s => s.state_id === currentStateId);
@@ -111,31 +229,182 @@ export function DivState({ componentContext }: DivStateProps) {
111
229
  return found as State;
112
230
  }, [json.states, currentStateId]);
113
231
 
114
- // Create child context for current state
115
- const childContext = useMemo(() => {
116
- if (!currentState?.div) return undefined;
232
+ const renderedDiv = currentState?.div;
117
233
 
118
- return componentContext.produceChildContext(currentState.div, {
234
+ const childContext = useMemo(() => {
235
+ if (!renderedDiv) return undefined;
236
+ return componentContext.produceChildContext(renderedDiv, {
119
237
  path: currentStateId
120
238
  });
121
- }, [currentState, currentStateId, componentContext]);
239
+ }, [renderedDiv, currentStateId, componentContext]);
240
+
241
+ const contentStyle = useMemo((): ViewStyle => {
242
+ const child = renderedDiv as any;
243
+ const style: ViewStyle = {
244
+ width: '100%',
245
+ alignItems: mapAlignmentToFlex(child?.alignment_horizontal),
246
+ justifyContent: mapAlignmentToFlex(child?.alignment_vertical),
247
+ };
248
+
249
+ const heightType = (json.height as any)?.type;
250
+ if (heightType === 'fixed' || heightType === 'match_parent') {
251
+ style.flex = 1;
252
+ }
253
+
254
+ return style;
255
+ }, [renderedDiv, json.height]);
122
256
 
123
- // Render current state
124
257
  const renderContent = () => {
125
- if (!currentState?.div || !childContext) {
258
+ if (!renderedDiv || !childContext) {
126
259
  return null;
127
260
  }
128
-
129
261
  // Import DivComponent dynamically to avoid circular dependency
130
262
  // eslint-disable-next-line @typescript-eslint/no-var-requires
131
263
  const DivComponent = require('../DivComponent').DivComponent;
132
-
133
264
  return <DivComponent componentContext={childContext} />;
134
265
  };
135
266
 
267
+ const overlayContext = useMemo(() => {
268
+ if (!stagedStateChange?.div) return undefined;
269
+ return componentContext.produceChildContext(stagedStateChange.div, {
270
+ path: currentStateId
271
+ });
272
+ }, [stagedStateChange, currentStateId, componentContext]);
273
+
274
+ const renderOverlay = () => {
275
+ if (!stagedStateChange?.div || !overlayContext) {
276
+ return null;
277
+ }
278
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
279
+ const DivComponent = require('../DivComponent').DivComponent;
280
+ return (
281
+ <Animated.View
282
+ pointerEvents="none"
283
+ style={{
284
+ position: 'absolute',
285
+ left: animatedFrame.left,
286
+ top: animatedFrame.top,
287
+ width: animatedFrame.width,
288
+ height: animatedFrame.height,
289
+ overflow: 'hidden',
290
+ }}
291
+ >
292
+ <LayoutParamsContext.Provider value={{ parentContainerOrientation: 'vertical' }}>
293
+ <DivComponent componentContext={overlayContext} />
294
+ </LayoutParamsContext.Provider>
295
+ </Animated.View>
296
+ );
297
+ };
298
+
299
+ const handleContentLayout = useCallback((event: LayoutChangeEvent) => {
300
+ const { width, height } = event.nativeEvent.layout;
301
+ setContentSize(prev => {
302
+ if (prev && prev.width === width && prev.height === height) return prev;
303
+ return { width, height };
304
+ });
305
+ }, []);
306
+
136
307
  return (
137
308
  <Outer componentContext={componentContext}>
138
- <View>{renderContent()}</View>
309
+ <DivStateScopeContext.Provider value={scopeValue}>
310
+ <View style={[contentStyle, { position: 'relative' }]} onLayout={handleContentLayout}>
311
+ {stagedStateChange ? (
312
+ <View style={{ opacity: 0 }}>
313
+ <LayoutParamsContext.Provider value={{ parentContainerOrientation: 'vertical' }}>
314
+ {renderContent()}
315
+ </LayoutParamsContext.Provider>
316
+ </View>
317
+ ) : (
318
+ <LayoutParamsContext.Provider value={{ parentContainerOrientation: 'vertical' }}>
319
+ {renderContent()}
320
+ </LayoutParamsContext.Provider>
321
+ )}
322
+ {renderOverlay()}
323
+ </View>
324
+ </DivStateScopeContext.Provider>
139
325
  </Outer>
140
326
  );
141
327
  }
328
+
329
+ type FlexAlignment = 'flex-start' | 'center' | 'flex-end';
330
+
331
+ function mapAlignmentToFlex(alignment: string | undefined): FlexAlignment {
332
+ switch (alignment) {
333
+ case 'center':
334
+ return 'center';
335
+ case 'right':
336
+ case 'bottom':
337
+ case 'end':
338
+ return 'flex-end';
339
+ case 'left':
340
+ case 'top':
341
+ case 'start':
342
+ default:
343
+ return 'flex-start';
344
+ }
345
+ }
346
+
347
+ function getChangeBoundsDuration(transition: MaybeMissing<TransitionChange> | undefined): number {
348
+ if (!transition) return 0;
349
+ return flattenChangeTransition(transition).reduce((max, item) => {
350
+ const duration = Math.max(0, (item as any).duration ?? 300);
351
+ const delay = Math.max(0, (item as any).start_delay ?? 0);
352
+ return Math.max(max, duration + delay);
353
+ }, 0);
354
+ }
355
+
356
+ function createOverlayDiv(previousDiv: any): any {
357
+ return {
358
+ ...previousDiv,
359
+ alignment_horizontal: 'left',
360
+ alignment_vertical: 'top',
361
+ width: { type: 'match_parent' },
362
+ height: { type: 'match_parent' },
363
+ margins: undefined,
364
+ transition_change: undefined,
365
+ };
366
+ }
367
+
368
+ function getChildFrame(div: any, container: { width: number; height: number }): BoundsFrame {
369
+ const margins = div?.margins || {};
370
+ const leftMargin = numberOrZero(margins.left ?? margins.start);
371
+ const rightMargin = numberOrZero(margins.right ?? margins.end);
372
+ const topMargin = numberOrZero(margins.top);
373
+ const bottomMargin = numberOrZero(margins.bottom);
374
+ const availableWidth = Math.max(0, container.width - leftMargin - rightMargin);
375
+ const availableHeight = Math.max(0, container.height - topMargin - bottomMargin);
376
+ const width = resolveSize(div?.width, availableWidth);
377
+ const height = resolveSize(div?.height, availableHeight);
378
+
379
+ return {
380
+ left: resolvePosition(div?.alignment_horizontal, leftMargin, availableWidth, width),
381
+ top: resolvePosition(div?.alignment_vertical, topMargin, availableHeight, height),
382
+ width,
383
+ height,
384
+ };
385
+ }
386
+
387
+ function resolveSize(size: any, available: number): number {
388
+ if (size?.type === 'fixed') return Math.max(0, numberOrZero(size.value));
389
+ return available;
390
+ }
391
+
392
+ function resolvePosition(alignment: string | undefined, start: number, available: number, size: number): number {
393
+ switch (alignment) {
394
+ case 'center':
395
+ return start + (available - size) / 2;
396
+ case 'right':
397
+ case 'bottom':
398
+ case 'end':
399
+ return start + available - size;
400
+ case 'left':
401
+ case 'top':
402
+ case 'start':
403
+ default:
404
+ return start;
405
+ }
406
+ }
407
+
408
+ function numberOrZero(value: unknown): number {
409
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
410
+ }
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { StyleSheet, View, ViewStyle } from 'react-native';
3
3
  import Svg, { Defs, RadialGradient, Stop, Rect } from 'react-native-svg';
4
4
  import type { Background as BackgroundType, RadialBackground } from '../../types/background';
5
+ import { correctColor } from '../../utils/correctColor';
5
6
 
6
7
  export interface BackgroundProps {
7
8
  layers?: BackgroundType[];
@@ -41,7 +42,7 @@ const RadialGradientLayer = ({ layer }: { layer: RadialBackground }) => {
41
42
  <Stop
42
43
  key={index}
43
44
  offset={index / (layer.colors!.length - 1)}
44
- stopColor={color}
45
+ stopColor={correctColor(color)}
45
46
  stopOpacity={1}
46
47
  />
47
48
  ));
@@ -50,7 +51,7 @@ const RadialGradientLayer = ({ layer }: { layer: RadialBackground }) => {
50
51
  <Stop
51
52
  key={index}
52
53
  offset={point.position}
53
- stopColor={point.color}
54
+ stopColor={correctColor(point.color)}
54
55
  stopOpacity={1}
55
56
  />
56
57
  ));
@@ -102,7 +103,7 @@ export const Background = ({ layers, style }: BackgroundProps) => {
102
103
  return (
103
104
  <View
104
105
  key={index}
105
- style={[StyleSheet.absoluteFill, { backgroundColor: layer.color }]}
106
+ style={[StyleSheet.absoluteFill, { backgroundColor: correctColor(layer.color) }]}
106
107
  />
107
108
  );
108
109
  }