@applicaster/zapp-react-native-ui-components 16.0.0-rc.108 → 16.0.0-rc.109
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/Components/ModalComponent/AudioPlayer/Components/Item.tsx +22 -1
- package/Components/ModalComponent/AudioPlayer/Components/__tests__/Item.test.tsx +52 -1
- package/Components/ModalComponent/AudioPlayer/utils/__tests__/mergeStyles.test.ts +37 -0
- package/Components/ModalComponent/AudioPlayer/utils/mergeStyles.ts +3 -1
- package/Components/ModalComponent/BottomSheetModalContent.tsx +4 -73
- package/Components/Transitioner/AnimationManager.js +1 -1
- package/Components/Transitioner/Scene.tsx +4 -3
- package/Components/Transitioner/Transitioner.tsx +69 -30
- package/Components/Transitioner/__tests__/transitioner.test.js +308 -20
- package/package.json +5 -5
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Green (configurable): background, image, nowPlayingAsset, nowPlayingLabel,
|
|
7
7
|
* textLabel1, textLabel2
|
|
8
8
|
* White (hardcoded): container padding/gutters, nowPlaying container layout,
|
|
9
|
-
* button sizes/padding/assets
|
|
9
|
+
* button sizes/padding. Play/pause icons use plugin assets when set.
|
|
10
10
|
*
|
|
11
11
|
* Variants (VARIANT_*) are preset configuration bundles — not passed as a field.
|
|
12
12
|
*/
|
|
@@ -94,6 +94,10 @@ export type ActionButtonAssetsConfiguration = {
|
|
|
94
94
|
removeAsset?: string;
|
|
95
95
|
/** Trailing drag handle on Edit Order / queue rows. */
|
|
96
96
|
dragAsset?: string;
|
|
97
|
+
/** Shown on the Now Playing row while the track is paused. */
|
|
98
|
+
playAsset?: string;
|
|
99
|
+
/** Shown on the Now Playing row while the track is playing. */
|
|
100
|
+
pauseAsset?: string;
|
|
97
101
|
};
|
|
98
102
|
|
|
99
103
|
export type ItemConfiguration = {
|
|
@@ -504,6 +508,23 @@ function ItemButton({
|
|
|
504
508
|
const renderIcon = () => {
|
|
505
509
|
switch (action) {
|
|
506
510
|
case "playPause": {
|
|
511
|
+
const playbackAsset = isSelected
|
|
512
|
+
? actionButtonAssets?.pauseAsset
|
|
513
|
+
: actionButtonAssets?.playAsset;
|
|
514
|
+
|
|
515
|
+
if (playbackAsset) {
|
|
516
|
+
return (
|
|
517
|
+
<Image
|
|
518
|
+
source={{ uri: playbackAsset }}
|
|
519
|
+
style={{
|
|
520
|
+
width: layout.assetWidth,
|
|
521
|
+
height: layout.assetHeight,
|
|
522
|
+
}}
|
|
523
|
+
resizeMode="contain"
|
|
524
|
+
/>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
507
528
|
const iconSize = layout.assetWidth;
|
|
508
529
|
const iconRadius = iconSize / 2;
|
|
509
530
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Pressable } from "react-native";
|
|
2
|
+
import { Image, Pressable } from "react-native";
|
|
3
3
|
import { fireEvent, render, screen } from "@testing-library/react-native";
|
|
4
|
+
import { ModalBlocksStyleContext } from "../../../ModalBlocksStyleContext";
|
|
4
5
|
import {
|
|
5
6
|
AudioPlayerTrackItem,
|
|
7
|
+
VARIANT_NOW_PLAYING,
|
|
6
8
|
VARIANT_PLAYLIST_ITEM,
|
|
7
9
|
mapMenuItemToNowPlayingData,
|
|
8
10
|
} from "../Item";
|
|
@@ -101,6 +103,55 @@ describe("mapMenuItemToNowPlayingData", () => {
|
|
|
101
103
|
expect(data.textLabel2).toBe("Artist Name");
|
|
102
104
|
});
|
|
103
105
|
|
|
106
|
+
it("uses the plugin play asset while paused and the pause asset while playing", () => {
|
|
107
|
+
const menuItem = {
|
|
108
|
+
id: "item_1",
|
|
109
|
+
title: "Track Title",
|
|
110
|
+
summary: "Artist Name",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const style = {
|
|
114
|
+
nowPlayingItem: {
|
|
115
|
+
actionButtons: {
|
|
116
|
+
default: {
|
|
117
|
+
playAsset: "https://example.com/play.png",
|
|
118
|
+
pauseAsset: "https://example.com/pause.png",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const { UNSAFE_getAllByType, rerender } = render(
|
|
125
|
+
<ModalBlocksStyleContext.Provider value={style}>
|
|
126
|
+
<AudioPlayerTrackItem
|
|
127
|
+
configuration={VARIANT_NOW_PLAYING}
|
|
128
|
+
data={mapMenuItemToNowPlayingData(menuItem as any, true)}
|
|
129
|
+
/>
|
|
130
|
+
</ModalBlocksStyleContext.Provider>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
UNSAFE_getAllByType(Image).some(
|
|
135
|
+
(node) => node.props.source?.uri === "https://example.com/play.png"
|
|
136
|
+
)
|
|
137
|
+
).toBe(true);
|
|
138
|
+
|
|
139
|
+
rerender(
|
|
140
|
+
<ModalBlocksStyleContext.Provider value={style}>
|
|
141
|
+
<AudioPlayerTrackItem
|
|
142
|
+
configuration={VARIANT_NOW_PLAYING}
|
|
143
|
+
data={mapMenuItemToNowPlayingData(menuItem as any, false)}
|
|
144
|
+
/>
|
|
145
|
+
</ModalBlocksStyleContext.Provider>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(
|
|
149
|
+
UNSAFE_getAllByType(Image).some(
|
|
150
|
+
(node) => node.props.source?.uri === "https://example.com/pause.png"
|
|
151
|
+
)
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
104
155
|
it("uses custom nowPlayingLabel when provided", () => {
|
|
105
156
|
const data = mapMenuItemToNowPlayingData(
|
|
106
157
|
item as any,
|
|
@@ -131,6 +131,43 @@ describe("mergeItemConfiguration background", () => {
|
|
|
131
131
|
"https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH"
|
|
132
132
|
);
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
it("applies a text label font family and ignores a non-string font family", () => {
|
|
136
|
+
const withFont = mergeItemConfiguration(base as any, {
|
|
137
|
+
textLabel1: { default: { fontFamily: "Roboto-Bold" } },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(withFont.textLabel1.iosFontFamily).toBe("Roboto-Bold");
|
|
141
|
+
expect(withFont.textLabel1.androidFontFamily).toBe("Roboto-Bold");
|
|
142
|
+
|
|
143
|
+
const withEmpty = mergeItemConfiguration(base as any, {
|
|
144
|
+
textLabel1: {
|
|
145
|
+
default: { fontFamily: {}, iosFontFamily: "SFProText-Semibold" },
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(withEmpty.textLabel1.iosFontFamily).toBe("SFProText-Semibold");
|
|
150
|
+
expect(withEmpty.textLabel1.androidFontFamily).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("applies play and pause assets onto actionButtonAssets", () => {
|
|
154
|
+
const merged = mergeItemConfiguration(base as any, {
|
|
155
|
+
actionButtons: {
|
|
156
|
+
default: {
|
|
157
|
+
playAsset: "https://example.com/play.png",
|
|
158
|
+
pauseAsset: "https://example.com/pause.png",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(merged.actionButtonAssets?.playAsset).toBe(
|
|
164
|
+
"https://example.com/play.png"
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(merged.actionButtonAssets?.pauseAsset).toBe(
|
|
168
|
+
"https://example.com/pause.png"
|
|
169
|
+
);
|
|
170
|
+
});
|
|
134
171
|
});
|
|
135
172
|
|
|
136
173
|
describe("mergeButtonConfiguration background", () => {
|
|
@@ -82,7 +82,7 @@ function mergeItemLabel(
|
|
|
82
82
|
...(override.lineHeight !== undefined
|
|
83
83
|
? { lineHeight: override.lineHeight }
|
|
84
84
|
: {}),
|
|
85
|
-
...(override.fontFamily
|
|
85
|
+
...(typeof override.fontFamily === "string" && override.fontFamily
|
|
86
86
|
? {
|
|
87
87
|
iosFontFamily: override.fontFamily,
|
|
88
88
|
androidFontFamily: override.fontFamily,
|
|
@@ -147,6 +147,8 @@ function mergeItemActionButtonAssets(
|
|
|
147
147
|
...base,
|
|
148
148
|
...(override.removeAsset ? { removeAsset: override.removeAsset } : {}),
|
|
149
149
|
...(override.dragAsset ? { dragAsset: override.dragAsset } : {}),
|
|
150
|
+
...(override.playAsset ? { playAsset: override.playAsset } : {}),
|
|
151
|
+
...(override.pauseAsset ? { pauseAsset: override.pauseAsset } : {}),
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
|
|
@@ -115,7 +115,6 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
115
115
|
iconPlacement,
|
|
116
116
|
contentComponent: ContentComponent,
|
|
117
117
|
contentComponentProps = {},
|
|
118
|
-
styleOverrides: propStyleOverrides,
|
|
119
118
|
measurementPhase = false,
|
|
120
119
|
emptyMessage,
|
|
121
120
|
} = props;
|
|
@@ -157,78 +156,10 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
|
|
|
157
156
|
const configuration =
|
|
158
157
|
plugin?.configuration || (plugin as any)?.configuration_json;
|
|
159
158
|
|
|
160
|
-
|
|
161
|
-
configuration
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!propStyleOverrides) {
|
|
166
|
-
return parsed;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// hydrate(QueueStyles) only emits sectionHeader after the plugin class
|
|
170
|
-
// includes it. Until then, Studio/local keys in configuration_json still
|
|
171
|
-
// apply via the parser.
|
|
172
|
-
const hydratedSection = propStyleOverrides.sectionHeader;
|
|
173
|
-
|
|
174
|
-
const hasTitleStyle = (style?: Record<string, any>) =>
|
|
175
|
-
Boolean(
|
|
176
|
-
style &&
|
|
177
|
-
(style.fontColor ||
|
|
178
|
-
style.fontSize !== undefined ||
|
|
179
|
-
style.lineHeight !== undefined ||
|
|
180
|
-
style.fontFamily ||
|
|
181
|
-
style.iosFontFamily ||
|
|
182
|
-
style.androidFontFamily ||
|
|
183
|
-
style.letterSpacing !== undefined ||
|
|
184
|
-
style.iosLetterSpacing !== undefined ||
|
|
185
|
-
style.androidLetterSpacing !== undefined ||
|
|
186
|
-
style.textTransform ||
|
|
187
|
-
style.alignment)
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
const hasActionStyle = (style?: Record<string, any>) =>
|
|
191
|
-
Boolean(
|
|
192
|
-
style &&
|
|
193
|
-
(style.backgroundColor ||
|
|
194
|
-
style.fontColor ||
|
|
195
|
-
style.fontSize !== undefined ||
|
|
196
|
-
style.lineHeight !== undefined ||
|
|
197
|
-
style.fontFamily ||
|
|
198
|
-
style.iosFontFamily ||
|
|
199
|
-
style.androidFontFamily ||
|
|
200
|
-
style.letterSpacing !== undefined ||
|
|
201
|
-
style.iosLetterSpacing !== undefined ||
|
|
202
|
-
style.androidLetterSpacing !== undefined ||
|
|
203
|
-
style.textTransform ||
|
|
204
|
-
style.borderRadius !== undefined ||
|
|
205
|
-
style.cornerRadius !== undefined ||
|
|
206
|
-
style.paddingTop !== undefined ||
|
|
207
|
-
style.paddingRight !== undefined ||
|
|
208
|
-
style.paddingBottom !== undefined ||
|
|
209
|
-
style.paddingLeft !== undefined)
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const actionNode = hydratedSection?.actionButtons;
|
|
213
|
-
|
|
214
|
-
// An empty `default: {}` is truthy and would hide fields set on the flat
|
|
215
|
-
// object or on the focused bucket.
|
|
216
|
-
const hasHydratedSection =
|
|
217
|
-
hasTitleStyle(hydratedSection?.title?.default) ||
|
|
218
|
-
hasTitleStyle(hydratedSection?.title) ||
|
|
219
|
-
hasActionStyle(actionNode?.default) ||
|
|
220
|
-
hasActionStyle(actionNode?.focused) ||
|
|
221
|
-
hasActionStyle(actionNode?.focus) ||
|
|
222
|
-
hasActionStyle(actionNode);
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
...parsed,
|
|
226
|
-
...propStyleOverrides,
|
|
227
|
-
sectionHeader: hasHydratedSection
|
|
228
|
-
? hydratedSection
|
|
229
|
-
: parsed?.sectionHeader,
|
|
230
|
-
};
|
|
231
|
-
}, [propStyleOverrides, plugin, themePluginId]);
|
|
159
|
+
return configuration && themePluginId
|
|
160
|
+
? parseModalBlocksConfiguration(configuration, themePluginId)
|
|
161
|
+
: null;
|
|
162
|
+
}, [plugin, themePluginId]);
|
|
232
163
|
|
|
233
164
|
const pluginLocalizations = useLocalizedStrings({
|
|
234
165
|
localizations:
|
|
@@ -66,7 +66,7 @@ export class AnimationManager {
|
|
|
66
66
|
*
|
|
67
67
|
* @param {object} to contains style to animate and scenes index
|
|
68
68
|
* @param {string} action push|back
|
|
69
|
-
* @param {number} stepsBack number of steps back
|
|
69
|
+
* @param {number} [stepsBack] number of steps back, used by the back action only
|
|
70
70
|
*/
|
|
71
71
|
stateForAction({ to, scenes }, action, stepsBack) {
|
|
72
72
|
let rootIndex = to.index;
|
|
@@ -17,13 +17,14 @@ const fullWidthDimensions = {
|
|
|
17
17
|
type Props = {
|
|
18
18
|
children: any;
|
|
19
19
|
style: any;
|
|
20
|
-
pointerEvents
|
|
21
|
-
overlayStyle
|
|
22
|
-
animating
|
|
20
|
+
pointerEvents?: ViewProps["pointerEvents"];
|
|
21
|
+
overlayStyle?: ViewStyle;
|
|
22
|
+
animating?: boolean;
|
|
23
23
|
contentStyle: ViewStyle;
|
|
24
24
|
pathname: string;
|
|
25
25
|
isActive: boolean;
|
|
26
26
|
screenData: NavigationScreenData;
|
|
27
|
+
screenUniqueId?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
export function CurrentScreenContextProvider({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
|
-
import { append, compose, identity, tail,
|
|
3
|
+
import { append, compose, identity, tail, update } from "ramda";
|
|
4
4
|
|
|
5
5
|
import { AnimationManager } from "./AnimationManager";
|
|
6
6
|
import { Scene } from "./Scene";
|
|
@@ -18,11 +18,23 @@ export const NAV_ACTION_REPLACE = "REPLACE";
|
|
|
18
18
|
|
|
19
19
|
export const NAV_ACTION_BACK = "POP";
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
const RIVER_SEGMENT_REGEX = /river\//g;
|
|
22
|
+
|
|
23
|
+
// Number of "river/" segments in the route, i.e. its depth in the navigation stack
|
|
24
|
+
function countRiverSegments(route?: string): number {
|
|
25
|
+
return (route?.match(RIVER_SEGMENT_REGEX) || []).length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The screen element rendered for the current route
|
|
29
|
+
type SceneChild = React.ReactElement<{
|
|
30
|
+
pathname: string;
|
|
31
|
+
screenData: NavigationScreenData;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
type TransitionerScene = {
|
|
35
|
+
children: SceneChild;
|
|
36
|
+
key: string;
|
|
37
|
+
screenUniqueId: string;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
40
|
/**
|
|
@@ -31,7 +43,7 @@ type SceneProps = {
|
|
|
31
43
|
* @param {Object} children
|
|
32
44
|
* @returns { children: Object, key: string }
|
|
33
45
|
*/
|
|
34
|
-
function prepareScene(children) {
|
|
46
|
+
function prepareScene(children: SceneChild): TransitionerScene {
|
|
35
47
|
return {
|
|
36
48
|
children,
|
|
37
49
|
key: children.props.pathname,
|
|
@@ -45,7 +57,10 @@ function prepareScene(children) {
|
|
|
45
57
|
* @param {any} props Component's props
|
|
46
58
|
* @param {any} state Component's state
|
|
47
59
|
*/
|
|
48
|
-
function scenesForBackTransition(
|
|
60
|
+
function scenesForBackTransition(
|
|
61
|
+
props: Pick<Props, "children">,
|
|
62
|
+
{ scenes }: Pick<State, "scenes">
|
|
63
|
+
): TransitionerScene[] {
|
|
49
64
|
return scenes.length > 1
|
|
50
65
|
? identity(scenes)
|
|
51
66
|
: [prepareScene(props.children), scenes[0]];
|
|
@@ -53,7 +68,8 @@ function scenesForBackTransition(props: SceneProps, { scenes }) {
|
|
|
53
68
|
|
|
54
69
|
type Props = {
|
|
55
70
|
transitionConfig: any;
|
|
56
|
-
|
|
71
|
+
isRTL?: boolean;
|
|
72
|
+
children: SceneChild;
|
|
57
73
|
navigator: {
|
|
58
74
|
previousAction: string;
|
|
59
75
|
currentRoute: string;
|
|
@@ -78,13 +94,12 @@ type Props = {
|
|
|
78
94
|
};
|
|
79
95
|
|
|
80
96
|
type State = {
|
|
81
|
-
animatedValue: any;
|
|
82
97
|
animating: boolean;
|
|
83
98
|
duration: number;
|
|
84
99
|
easing: any;
|
|
85
100
|
from: any;
|
|
86
101
|
to: any;
|
|
87
|
-
scenes:
|
|
102
|
+
scenes: TransitionerScene[];
|
|
88
103
|
};
|
|
89
104
|
|
|
90
105
|
const styles = {
|
|
@@ -92,11 +107,18 @@ const styles = {
|
|
|
92
107
|
} as const;
|
|
93
108
|
|
|
94
109
|
export class TransitionerComponent extends React.PureComponent<Props, State> {
|
|
110
|
+
private animationManager: AnimationManager;
|
|
111
|
+
|
|
112
|
+
private transitionId: number;
|
|
113
|
+
|
|
95
114
|
constructor(props: Props) {
|
|
96
115
|
super(props);
|
|
97
116
|
|
|
98
117
|
this.animationManager = new AnimationManager(props);
|
|
99
118
|
|
|
119
|
+
// Bumped by every animated transition; end callbacks of older ones are ignored
|
|
120
|
+
this.transitionId = 0;
|
|
121
|
+
|
|
100
122
|
this.state = {
|
|
101
123
|
scenes: [prepareScene(props.children)], // starting with index: 0
|
|
102
124
|
...this.animationManager.initialState(),
|
|
@@ -113,28 +135,36 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
|
|
|
113
135
|
* and trigger push animation with callbacks.
|
|
114
136
|
*/
|
|
115
137
|
pushTransitionStart() {
|
|
138
|
+
const transitionId = ++this.transitionId;
|
|
139
|
+
|
|
116
140
|
this.setState(
|
|
117
141
|
{
|
|
118
|
-
|
|
142
|
+
// Append right after the visible scene: stateForAction(PUSH) animates to.index + 1
|
|
143
|
+
scenes: [
|
|
144
|
+
...this.state.scenes.slice(0, this.state.to.index + 1),
|
|
145
|
+
prepareScene(this.props.children),
|
|
146
|
+
],
|
|
119
147
|
...this.animationManager.stateForAction(this.state, NAV_ACTION_PUSH),
|
|
120
148
|
},
|
|
121
149
|
() => {
|
|
122
|
-
this.animationManager.animate(this.state,
|
|
150
|
+
this.animationManager.animate(this.state, () =>
|
|
151
|
+
this.pushTransitionEnd(transitionId)
|
|
152
|
+
);
|
|
123
153
|
}
|
|
124
154
|
);
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
/**
|
|
128
158
|
* Trigger back animation.
|
|
129
|
-
* The
|
|
159
|
+
* The scenes after the target one are removed in the "backTransitionEnd" callback,
|
|
130
160
|
* only after the animation is complete.
|
|
131
161
|
*/
|
|
132
162
|
backTransitionStart(previousRoute: string) {
|
|
163
|
+
const transitionId = ++this.transitionId;
|
|
133
164
|
const currentRoute = this.props.navigator?.currentRoute;
|
|
134
165
|
|
|
135
166
|
const diffLengthRoutes =
|
|
136
|
-
(previousRoute
|
|
137
|
-
(currentRoute?.match(/river\//g) || []).length;
|
|
167
|
+
countRiverSegments(previousRoute) - countRiverSegments(currentRoute);
|
|
138
168
|
|
|
139
169
|
const stepsBack =
|
|
140
170
|
diffLengthRoutes && diffLengthRoutes > 0 ? diffLengthRoutes : 1;
|
|
@@ -150,30 +180,32 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
|
|
|
150
180
|
},
|
|
151
181
|
() => {
|
|
152
182
|
this.animationManager.animate(this.state, () =>
|
|
153
|
-
this.backTransitionEnd(
|
|
183
|
+
this.backTransitionEnd(transitionId)
|
|
154
184
|
);
|
|
155
185
|
}
|
|
156
186
|
);
|
|
157
187
|
}
|
|
158
188
|
|
|
159
|
-
shouldSkipAnimationForRoute(route) {
|
|
189
|
+
shouldSkipAnimationForRoute(route: string) {
|
|
160
190
|
// We are skipping animations for some routes, likes hooks & the player mainly
|
|
161
191
|
|
|
162
192
|
return route.includes("/playable") || route.includes("/hook");
|
|
163
193
|
}
|
|
164
194
|
|
|
165
|
-
shouldResetStateForRoute(route) {
|
|
195
|
+
shouldResetStateForRoute(route: string) {
|
|
166
196
|
// We are resetting the transitioner state for root routes
|
|
167
197
|
|
|
168
|
-
return (route
|
|
198
|
+
return countRiverSegments(route) === 1;
|
|
169
199
|
}
|
|
170
200
|
|
|
171
|
-
transitionWithoutAnimation(resetState) {
|
|
172
|
-
|
|
201
|
+
transitionWithoutAnimation(resetState?: boolean) {
|
|
202
|
+
// Scenes after the visible one are only leaving (back animation in flight),
|
|
203
|
+
// so the new scene replaces the one being backed to
|
|
204
|
+
const scenes = this.state.scenes.slice(0, this.state.to.index + 1);
|
|
173
205
|
const newScene = prepareScene(this.props.children);
|
|
174
206
|
const isLastSceneHook = scenes[scenes.length - 1].key.startsWith("/hook");
|
|
175
207
|
|
|
176
|
-
let newState
|
|
208
|
+
let newState: Pick<State, "scenes"> | Pick<State, "scenes" | "to">;
|
|
177
209
|
|
|
178
210
|
if (resetState) {
|
|
179
211
|
newState = {
|
|
@@ -263,20 +295,27 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
|
|
|
263
295
|
}
|
|
264
296
|
}
|
|
265
297
|
|
|
266
|
-
// Sets state.animating to false
|
|
267
|
-
|
|
298
|
+
// Sets state.animating to false,
|
|
299
|
+
// unless a newer transition has started since this one
|
|
300
|
+
pushTransitionEnd(transitionId: number) {
|
|
301
|
+
if (transitionId !== this.transitionId) return;
|
|
302
|
+
|
|
268
303
|
this.setState({
|
|
269
304
|
animating: false,
|
|
270
305
|
});
|
|
271
306
|
}
|
|
272
307
|
|
|
273
308
|
// Sets state.animating to false
|
|
274
|
-
// and
|
|
275
|
-
|
|
276
|
-
|
|
309
|
+
// and drops every scene after the target one. Reads the latest state,
|
|
310
|
+
// so it's correct however many backs were merged into this animation.
|
|
311
|
+
// Ignored if a newer transition has started since this one
|
|
312
|
+
backTransitionEnd(transitionId: number) {
|
|
313
|
+
if (transitionId !== this.transitionId) return;
|
|
314
|
+
|
|
315
|
+
this.setState(({ scenes, to }) => ({
|
|
277
316
|
animating: false,
|
|
278
|
-
scenes:
|
|
279
|
-
});
|
|
317
|
+
scenes: scenes.slice(0, to.index + 1),
|
|
318
|
+
}));
|
|
280
319
|
}
|
|
281
320
|
|
|
282
321
|
// On initial state, both "to" and "from" point to a single scene
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Text, View } from "react-native";
|
|
2
|
+
import { Animated, Text, View } from "react-native";
|
|
3
|
+
import { v4 as uuidV4 } from "uuid";
|
|
3
4
|
|
|
4
5
|
import { AnimationManager } from "../AnimationManager";
|
|
5
6
|
import {
|
|
6
7
|
NAV_ACTION_BACK,
|
|
7
8
|
NAV_ACTION_PUSH,
|
|
9
|
+
NAV_ACTION_REPLACE,
|
|
8
10
|
TransitionerComponent,
|
|
9
11
|
} from "../Transitioner";
|
|
10
12
|
import { act, render } from "@testing-library/react-native";
|
|
@@ -66,6 +68,26 @@ const sceneFor = (route) => (
|
|
|
66
68
|
</View>
|
|
67
69
|
);
|
|
68
70
|
|
|
71
|
+
const root = "/river/root";
|
|
72
|
+
const child = `${root}/river/child`;
|
|
73
|
+
const grandchild = `${child}/river/grandchild`;
|
|
74
|
+
|
|
75
|
+
const propsFor = (route, previousAction, children = sceneFor(route)) => ({
|
|
76
|
+
navigator: {
|
|
77
|
+
previousAction,
|
|
78
|
+
currentRoute: route,
|
|
79
|
+
screenData: {},
|
|
80
|
+
},
|
|
81
|
+
transitionConfig: sceneTransitionConfig,
|
|
82
|
+
layoutData: {
|
|
83
|
+
isTablet: false,
|
|
84
|
+
isTabletPortrait: false,
|
|
85
|
+
},
|
|
86
|
+
children,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const sceneKeys = (ref) => ref.current.state.scenes.map((scene) => scene.key);
|
|
90
|
+
|
|
69
91
|
describe("<TransitionerComponent />", () => {
|
|
70
92
|
it("renders correctly", () => {
|
|
71
93
|
const wrapper = render(
|
|
@@ -83,26 +105,12 @@ describe("<TransitionerComponent />", () => {
|
|
|
83
105
|
.mockImplementation((_, done) => done());
|
|
84
106
|
|
|
85
107
|
const ref = React.createRef();
|
|
86
|
-
const root = "/river/root";
|
|
87
|
-
const child = `${root}/river/child`;
|
|
88
|
-
const grandchild = `${child}/river/grandchild`;
|
|
89
|
-
|
|
90
|
-
const propsFor = (route, previousAction) => ({
|
|
91
|
-
navigator: {
|
|
92
|
-
previousAction,
|
|
93
|
-
currentRoute: route,
|
|
94
|
-
screenData: {},
|
|
95
|
-
},
|
|
96
|
-
transitionConfig: sceneTransitionConfig,
|
|
97
|
-
layoutData: {
|
|
98
|
-
isTablet: false,
|
|
99
|
-
isTabletPortrait: false,
|
|
100
|
-
},
|
|
101
|
-
children: sceneFor(route),
|
|
102
|
-
});
|
|
103
108
|
|
|
104
109
|
const { rerender } = render(
|
|
105
|
-
<TransitionerComponent
|
|
110
|
+
<TransitionerComponent
|
|
111
|
+
ref={ref}
|
|
112
|
+
{...propsFor(root, NAV_ACTION_REPLACE)}
|
|
113
|
+
/>
|
|
106
114
|
);
|
|
107
115
|
|
|
108
116
|
act(() => {
|
|
@@ -129,6 +137,286 @@ describe("<TransitionerComponent />", () => {
|
|
|
129
137
|
);
|
|
130
138
|
});
|
|
131
139
|
|
|
132
|
-
expect(ref
|
|
140
|
+
expect(sceneKeys(ref)).toEqual([root]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("with interruptible animations", () => {
|
|
144
|
+
// End callback of the animation that is currently "running"
|
|
145
|
+
let runningAnimationEnd = null;
|
|
146
|
+
|
|
147
|
+
const finishAnimation = () => {
|
|
148
|
+
act(() => {
|
|
149
|
+
const done = runningAnimationEnd;
|
|
150
|
+
runningAnimationEnd = null;
|
|
151
|
+
done?.();
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const mountTransitioner = ({
|
|
156
|
+
initialRoute = root,
|
|
157
|
+
renderScene = sceneFor,
|
|
158
|
+
} = {}) => {
|
|
159
|
+
const ref = React.createRef();
|
|
160
|
+
|
|
161
|
+
const transitioner = (route, action) => (
|
|
162
|
+
<TransitionerComponent
|
|
163
|
+
ref={ref}
|
|
164
|
+
{...propsFor(route, action, renderScene(route))}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const { rerender } = render(
|
|
169
|
+
transitioner(initialRoute, NAV_ACTION_REPLACE)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const navigate = (route, action) => {
|
|
173
|
+
act(() => {
|
|
174
|
+
rerender(transitioner(route, action));
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return { ref, navigate };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
let sceneCount = 0;
|
|
183
|
+
uuidV4.mockImplementation(() => `scene-${++sceneCount}`);
|
|
184
|
+
|
|
185
|
+
jest
|
|
186
|
+
.spyOn(AnimationManager.prototype, "animate")
|
|
187
|
+
.mockImplementation((_, done) => {
|
|
188
|
+
runningAnimationEnd = done;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const setValue = Animated.Value.prototype.setValue;
|
|
192
|
+
|
|
193
|
+
// RN 0.77: setValue() stops the running animation, which calls
|
|
194
|
+
// its end callback synchronously with { finished: false }
|
|
195
|
+
jest
|
|
196
|
+
.spyOn(Animated.Value.prototype, "setValue")
|
|
197
|
+
.mockImplementation(function (value) {
|
|
198
|
+
const interrupted = runningAnimationEnd;
|
|
199
|
+
runningAnimationEnd = null;
|
|
200
|
+
interrupted?.();
|
|
201
|
+
|
|
202
|
+
return setValue.call(this, value);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
runningAnimationEnd = null;
|
|
208
|
+
jest.restoreAllMocks();
|
|
209
|
+
uuidV4.mockReset();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("drops interrupted scenes when going back twice during a back animation", () => {
|
|
213
|
+
const { ref, navigate } = mountTransitioner();
|
|
214
|
+
|
|
215
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
216
|
+
finishAnimation();
|
|
217
|
+
navigate(grandchild, NAV_ACTION_PUSH);
|
|
218
|
+
finishAnimation();
|
|
219
|
+
|
|
220
|
+
navigate(child, NAV_ACTION_BACK);
|
|
221
|
+
navigate(root, NAV_ACTION_BACK);
|
|
222
|
+
|
|
223
|
+
expect(ref.current.state.animating).toBe(true);
|
|
224
|
+
|
|
225
|
+
finishAnimation();
|
|
226
|
+
|
|
227
|
+
expect(sceneKeys(ref)).toEqual([root]);
|
|
228
|
+
expect(ref.current.state.animating).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("remounts the screen when revisiting it after a quick double back", () => {
|
|
232
|
+
const mounts = {};
|
|
233
|
+
|
|
234
|
+
const MountProbe = ({ pathname }) => {
|
|
235
|
+
React.useEffect(() => {
|
|
236
|
+
mounts[pathname] = (mounts[pathname] || 0) + 1;
|
|
237
|
+
}, [pathname]);
|
|
238
|
+
|
|
239
|
+
return <Text>{pathname}</Text>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const { ref, navigate } = mountTransitioner({
|
|
243
|
+
renderScene: (route) => <MountProbe pathname={route} />,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
247
|
+
finishAnimation();
|
|
248
|
+
|
|
249
|
+
const originalChildId = ref.current.state.scenes[1].screenUniqueId;
|
|
250
|
+
|
|
251
|
+
navigate(grandchild, NAV_ACTION_PUSH);
|
|
252
|
+
finishAnimation();
|
|
253
|
+
navigate(child, NAV_ACTION_BACK);
|
|
254
|
+
navigate(root, NAV_ACTION_BACK);
|
|
255
|
+
finishAnimation();
|
|
256
|
+
|
|
257
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
258
|
+
finishAnimation();
|
|
259
|
+
|
|
260
|
+
const { scenes, to } = ref.current.state;
|
|
261
|
+
|
|
262
|
+
expect(scenes).toHaveLength(2);
|
|
263
|
+
expect(to.index).toBe(1);
|
|
264
|
+
expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
|
|
265
|
+
expect(mounts[child]).toBe(2);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("keeps a single scene when going back from the first scene", () => {
|
|
269
|
+
const { ref, navigate } = mountTransitioner({ initialRoute: child });
|
|
270
|
+
|
|
271
|
+
navigate(root, NAV_ACTION_BACK);
|
|
272
|
+
finishAnimation();
|
|
273
|
+
|
|
274
|
+
expect(sceneKeys(ref)).toEqual([root]);
|
|
275
|
+
expect(ref.current.state.to.index).toBe(0);
|
|
276
|
+
expect(ref.current.state.animating).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("pushes a fresh scene after a completed single back", () => {
|
|
280
|
+
const { ref, navigate } = mountTransitioner();
|
|
281
|
+
|
|
282
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
283
|
+
finishAnimation();
|
|
284
|
+
|
|
285
|
+
const originalChildId = ref.current.state.scenes[1].screenUniqueId;
|
|
286
|
+
|
|
287
|
+
navigate(root, NAV_ACTION_BACK);
|
|
288
|
+
finishAnimation();
|
|
289
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
290
|
+
finishAnimation();
|
|
291
|
+
|
|
292
|
+
const { scenes, to } = ref.current.state;
|
|
293
|
+
|
|
294
|
+
expect(sceneKeys(ref)).toEqual([root, child]);
|
|
295
|
+
expect(to.index).toBe(1);
|
|
296
|
+
expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("ignores the end of a back animation that a newer back superseded", () => {
|
|
300
|
+
const { ref, navigate } = mountTransitioner();
|
|
301
|
+
|
|
302
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
303
|
+
finishAnimation();
|
|
304
|
+
navigate(grandchild, NAV_ACTION_PUSH);
|
|
305
|
+
finishAnimation();
|
|
306
|
+
|
|
307
|
+
navigate(child, NAV_ACTION_BACK);
|
|
308
|
+
|
|
309
|
+
const supersededEnd = runningAnimationEnd;
|
|
310
|
+
|
|
311
|
+
// Deliver the interrupted animation's end late, after the newer one started
|
|
312
|
+
Animated.Value.prototype.setValue.mockImplementation(() => {});
|
|
313
|
+
|
|
314
|
+
navigate(root, NAV_ACTION_BACK);
|
|
315
|
+
|
|
316
|
+
act(() => {
|
|
317
|
+
supersededEnd();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(ref.current.state.animating).toBe(true);
|
|
321
|
+
expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
|
|
322
|
+
|
|
323
|
+
finishAnimation();
|
|
324
|
+
|
|
325
|
+
expect(sceneKeys(ref)).toEqual([root]);
|
|
326
|
+
expect(ref.current.state.animating).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("drops the pushed scene when going back before its push animation ends", () => {
|
|
330
|
+
const { ref, navigate } = mountTransitioner();
|
|
331
|
+
|
|
332
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
333
|
+
navigate(root, NAV_ACTION_BACK);
|
|
334
|
+
|
|
335
|
+
expect(ref.current.state.animating).toBe(true);
|
|
336
|
+
|
|
337
|
+
finishAnimation();
|
|
338
|
+
|
|
339
|
+
expect(sceneKeys(ref)).toEqual([root]);
|
|
340
|
+
expect(ref.current.state.animating).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("pushes right after the visible scene even when stale scenes are left behind", () => {
|
|
344
|
+
const { ref, navigate } = mountTransitioner();
|
|
345
|
+
|
|
346
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
347
|
+
finishAnimation();
|
|
348
|
+
|
|
349
|
+
// Leftover past to.index, as some other path could leave behind
|
|
350
|
+
act(() => {
|
|
351
|
+
ref.current.setState(({ scenes }) => ({
|
|
352
|
+
scenes: [
|
|
353
|
+
...scenes,
|
|
354
|
+
{
|
|
355
|
+
...scenes[1],
|
|
356
|
+
key: `${root}/river/stale`,
|
|
357
|
+
screenUniqueId: "stale",
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
navigate(grandchild, NAV_ACTION_PUSH);
|
|
364
|
+
finishAnimation();
|
|
365
|
+
|
|
366
|
+
const { scenes, to } = ref.current.state;
|
|
367
|
+
|
|
368
|
+
expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
|
|
369
|
+
expect(scenes[to.index].key).toBe(grandchild);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("shows the replaced screen when going home during a back animation", () => {
|
|
373
|
+
const home = "/river/home";
|
|
374
|
+
const { ref, navigate } = mountTransitioner();
|
|
375
|
+
|
|
376
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
377
|
+
finishAnimation();
|
|
378
|
+
|
|
379
|
+
navigate(root, NAV_ACTION_BACK);
|
|
380
|
+
navigate(home, NAV_ACTION_REPLACE);
|
|
381
|
+
|
|
382
|
+
const { scenes, to } = ref.current.state;
|
|
383
|
+
|
|
384
|
+
expect(scenes[to.index].key).toBe(home);
|
|
385
|
+
|
|
386
|
+
finishAnimation();
|
|
387
|
+
|
|
388
|
+
expect(sceneKeys(ref)).toEqual([home]);
|
|
389
|
+
expect(ref.current.state.animating).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("shows the pushed screen when pushing during a back animation", () => {
|
|
393
|
+
const sibling = `${root}/river/sibling`;
|
|
394
|
+
const { ref, navigate } = mountTransitioner();
|
|
395
|
+
|
|
396
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
397
|
+
finishAnimation();
|
|
398
|
+
|
|
399
|
+
navigate(root, NAV_ACTION_BACK);
|
|
400
|
+
navigate(sibling, NAV_ACTION_PUSH);
|
|
401
|
+
|
|
402
|
+
const { scenes, to } = ref.current.state;
|
|
403
|
+
|
|
404
|
+
expect(scenes[to.index].key).toBe(sibling);
|
|
405
|
+
|
|
406
|
+
finishAnimation();
|
|
407
|
+
|
|
408
|
+
expect(sceneKeys(ref)).not.toContain(child);
|
|
409
|
+
expect(ref.current.state.animating).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("keeps both scenes when pushing during a push animation", () => {
|
|
413
|
+
const { ref, navigate } = mountTransitioner();
|
|
414
|
+
|
|
415
|
+
navigate(child, NAV_ACTION_PUSH);
|
|
416
|
+
navigate(grandchild, NAV_ACTION_PUSH);
|
|
417
|
+
|
|
418
|
+
expect(sceneKeys(ref)).toEqual([child, grandchild]);
|
|
419
|
+
expect(ref.current.state.to.index).toBe(1);
|
|
420
|
+
});
|
|
133
421
|
});
|
|
134
422
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/zapp-react-native-ui-components",
|
|
3
|
-
"version": "16.0.0-rc.
|
|
3
|
+
"version": "16.0.0-rc.109",
|
|
4
4
|
"description": "Applicaster Zapp React Native ui components for the Quick Brick App",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/applicaster/quickbrick#readme",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@applicaster/applicaster-types": "16.0.0-rc.
|
|
32
|
-
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.
|
|
33
|
-
"@applicaster/zapp-react-native-redux": "16.0.0-rc.
|
|
34
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-rc.
|
|
31
|
+
"@applicaster/applicaster-types": "16.0.0-rc.109",
|
|
32
|
+
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.109",
|
|
33
|
+
"@applicaster/zapp-react-native-redux": "16.0.0-rc.109",
|
|
34
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-rc.109",
|
|
35
35
|
"fast-json-stable-stringify": "^2.1.0",
|
|
36
36
|
"promise": "^8.3.0",
|
|
37
37
|
"react-native-sortables": "1.7.1",
|