This commit is contained in:
Satyajit Sahoo
2020-04-24 22:01:12 +02:00
parent f2291d110f
commit 515e652b37
33 changed files with 1378 additions and 0 deletions

View File

@@ -8,6 +8,7 @@
"@react-navigation/routers",
"@react-navigation/compat",
"@react-navigation/stack",
"@react-navigation/web-stack",
"@react-navigation/drawer",
"@react-navigation/bottom-tabs",
"@react-navigation/material-top-tabs",

View File

@@ -0,0 +1,3 @@
export default function WebStack() {
return null;
}

View File

@@ -0,0 +1,142 @@
import * as React from 'react';
import { View, StyleSheet, ScrollView } from 'react-native';
import { Button } from 'react-native-paper';
import { RouteProp, ParamListBase } from '@react-navigation/native';
import {
createWebStackNavigator,
WebStackNavigationProp,
} from '@react-navigation/web-stack';
import Article from '../Shared/Article';
import Albums from '../Shared/Albums';
import NewsFeed from '../Shared/NewsFeed';
type WebStackParams = {
Article: { author: string };
NewsFeed: undefined;
Album: undefined;
};
type WebStackNavigation = WebStackNavigationProp<WebStackParams>;
const ArticleScreen = ({
navigation,
route,
}: {
navigation: WebStackNavigation;
route: RouteProp<WebStackParams, 'Article'>;
}) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.replace('NewsFeed')}
style={styles.button}
>
Replace with feed
</Button>
<Button
mode="outlined"
onPress={() => navigation.pop()}
style={styles.button}
>
Pop screen
</Button>
</View>
<Article author={{ name: route.params.author }} scrollEnabled={false} />
</ScrollView>
);
};
const NewsFeedScreen = ({ navigation }: { navigation: WebStackNavigation }) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.navigate('Album')}
style={styles.button}
>
Navigate to album
</Button>
<Button
mode="outlined"
onPress={() => navigation.goBack()}
style={styles.button}
>
Go back
</Button>
</View>
<NewsFeed scrollEnabled={false} />
</ScrollView>
);
};
const AlbumsScreen = ({ navigation }: { navigation: WebStackNavigation }) => {
return (
<ScrollView>
<View style={styles.buttons}>
<Button
mode="contained"
onPress={() => navigation.push('Article', { author: 'Babel fish' })}
style={styles.button}
>
Push article
</Button>
<Button
mode="outlined"
onPress={() => navigation.pop(2)}
style={styles.button}
>
Pop by 2
</Button>
</View>
<Albums scrollEnabled={false} />
</ScrollView>
);
};
const WebStack = createWebStackNavigator<WebStackParams>();
type Props = Partial<React.ComponentProps<typeof WebStack.Navigator>> & {
navigation: WebStackNavigationProp<ParamListBase>;
};
export default function WebStackScreen({ navigation, ...rest }: Props) {
navigation.setOptions({
headerShown: false,
});
return (
<WebStack.Navigator {...rest}>
<WebStack.Screen
name="Article"
component={ArticleScreen}
options={({ route }) => ({
title: `Article by ${route.params.author}`,
})}
initialParams={{ author: 'Gandalf' }}
/>
<WebStack.Screen
name="NewsFeed"
component={NewsFeedScreen}
options={{ title: 'Feed' }}
/>
<WebStack.Screen
name="Album"
component={AlbumsScreen}
options={{ title: 'Album' }}
/>
</WebStack.Navigator>
);
}
const styles = StyleSheet.create({
buttons: {
flexDirection: 'row',
padding: 8,
},
button: {
margin: 8,
},
});

View File

@@ -44,6 +44,7 @@ import {
import LinkingPrefixes from './LinkingPrefixes';
import SettingsItem from './Shared/SettingsItem';
import WebStack from './Screens/WebStack';
import SimpleStack from './Screens/SimpleStack';
import ModalPresentationStack from './Screens/ModalPresentationStack';
import StackTransparent from './Screens/StackTransparent';
@@ -76,6 +77,11 @@ type RootStackParamList = {
};
const SCREENS = {
...(Platform.OS === 'web'
? {
WebStack: { title: 'Web Stack', component: WebStack },
}
: null),
SimpleStack: { title: 'Simple Stack', component: SimpleStack },
ModalPresentationStack: {
title: 'Modal Presentation Stack',

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 React Navigation Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,5 @@
# `@react-navigation/web-stack`
Stack navigator for React Navigation on Web.
Installation instructions and documentation can be found on the [React Navigation website](https://reactnavigation.org/docs/web-stack-navigator.html).

View File

@@ -0,0 +1,62 @@
{
"name": "@react-navigation/webstack",
"description": "Stack navigator component for Web",
"version": "5.0.0",
"keywords": [
"react",
"react-native",
"react-component",
"react-navigation",
"web",
"stack"
],
"license": "MIT",
"repository": "https://github.com/react-navigation/react-navigation/tree/master/packages/web-stack",
"bugs": {
"url": "https://github.com/react-navigation/react-navigation/issues"
},
"homepage": "https://reactnavigation.org/docs/web-stack-navigator.html",
"main": "lib/commonjs/index.js",
"react-native": "src/index.tsx",
"module": "lib/module/index.js",
"types": "lib/typescript/src/index.d.ts",
"files": [
"src",
"lib"
],
"sideEffects": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"prepare": "bob build",
"clean": "del lib"
},
"devDependencies": {
"@react-native-community/bob": "^0.10.0",
"@react-navigation/native": "^5.1.6",
"@types/react": "^16.9.23",
"del-cli": "^3.0.0",
"react": "~16.9.0",
"react-dom": "~16.9.0",
"typescript": "^3.8.3"
},
"peerDependencies": {
"@react-navigation/native": "^5.0.5",
"react": "*"
},
"@react-native-community/bob": {
"source": "src",
"output": "lib",
"targets": [
"commonjs",
"module",
[
"typescript",
{
"project": "tsconfig.build.json"
}
]
]
}
}

View File

@@ -0,0 +1,23 @@
/**
* Navigators
*/
export { default as createWebStackNavigator } from './navigators/createWebStackNavigator';
/**
* Views
*/
export { default as WebStackView } from './views/Stack/WebStackView';
export { default as Header } from './views/Header/Header';
export { default as HeaderTitle } from './views/Header/HeaderTitle';
export { default as HeaderBackButton } from './views/Header/HeaderBackButton';
/**
* Types
*/
export type {
WebStackNavigationOptions,
WebStackNavigationProp,
WebStackHeaderProps,
WebStackHeaderLeftButtonProps,
WebStackHeaderTitleProps,
} from './types';

View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import {
useNavigationBuilder,
createNavigatorFactory,
DefaultNavigatorOptions,
EventArg,
StackRouter,
StackRouterOptions,
StackNavigationState,
StackActions,
} from '@react-navigation/native';
import WebStackView from '../views/Stack/WebStackView';
import {
WebStackNavigationConfig,
WebStackNavigationOptions,
WebStackNavigationEventMap,
} from '../types';
type Props = DefaultNavigatorOptions<WebStackNavigationOptions> &
StackRouterOptions &
WebStackNavigationConfig;
function StackNavigator({
initialRouteName,
children,
screenOptions,
...rest
}: Props) {
const { state, descriptors, navigation } = useNavigationBuilder<
StackNavigationState,
StackRouterOptions,
WebStackNavigationOptions,
WebStackNavigationEventMap
>(StackRouter, {
initialRouteName,
children,
screenOptions,
});
React.useEffect(
() =>
navigation.addListener &&
navigation.addListener('tabPress', (e) => {
const isFocused = navigation.isFocused();
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
if (
state.index > 0 &&
isFocused &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
// When user taps on already focused tab and we're inside the tab,
// reset the stack to replicate native behaviour
navigation.dispatch({
...StackActions.popToTop(),
target: state.key,
});
}
});
}),
[navigation, state.index, state.key]
);
return (
<WebStackView
{...rest}
state={state}
descriptors={descriptors}
navigation={navigation}
/>
);
}
export default createNavigatorFactory<
StackNavigationState,
WebStackNavigationOptions,
WebStackNavigationEventMap,
typeof StackNavigator
>(StackNavigator);

View File

@@ -0,0 +1,166 @@
import * as React from 'react';
import {
NavigationProp,
ParamListBase,
Descriptor,
RouteProp,
NavigationHelpers,
StackNavigationState,
StackActionHelpers,
} from '@react-navigation/native';
export type WebStackNavigationEventMap = {};
export type WebStackNavigationHelpers = NavigationHelpers<
ParamListBase,
WebStackNavigationEventMap
>;
export type WebStackNavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = string
> = NavigationProp<
ParamList,
RouteName,
StackNavigationState,
WebStackNavigationOptions,
WebStackNavigationEventMap
> &
StackActionHelpers<ParamList>;
export type WebStackHeaderOptions = {
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to scene `title`.
* It receives `allowFontScaling`, `onLayout`, `style` and `children` in the options object as an argument.
* The title string is passed in `children`.
*/
headerTitle?: string | ((props: WebStackHeaderTitleProps) => React.ReactNode);
/**
* How to align the the header title.
* Defaults to `center` on iOS and `left` on Android.
*/
headerTitleAlign?: 'left' | 'center';
/**
* Style object for the title component.
*/
headerTitleStyle?: React.CSSProperties;
/**
* Tint color for the header.
*/
headerTintColor?: string;
/**
* Function which returns a React Element to display on the left side of the header.
* It receives a number of arguments when rendered (`onPress`, `label`, `labelStyle` and more.
*/
headerLeft?: (props: WebStackHeaderLeftButtonProps) => React.ReactNode;
/**
* Function which returns a React Element to display on the right side of the header.
*/
headerRight?: (props: { tintColor?: string }) => React.ReactNode;
/**
* Style object for the header. You can specify a custom background color here, for example.
*/
headerStyle?: React.CSSProperties;
};
export type WebStackHeaderProps = {
/**
* Navigation prop for the header.
*/
route: RouteProp<ParamListBase, string>;
/**
* Navigation prop for the header.
*/
navigation: WebStackNavigationProp<ParamListBase>;
/**
* Descriptors for the header.
*/
descriptor: WebStackDescriptor;
/**
* If header should display back button
*/
canGoBack: boolean;
};
export type WebStackDescriptor = Descriptor<
ParamListBase,
string,
StackNavigationState,
WebStackNavigationOptions
>;
export type WebStackDescriptorMap = {
[key: string]: WebStackDescriptor;
};
export type WebStackNavigationOptions = WebStackHeaderOptions & {
/**
* String that can be displayed in the header as a fallback for `headerTitle`.
*/
title?: string;
/**
* Function that given `HeaderProps` returns a React Element to display as a header.
*/
header?: (props: WebStackHeaderProps) => React.ReactNode;
/**
* Whether to show the header. The header is shown by default unless `headerMode` was set to `none`.
* Setting this to `false` hides the header.
*/
headerShown?: boolean;
/**
* Style object for the card in stack.
* You can provide a custom background color to use instead of the default background here.
*
* You can also specify `{ backgroundColor: 'transparent' }` to make the previous screen visible underneath.
* This is useful to implement things like modal dialogs..
*/
cardStyle?: React.CSSProperties;
/**
* Whether transition animation should be enabled the screen.
* If you set it to `false`, the screen won't animate when pushing or popping. Defaults to `true`.
*/
animationEnabled?: boolean;
/**
* The type of animation to use when this screen replaces another screen. Defaults to `push`.
* When `pop` is used, the `pop` animation is applied to the screen being replaced.
*/
animationTypeForReplace?: 'push' | 'pop';
};
export type WebStackNavigationConfig = {};
export type WebStackHeaderLeftButtonProps = {
/**
* Whether the button is disabled.
*/
disabled?: boolean;
/**
* Callback to call when the button is clicked.
* By default, this triggers `goBack`.
*/
onClick?: () => void;
/**
* Style object for the button
*/
style?: React.CSSProperties;
/**
* Whether it's possible to navigate back in stack.
*/
canGoBack?: boolean;
};
export type WebStackHeaderTitleProps = {
/**
* Tint color for the header.
*/
tintColor?: string;
/**
* Content of the title element. Usually the title string.
*/
children?: string;
/**
* Style object for the title element.
*/
style?: React.CSSProperties;
};

View File

@@ -0,0 +1,17 @@
export default function debounce<T extends (...args: any[]) => void>(
func: T,
duration: number
): T {
let timeout: NodeJS.Timeout | number | undefined;
return function (this: any, ...args) {
if (!timeout) {
// eslint-disable-next-line babel/no-invalid-this
func.apply(this, args);
timeout = setTimeout(() => {
timeout = undefined;
}, duration);
}
} as T;
}

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
import { StackActions } from '@react-navigation/native';
import HeaderSegment from './HeaderSegment';
import HeaderTitle from './HeaderTitle';
import debounce from '../../utils/debounce';
import { WebStackHeaderProps, WebStackHeaderTitleProps } from '../../types';
export default React.memo(function Header({
navigation,
route,
descriptor,
}: WebStackHeaderProps) {
const { options } = descriptor;
const title =
typeof options.headerTitle !== 'function' &&
options.headerTitle !== undefined
? options.headerTitle
: options.title !== undefined
? options.title
: route.name;
const goBack = React.useCallback(
debounce(() => {
if (navigation.isFocused() && navigation.canGoBack()) {
navigation.dispatch({
...StackActions.pop(),
source: route.key,
});
}
}, 50),
[navigation, route.key]
);
return (
<HeaderSegment
{...options}
route={route}
descriptor={descriptor}
title={title}
headerTitle={
typeof options.headerTitle !== 'function'
? (props: WebStackHeaderTitleProps) => <HeaderTitle {...props} />
: options.headerTitle
}
onGoBack={goBack}
/>
);
});

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { useTheme } from '@react-navigation/native';
import { WebStackHeaderLeftButtonProps } from '../../types';
type Props = WebStackHeaderLeftButtonProps;
export default function HeaderBackButton({ disabled, onClick, style }: Props) {
const { colors } = useTheme();
/* TODO: styling */
return (
<button
type="button"
disabled={disabled}
style={{
color: style?.color !== undefined ? style.color : colors.text,
...styles.button,
...style,
}}
onClick={onClick}
>
Back
{/* TODO: SVG image for back button */}
</button>
);
}
const styles = {
button: {},
};

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import {
NavigationContext,
NavigationRouteContext,
Route,
ParamListBase,
RouteProp,
} from '@react-navigation/native';
import Header from './Header';
import { WebStackNavigationProp, WebStackDescriptor } from '../../types';
export type Props = {
scenes: {
route: RouteProp<ParamListBase, string>;
descriptor: WebStackDescriptor;
}[];
getPreviousRoute: (props: {
route: Route<string>;
}) => Route<string> | undefined;
getFocusedRoute: () => Route<string>;
style?: React.CSSProperties;
};
export default function HeaderContainer({
scenes,
getFocusedRoute,
getPreviousRoute,
style,
}: Props) {
const focusedRoute = getFocusedRoute();
return (
<div style={style}>
{scenes.slice(-3).map((scene, i, self) => {
if (i !== self.length - 1 || !scene) {
return null;
}
const { options } = scene.descriptor;
const isFocused = focusedRoute.key === scene.route.key;
const previousRoute = getPreviousRoute({ route: scene.route });
const props = {
canGoBack: !previousRoute,
route: scene.route,
descriptor: scene.descriptor,
navigation: scene.descriptor.navigation as WebStackNavigationProp<
ParamListBase
>,
};
return (
<NavigationContext.Provider
key={scene.route.key}
value={scene.descriptor.navigation}
>
<NavigationRouteContext.Provider value={scene.route}>
<div aria-hidden={isFocused ? true : false}>
{options.headerShown !== false ? (
options.header !== undefined ? (
options.header(props)
) : (
<Header {...props} />
)
) : null}
</div>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
})}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { RouteProp, ParamListBase, useTheme } from '@react-navigation/native';
import HeaderBackButton from './HeaderBackButton';
import {
WebStackHeaderLeftButtonProps,
WebStackHeaderTitleProps,
WebStackHeaderOptions,
WebStackDescriptor,
} from '../../types';
type Props = WebStackHeaderOptions & {
headerTitle: (props: WebStackHeaderTitleProps) => React.ReactNode;
onGoBack?: () => void;
title?: string;
route: RouteProp<ParamListBase, string>;
descriptor: WebStackDescriptor;
};
export default function HeaderSegment(props: Props) {
const {
title: currentTitle,
onGoBack,
headerTitle,
headerLeft: left = onGoBack
? (props: WebStackHeaderLeftButtonProps) => (
<HeaderBackButton {...props} />
)
: undefined,
headerTintColor,
headerRight: right,
headerTitleStyle,
headerStyle,
} = props;
const { colors } = useTheme();
const leftButton = left
? left({
onClick: onGoBack,
canGoBack: Boolean(onGoBack),
})
: null;
return (
<div
style={{
backgroundColor: colors.card,
borderBottomColor: colors.border,
...styles.header,
...headerStyle,
}}
>
{leftButton}
{headerTitle({
children: currentTitle,
style: { marginLeft: 18, marginRight: 16, ...headerTitleStyle },
})}
{right ? right({ tintColor: headerTintColor }) : null}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
header: {
display: 'flex',
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 1,
},
};

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { useTheme } from '@react-navigation/native';
type Props = JSX.IntrinsicElements['h1'];
export default function HeaderTitle({ style, ...rest }: Props) {
const { colors } = useTheme();
return (
<h1
{...rest}
style={{
color: style?.color === undefined ? colors.text : style?.color,
...styles.title,
...style,
}}
/>
);
}
const styles = {
title: {
fontSize: 18,
fontWeight: 500,
},
};

View File

@@ -0,0 +1,91 @@
import * as React from 'react';
import {
NavigationContext,
NavigationRouteContext,
Route,
RouteProp,
ParamListBase,
useTheme,
} from '@react-navigation/native';
import Header from '../Header/Header';
import { WebStackDescriptor, WebStackNavigationProp } from '../../types';
type Props = {
active: boolean;
focused: boolean;
closing: boolean;
route: RouteProp<ParamListBase, string>;
descriptor: WebStackDescriptor;
canGoBack: boolean;
renderScene: (props: { route: Route<string> }) => React.ReactNode;
onOpenRoute: (props: { route: Route<string> }) => void;
onCloseRoute: (props: { route: Route<string> }) => void;
};
function Card({
active,
closing,
focused,
canGoBack,
onCloseRoute,
onOpenRoute,
renderScene,
route,
descriptor,
}: Props) {
const { colors } = useTheme();
const { options, navigation } = descriptor;
const headerProps = {
canGoBack,
route,
descriptor,
navigation: navigation as WebStackNavigationProp<ParamListBase>,
};
return (
<NavigationContext.Provider key={route.key} value={descriptor.navigation}>
<NavigationRouteContext.Provider value={route}>
<div
aria-hidden={focused ? true : false}
style={{
opacity: active ? 1 : 0,
pointerEvents: focused ? 'auto' : 'none',
backgroundColor: colors.background,
...styles.container,
...options.cardStyle,
}}
onTransitionEnd={() => {
if (closing) {
onCloseRoute({ route });
} else {
onOpenRoute({ route });
}
}}
>
{options.headerShown !== false ? (
options.header !== undefined ? (
options.header(headerProps)
) : (
<Header {...headerProps} />
)
) : null}
{renderScene({ route })}
</div>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
}
export default React.memo(Card);
const styles = {
container: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
transitionDuration: '200ms',
},
};

View File

@@ -0,0 +1,121 @@
import * as React from 'react';
import { Route, StackNavigationState } from '@react-navigation/native';
import Card from './Card';
import { WebStackDescriptorMap, WebStackDescriptor } from '../../types';
type Props = {
state: StackNavigationState;
descriptors: WebStackDescriptorMap;
routes: Route<string>[];
openingRouteKeys: string[];
closingRouteKeys: string[];
onOpenRoute: (props: { route: Route<string> }) => void;
onCloseRoute: (props: { route: Route<string> }) => void;
renderScene: (props: { route: Route<string> }) => React.ReactNode;
};
type State = {
routes: Route<string>[];
descriptors: WebStackDescriptorMap;
scenes: { route: Route<string>; descriptor: WebStackDescriptor }[];
};
const FALLBACK_DESCRIPTOR = Object.freeze({ options: {} });
export default class CardStack extends React.Component<Props, State> {
static getDerivedStateFromProps(props: Props, state: State) {
if (
props.routes === state.routes &&
props.descriptors === state.descriptors
) {
return null;
}
return {
routes: props.routes,
scenes: props.routes.map((route, index, self) => {
const previousRoute = self[index - 1];
const nextRoute = self[index + 1];
const oldScene = state.scenes[index];
const descriptor =
props.descriptors[route.key] ||
state.descriptors[route.key] ||
(oldScene ? oldScene.descriptor : FALLBACK_DESCRIPTOR);
const nextDescriptor =
props.descriptors[nextRoute?.key] ||
state.descriptors[nextRoute?.key];
const previousDescriptor =
props.descriptors[previousRoute?.key] ||
state.descriptors[previousRoute?.key];
const scene = {
route,
descriptor,
__memo: [route, descriptor, nextDescriptor, previousDescriptor],
};
if (
oldScene &&
scene.__memo.every((it, i) => {
// @ts-ignore
return oldScene.__memo[i] === it;
})
) {
return oldScene;
}
return scene;
}),
descriptors: props.descriptors,
};
}
state: State = {
routes: [],
scenes: [],
descriptors: this.props.descriptors,
};
render() {
const {
state,
routes,
closingRouteKeys,
onOpenRoute,
onCloseRoute,
renderScene,
} = this.props;
const { scenes } = this.state;
const focusedRoute = state.routes[state.index];
return (
<React.Fragment>
{routes.map((route, index, self) => {
const focused = focusedRoute.key === route.key;
const scene = scenes[index];
return (
<Card
key={route.key}
active={index === self.length - 1}
focused={focused}
closing={closingRouteKeys.includes(route.key)}
route={route as any}
descriptor={scene.descriptor}
canGoBack={scenes.length > index}
renderScene={renderScene}
onOpenRoute={onOpenRoute}
onCloseRoute={onCloseRoute}
/>
);
})}
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,371 @@
import * as React from 'react';
import {
StackActions,
StackNavigationState,
Route,
} from '@react-navigation/native';
import CardStack from './CardStack';
import {
WebStackNavigationHelpers,
WebStackNavigationConfig,
WebStackDescriptorMap,
} from '../../types';
type Props = WebStackNavigationConfig & {
state: StackNavigationState;
navigation: WebStackNavigationHelpers;
descriptors: WebStackDescriptorMap;
};
type State = {
// Local copy of the routes which are actually rendered
routes: Route<string>[];
// Previous routes, to compare whether routes have changed or not
previousRoutes: Route<string>[];
// Previous descriptors, to compare whether descriptors have changed or not
previousDescriptors: WebStackDescriptorMap;
// List of routes being opened, we need to animate pushing of these new routes
openingRouteKeys: string[];
// List of routes being closed, we need to animate popping of these routes
closingRouteKeys: string[];
// List of routes being replaced, we need to keep a copy until the new route animates in
replacingRouteKeys: string[];
// Since the local routes can vary from the routes from props, we need to keep the descriptors for old routes
// Otherwise we won't be able to access the options for routes that were removed
descriptors: WebStackDescriptorMap;
};
/**
* Compare two arrays with primitive values as the content.
* We need to make sure that both values and order match.
*/
const isArrayEqual = (a: any[], b: any[]) =>
a.length === b.length && a.every((it, index) => it === b[index]);
export default class StackView extends React.Component<Props, State> {
static getDerivedStateFromProps(
props: Readonly<Props>,
state: Readonly<State>
) {
// If there was no change in routes, we don't need to compute anything
if (
(props.state.routes === state.previousRoutes ||
isArrayEqual(
props.state.routes.map((r) => r.key),
state.previousRoutes.map((r) => r.key)
)) &&
state.routes.length
) {
let routes = state.routes;
let previousRoutes = state.previousRoutes;
let descriptors = props.descriptors;
let previousDescriptors = state.previousDescriptors;
if (props.descriptors !== state.previousDescriptors) {
descriptors = state.routes.reduce<WebStackDescriptorMap>(
(acc, route) => {
acc[route.key] =
props.descriptors[route.key] || state.descriptors[route.key];
return acc;
},
{}
);
previousDescriptors = props.descriptors;
}
if (props.state.routes !== state.previousRoutes) {
// if any route objects have changed, we should update them
const map = props.state.routes.reduce<Record<string, Route<string>>>(
(acc, route) => {
acc[route.key] = route;
return acc;
},
{}
);
routes = state.routes.map((route) => map[route.key] || route);
previousRoutes = props.state.routes;
}
return {
routes,
previousRoutes,
descriptors,
previousDescriptors,
};
}
// Here we determine which routes were added or removed to animate them
// We keep a copy of the route being removed in local state to be able to animate it
let routes =
props.state.index < props.state.routes.length - 1
? // Remove any extra routes from the state
// The last visible route should be the focused route, i.e. at current index
props.state.routes.slice(0, props.state.index + 1)
: props.state.routes;
// Now we need to determine which routes were added and removed
let {
openingRouteKeys,
closingRouteKeys,
replacingRouteKeys,
previousRoutes,
} = state;
const previousFocusedRoute = previousRoutes[previousRoutes.length - 1] as
| Route<string>
| undefined;
const nextFocusedRoute = routes[routes.length - 1];
const isAnimationEnabled = (key: string) => {
const descriptor = props.descriptors[key] || state.descriptors[key];
return descriptor ? descriptor.options.animationEnabled !== false : true;
};
const getAnimationTypeForReplace = (key: string) => {
const descriptor = props.descriptors[key] || state.descriptors[key];
return descriptor.options.animationTypeForReplace ?? 'push';
};
if (
previousFocusedRoute &&
previousFocusedRoute.key !== nextFocusedRoute.key
) {
// We only need to animate routes if the focused route changed
// Animating previous routes won't be visible coz the focused route is on top of everything
if (!previousRoutes.some((r) => r.key === nextFocusedRoute.key)) {
// A new route has come to the focus, we treat this as a push
// A replace can also trigger this, the animation should look like push
if (
isAnimationEnabled(nextFocusedRoute.key) &&
!openingRouteKeys.includes(nextFocusedRoute.key)
) {
// In this case, we need to animate pushing the focused route
// We don't care about animating any other added routes because they won't be visible
openingRouteKeys = [...openingRouteKeys, nextFocusedRoute.key];
closingRouteKeys = closingRouteKeys.filter(
(key) => key !== nextFocusedRoute.key
);
replacingRouteKeys = replacingRouteKeys.filter(
(key) => key !== nextFocusedRoute.key
);
if (!routes.some((r) => r.key === previousFocusedRoute.key)) {
// The previous focused route isn't present in state, we treat this as a replace
openingRouteKeys = openingRouteKeys.filter(
(key) => key !== previousFocusedRoute.key
);
if (getAnimationTypeForReplace(nextFocusedRoute.key) === 'pop') {
closingRouteKeys = [
...closingRouteKeys,
previousFocusedRoute.key,
];
// By default, new routes have a push animation, so we add it to `openingRouteKeys` before
// But since user configured it to animate the old screen like a pop, we need to add this without animation
// So remove it from `openingRouteKeys` which will remove the animation
openingRouteKeys = openingRouteKeys.filter(
(key) => key !== nextFocusedRoute.key
);
// Keep the route being removed at the end to animate it out
routes = [...routes, previousFocusedRoute];
} else {
replacingRouteKeys = [
...replacingRouteKeys,
previousFocusedRoute.key,
];
closingRouteKeys = closingRouteKeys.filter(
(key) => key !== previousFocusedRoute.key
);
// Keep the old route in the state because it's visible under the new route, and removing it will feel abrupt
// We need to insert it just before the focused one (the route being pushed)
// After the push animation is completed, routes being replaced will be removed completely
routes = routes.slice();
routes.splice(routes.length - 1, 0, previousFocusedRoute);
}
}
}
} else if (!routes.some((r) => r.key === previousFocusedRoute.key)) {
// The previously focused route was removed, we treat this as a pop
if (
isAnimationEnabled(previousFocusedRoute.key) &&
!closingRouteKeys.includes(previousFocusedRoute.key)
) {
closingRouteKeys = [...closingRouteKeys, previousFocusedRoute.key];
// Sometimes a route can be closed before the opening animation finishes
// So we also need to remove it from the opening list
openingRouteKeys = openingRouteKeys.filter(
(key) => key !== previousFocusedRoute.key
);
replacingRouteKeys = replacingRouteKeys.filter(
(key) => key !== previousFocusedRoute.key
);
// Keep a copy of route being removed in the state to be able to animate it
routes = [...routes, previousFocusedRoute];
}
} else {
// Looks like some routes were re-arranged and no focused routes were added/removed
// i.e. the currently focused route already existed and the previously focused route still exists
// We don't know how to animate this
}
} else if (replacingRouteKeys.length || closingRouteKeys.length) {
// Keep the routes we are closing or replacing if animation is enabled for them
routes = routes.slice();
routes.splice(
routes.length - 1,
0,
...state.routes.filter(({ key }) =>
isAnimationEnabled(key)
? replacingRouteKeys.includes(key) || closingRouteKeys.includes(key)
: false
)
);
}
if (!routes.length) {
throw new Error(
'There should always be at least one route in the navigation state.'
);
}
const descriptors = routes.reduce<WebStackDescriptorMap>((acc, route) => {
acc[route.key] =
props.descriptors[route.key] || state.descriptors[route.key];
return acc;
}, {});
return {
routes,
previousRoutes: props.state.routes,
previousDescriptors: props.descriptors,
openingRouteKeys,
closingRouteKeys,
replacingRouteKeys,
descriptors,
};
}
state: State = {
routes: [],
previousRoutes: [],
previousDescriptors: {},
openingRouteKeys: [],
closingRouteKeys: [],
replacingRouteKeys: [],
descriptors: {},
};
private renderScene = ({ route }: { route: Route<string> }) => {
const descriptor =
this.state.descriptors[route.key] || this.props.descriptors[route.key];
if (!descriptor) {
return null;
}
return descriptor.render();
};
private handleOpenRoute = ({ route }: { route: Route<string> }) => {
const { state, navigation } = this.props;
if (
this.state.replacingRouteKeys.every((key) => key !== route.key) &&
state.routeNames.includes(route.name) &&
!state.routes.some((r) => r.key === route.key)
) {
// If route isn't present in current state, assume that a close animation was cancelled
// So we need to add this route back to the state
navigation.navigate(route);
} else {
this.setState((state) => ({
routes: state.replacingRouteKeys.length
? state.routes.filter(
(r) => !state.replacingRouteKeys.includes(r.key)
)
: state.routes,
openingRouteKeys: state.openingRouteKeys.filter(
(key) => key !== route.key
),
closingRouteKeys: state.closingRouteKeys.filter(
(key) => key !== route.key
),
replacingRouteKeys: [],
}));
}
};
private handleCloseRoute = ({ route }: { route: Route<string> }) => {
const { state, navigation } = this.props;
if (state.routes.some((r) => r.key === route.key)) {
// If a route exists in state, trigger a pop
// This will happen in when the route was closed from the card component
// e.g. When the close animation triggered from a gesture ends
navigation.dispatch({
...StackActions.pop(),
source: route.key,
target: state.key,
});
} else {
// We need to clean up any state tracking the route and pop it immediately
this.setState((state) => ({
routes: state.routes.filter((r) => r.key !== route.key),
openingRouteKeys: state.openingRouteKeys.filter(
(key) => key !== route.key
),
closingRouteKeys: state.closingRouteKeys.filter(
(key) => key !== route.key
),
}));
}
};
render() {
const {
state,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
navigation,
...rest
} = this.props;
const {
routes,
descriptors,
openingRouteKeys,
closingRouteKeys,
} = this.state;
return (
<CardStack
routes={routes}
openingRouteKeys={openingRouteKeys}
closingRouteKeys={closingRouteKeys}
onOpenRoute={this.handleOpenRoute}
onCloseRoute={this.handleCloseRoute}
renderScene={this.renderScene}
state={state}
descriptors={descriptors}
{...rest}
/>
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"paths": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig",
"references": [
{ "path": "../core" },
{ "path": "../routers" },
{ "path": "../native" }
],
"compilerOptions": {
"outDir": "./lib/typescript"
}
}