mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-04-24 04:25:34 +08:00
refactor: keep transition states locally and implement animated replace
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -41,6 +41,7 @@ export type NavigationProp<RouteName = string, Params = object> = {
|
||||
setParams(params: Params): void;
|
||||
getParam(): Params;
|
||||
dispatch(action: { type: string }): void;
|
||||
isFirstRouteInParent(): boolean;
|
||||
dangerouslyGetParent(): NavigationProp | undefined;
|
||||
};
|
||||
|
||||
@@ -93,6 +94,7 @@ export type HeaderProps = {
|
||||
export type NavigationStackOptions = HeaderOptions & {
|
||||
title?: string;
|
||||
header?: null | ((props: HeaderProps) => React.ReactNode);
|
||||
animationEnabled?: boolean;
|
||||
gesturesEnabled?: boolean;
|
||||
gestureResponseDistance?: {
|
||||
vertical?: number;
|
||||
|
||||
@@ -14,7 +14,11 @@ export default class Header extends React.PureComponent<HeaderProps> {
|
||||
} = this.props;
|
||||
const { options } = scene.descriptor;
|
||||
const title =
|
||||
options.headerTitle !== undefined ? options.headerTitle : options.title;
|
||||
options.headerTitle !== undefined
|
||||
? options.headerTitle
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: scene.route.routeName;
|
||||
|
||||
let leftLabel;
|
||||
|
||||
@@ -23,9 +27,14 @@ export default class Header extends React.PureComponent<HeaderProps> {
|
||||
if (options.headerBackTitle !== undefined) {
|
||||
leftLabel = options.headerBackTitle;
|
||||
} else if (previous) {
|
||||
const opts = previous.descriptor.options;
|
||||
const o = previous.descriptor.options;
|
||||
|
||||
leftLabel =
|
||||
opts.headerTitle !== undefined ? opts.headerTitle : opts.title;
|
||||
o.headerTitle !== undefined
|
||||
? o.headerTitle
|
||||
: o.title !== undefined
|
||||
? o.title
|
||||
: previous.route.routeName;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,7 +45,6 @@ export default class Header extends React.PureComponent<HeaderProps> {
|
||||
title={title}
|
||||
leftLabel={leftLabel}
|
||||
onGoBack={
|
||||
// TODO: use isFirstRouteInParent
|
||||
previous
|
||||
? () =>
|
||||
navigation.dispatch(StackActions.pop({ key: scene.route.key }))
|
||||
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
layout: Layout;
|
||||
scenes: HeaderScene<Route>[];
|
||||
navigation: NavigationProp;
|
||||
getPreviousRoute: (props: { route: Route }) => Route | undefined;
|
||||
onLayout?: (e: LayoutChangeEvent) => void;
|
||||
styleInterpolator: HeaderStyleInterpolator;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
@@ -31,6 +32,7 @@ export default function HeaderContainer({
|
||||
scenes,
|
||||
layout,
|
||||
navigation,
|
||||
getPreviousRoute,
|
||||
onLayout,
|
||||
styleInterpolator,
|
||||
style,
|
||||
@@ -46,12 +48,26 @@ export default function HeaderContainer({
|
||||
|
||||
const { options } = scene.descriptor;
|
||||
const isFocused = focusedRoute.key === scene.route.key;
|
||||
const previousRoute = getPreviousRoute({ route: scene.route });
|
||||
|
||||
let previous;
|
||||
|
||||
if (previousRoute) {
|
||||
// The previous scene will be shortly before the current scene in the array
|
||||
// So loop back from current index to avoid looping over the full array
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (self[j].route.key === previousRoute.key) {
|
||||
previous = self[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = {
|
||||
mode,
|
||||
layout,
|
||||
scene,
|
||||
previous: self[i - 1],
|
||||
previous,
|
||||
navigation: scene.descriptor.navigation,
|
||||
styleInterpolator,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { View, StyleSheet, LayoutChangeEvent, Dimensions } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
LayoutChangeEvent,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { getDefaultHeaderHeight } from '../Header/HeaderSegment';
|
||||
import HeaderContainer from '../Header/HeaderContainer';
|
||||
@@ -30,6 +36,7 @@ type Props = {
|
||||
onGoBack: (props: { route: Route }) => void;
|
||||
onOpenRoute: (props: { route: Route }) => void;
|
||||
onCloseRoute: (props: { route: Route }) => void;
|
||||
getPreviousRoute: (props: { route: Route }) => Route | undefined;
|
||||
getGesturesEnabled: (props: { route: Route }) => boolean;
|
||||
renderScene: (props: { route: Route }) => React.ReactNode;
|
||||
transparentCard?: boolean;
|
||||
@@ -73,7 +80,12 @@ export default class Stack extends React.Component<Props, State> {
|
||||
(acc, curr) => {
|
||||
acc[curr.key] =
|
||||
state.progress[curr.key] ||
|
||||
new Animated.Value(props.openingRoutes.includes(curr.key) ? 0 : 1);
|
||||
new Animated.Value(
|
||||
props.openingRoutes.includes(curr.key) &&
|
||||
props.descriptors[curr.key].options.animationEnabled !== false
|
||||
? 0
|
||||
: 1
|
||||
);
|
||||
|
||||
return acc;
|
||||
},
|
||||
@@ -168,6 +180,7 @@ export default class Stack extends React.Component<Props, State> {
|
||||
onOpenRoute,
|
||||
onCloseRoute,
|
||||
onGoBack,
|
||||
getPreviousRoute,
|
||||
getGesturesEnabled,
|
||||
renderScene,
|
||||
transparentCard,
|
||||
@@ -212,7 +225,7 @@ export default class Stack extends React.Component<Props, State> {
|
||||
onClose={() => onCloseRoute({ route })}
|
||||
overlayEnabled={cardOverlayEnabled}
|
||||
shadowEnabled={cardShadowEnabled}
|
||||
gesturesEnabled={getGesturesEnabled({ route })}
|
||||
gesturesEnabled={index !== 0 && getGesturesEnabled({ route })}
|
||||
onTransitionStart={({ closing }) => {
|
||||
onTransitionStart &&
|
||||
onTransitionStart(
|
||||
@@ -250,6 +263,7 @@ export default class Stack extends React.Component<Props, State> {
|
||||
layout={layout}
|
||||
scenes={[scenes[index - 1], scenes[index]]}
|
||||
navigation={navigation}
|
||||
getPreviousRoute={getPreviousRoute}
|
||||
styleInterpolator={headerStyleInterpolator}
|
||||
style={styles.header}
|
||||
/>
|
||||
@@ -265,6 +279,7 @@ export default class Stack extends React.Component<Props, State> {
|
||||
layout={layout}
|
||||
scenes={scenes}
|
||||
navigation={navigation}
|
||||
getPreviousRoute={getPreviousRoute}
|
||||
onLayout={this.handleFloatingHeaderLayout}
|
||||
styleInterpolator={headerStyleInterpolator}
|
||||
style={[styles.header, styles.floating]}
|
||||
@@ -282,7 +297,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
header: {
|
||||
// This is needed to show elevation shadow
|
||||
zIndex: 1,
|
||||
zIndex: Platform.OS === 'android' ? 1 : 0,
|
||||
},
|
||||
floating: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
} from '../../types';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type Descriptors = { [key: string]: SceneDescriptor };
|
||||
|
||||
type Props = {
|
||||
navigation: NavigationProp;
|
||||
descriptors: { [key: string]: SceneDescriptor };
|
||||
descriptors: Descriptors;
|
||||
navigationConfig: NavigationConfig;
|
||||
onTransitionStart?: (
|
||||
curr: { index: number },
|
||||
@@ -28,8 +30,17 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
// Local copy of the routes which are actually rendered
|
||||
routes: Route[];
|
||||
descriptors: { [key: string]: SceneDescriptor };
|
||||
// List of routes being opened, we need to animate pushing of these new routes
|
||||
opening: string[];
|
||||
// List of routes being closed, we need to animate popping of thse routes
|
||||
closing: string[];
|
||||
// List of routes being replaced, we need to keep a copy until the new route animates in
|
||||
replacing: 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: Descriptors;
|
||||
};
|
||||
|
||||
class StackView extends React.Component<Props, State> {
|
||||
@@ -37,74 +48,165 @@ class StackView extends React.Component<Props, State> {
|
||||
props: Readonly<Props>,
|
||||
state: Readonly<State>
|
||||
) {
|
||||
// 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
|
||||
|
||||
const { navigation } = props;
|
||||
const { transitions } = navigation.state;
|
||||
|
||||
let routes = navigation.state.routes.slice(0, navigation.state.index + 1);
|
||||
let routes =
|
||||
navigation.state.index < navigation.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
|
||||
navigation.state.routes.slice(0, navigation.state.index + 1)
|
||||
: navigation.state.routes;
|
||||
|
||||
if (transitions.pushing.length) {
|
||||
// If there are multiple routes being pushed/popped, we'll encounter glitches
|
||||
// Only keep one screen animating at a time to avoid this
|
||||
const toFilter = transitions.popping.length
|
||||
? // If there are screens popping, we want to defer pushing of all screens
|
||||
transitions.pushing
|
||||
: transitions.pushing.length > 1
|
||||
? // If there are more than 1 screens pushing, we want to defer pushing all except the first
|
||||
transitions.pushing.slice(1)
|
||||
: undefined;
|
||||
|
||||
if (toFilter) {
|
||||
routes = routes.filter(route => !toFilter.includes(route.key));
|
||||
}
|
||||
}
|
||||
|
||||
if (transitions.popping.length) {
|
||||
// Get indices of routes that were removed so we can preserve their position when transitioning away
|
||||
const indices = state.routes.reduce(
|
||||
(acc, curr, index) => {
|
||||
if (transitions.popping.includes(curr.key)) {
|
||||
acc.push([curr, index]);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as Array<[Route, number]>
|
||||
if (navigation.state.index < navigation.state.routes.length - 1) {
|
||||
console.warn(
|
||||
'StackRouter provided invalid state, index should always be the last route in the stack.'
|
||||
);
|
||||
}
|
||||
|
||||
if (indices.length) {
|
||||
routes = routes.slice();
|
||||
indices.forEach(([route, index]) => {
|
||||
routes.splice(index, 0, route);
|
||||
});
|
||||
if (!routes.length) {
|
||||
throw new Error(`There should always be at least one route.`);
|
||||
}
|
||||
|
||||
// If there was no change in routes, we don't need to compute anything
|
||||
if (routes === state.routes || !state.routes.length) {
|
||||
return {
|
||||
routes,
|
||||
descriptors: props.descriptors,
|
||||
};
|
||||
}
|
||||
|
||||
// Now we need to determine which routes were added and removed
|
||||
let { opening, closing, replacing } = state;
|
||||
|
||||
const previousRoutes = state.routes.filter(
|
||||
route => !closing.includes(route.key) && !replacing.includes(route.key)
|
||||
);
|
||||
|
||||
const previousFocusedRoute = previousRoutes[previousRoutes.length - 1];
|
||||
const nextFocusedRoute = routes[routes.length - 1];
|
||||
|
||||
if (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
|
||||
|
||||
const isAnimationEnabled = (route: Route) =>
|
||||
(props.descriptors[route.key] || state.descriptors[route.key]).options
|
||||
.animationEnabled !== false;
|
||||
|
||||
if (!previousRoutes.find(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) &&
|
||||
!opening.includes(nextFocusedRoute.key)
|
||||
) {
|
||||
// In this case, we need to animate pushing the focused route
|
||||
// We don't care about animating any other addded routes because they won't be visible
|
||||
opening = [...opening, nextFocusedRoute.key];
|
||||
|
||||
closing = closing.filter(key => key !== nextFocusedRoute.key);
|
||||
replacing = replacing.filter(key => key !== nextFocusedRoute.key);
|
||||
|
||||
if (!routes.find(r => r.key === previousFocusedRoute.key)) {
|
||||
// The previous focused route isn't present in state, we treat this as a replace
|
||||
|
||||
replacing = [...replacing, previousFocusedRoute.key];
|
||||
|
||||
opening = opening.filter(key => key !== previousFocusedRoute.key);
|
||||
closing = closing.filter(key => key !== previousFocusedRoute.key);
|
||||
|
||||
// Keep the old route in 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.find(r => r.key === previousFocusedRoute.key)) {
|
||||
// The previously focused route was removed, we treat this as a pop
|
||||
|
||||
if (
|
||||
isAnimationEnabled(previousFocusedRoute) &&
|
||||
!closing.includes(previousFocusedRoute.key)
|
||||
) {
|
||||
// Sometimes a route can be closed before the opening animation finishes
|
||||
// So we also need to remove it from the opening list
|
||||
closing = [...closing, previousFocusedRoute.key];
|
||||
|
||||
opening = opening.filter(key => key !== previousFocusedRoute.key);
|
||||
replacing = replacing.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
|
||||
}
|
||||
}
|
||||
|
||||
const descriptors = routes.reduce(
|
||||
(acc, route) => {
|
||||
acc[route.key] =
|
||||
props.descriptors[route.key] || state.descriptors[route.key];
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Descriptors
|
||||
);
|
||||
|
||||
return {
|
||||
routes,
|
||||
descriptors: { ...state.descriptors, ...props.descriptors },
|
||||
opening,
|
||||
closing,
|
||||
replacing,
|
||||
descriptors,
|
||||
};
|
||||
}
|
||||
|
||||
state: State = {
|
||||
routes: this.props.navigation.state.routes,
|
||||
routes: [],
|
||||
opening: [],
|
||||
closing: [],
|
||||
replacing: [],
|
||||
descriptors: {},
|
||||
};
|
||||
|
||||
private getGesturesEnabled = ({ route }: { route: Route }) => {
|
||||
const { routes } = this.props.navigation.state;
|
||||
|
||||
const isFirst = routes[0].key === route.key;
|
||||
|
||||
if (isFirst) {
|
||||
// The user shouldn't be able to close the first screen
|
||||
return false;
|
||||
}
|
||||
|
||||
const descriptor = this.state.descriptors[route.key];
|
||||
|
||||
return descriptor && descriptor.options.gesturesEnabled !== undefined
|
||||
? descriptor.options.gesturesEnabled
|
||||
: Platform.OS !== 'android';
|
||||
if (descriptor) {
|
||||
const { gesturesEnabled, animationEnabled } = descriptor.options;
|
||||
|
||||
if (animationEnabled === false) {
|
||||
// When animation is disabled, also disable gestures
|
||||
// The gesture to dismiss a route will look weird when not animated
|
||||
return false;
|
||||
}
|
||||
|
||||
return gesturesEnabled !== undefined
|
||||
? gesturesEnabled
|
||||
: Platform.OS !== 'android';
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private getPreviousRoute = ({ route }: { route: Route }) => {
|
||||
const { closing, replacing } = this.state;
|
||||
const routes = this.state.routes.filter(
|
||||
r =>
|
||||
r.key === route.key ||
|
||||
(!closing.includes(r.key) && !replacing.includes(r.key))
|
||||
);
|
||||
const index = routes.findIndex(r => r.key === route.key);
|
||||
|
||||
return routes[index - 1];
|
||||
};
|
||||
|
||||
private renderScene = ({ route }: { route: Route }) => {
|
||||
@@ -121,38 +223,40 @@ class StackView extends React.Component<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
private handleTransitionComplete = () => {
|
||||
// TODO: remove when the new event system lands
|
||||
this.props.navigation.dispatch(StackActions.completeTransition());
|
||||
};
|
||||
|
||||
private handleGoBack = ({ route }: { route: Route }) => {
|
||||
// This event will trigger when a gesture ends
|
||||
// We need to perform the transition before popping the route completely
|
||||
// We need to perform the transition before removing the route completely
|
||||
this.props.navigation.dispatch(StackActions.pop({ key: route.key }));
|
||||
};
|
||||
|
||||
private handleTransitionComplete = ({ route }: { route: Route }) => {
|
||||
// When transition completes, we need to remove the item from the pushing/popping arrays
|
||||
this.props.navigation.dispatch(
|
||||
StackActions.completeTransition({ toChildKey: route.key })
|
||||
);
|
||||
};
|
||||
|
||||
private handleOpenRoute = ({ route }: { route: Route }) => {
|
||||
this.handleTransitionComplete({ route });
|
||||
this.handleTransitionComplete();
|
||||
this.setState(state => ({
|
||||
routes: state.replacing.length
|
||||
? state.routes.filter(r => !state.replacing.includes(r.key))
|
||||
: state.routes,
|
||||
opening: state.opening.filter(key => key !== route.key),
|
||||
replacing: [],
|
||||
}));
|
||||
};
|
||||
|
||||
private handleCloseRoute = ({ route }: { route: Route }) => {
|
||||
// This event will trigger when the transition for closing the route ends
|
||||
this.handleTransitionComplete();
|
||||
|
||||
// This event will trigger when the animation for closing the route ends
|
||||
// In this case, we need to clean up any state tracking the route and pop it immediately
|
||||
|
||||
// @ts-ignore
|
||||
this.setState(state => ({
|
||||
routes: state.routes.filter(r => r.key !== route.key),
|
||||
descriptors: { ...state.descriptors, [route.key]: undefined },
|
||||
opening: state.closing.filter(key => key !== route.key),
|
||||
closing: state.closing.filter(key => key !== route.key),
|
||||
}));
|
||||
|
||||
this.props.navigation.dispatch(
|
||||
StackActions.pop({ key: route.key, immediate: true })
|
||||
);
|
||||
|
||||
this.handleTransitionComplete({ route });
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -166,9 +270,7 @@ class StackView extends React.Component<Props, State> {
|
||||
} = this.props;
|
||||
|
||||
const { mode, ...config } = navigationConfig;
|
||||
const { pushing, popping } = navigation.state.transitions;
|
||||
|
||||
const { routes, descriptors } = this.state;
|
||||
const { routes, descriptors, opening, closing } = this.state;
|
||||
|
||||
const headerMode =
|
||||
mode !== 'modal' && Platform.OS === 'ios' ? 'float' : 'screen';
|
||||
@@ -180,10 +282,11 @@ class StackView extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<Stack
|
||||
getPreviousRoute={this.getPreviousRoute}
|
||||
getGesturesEnabled={this.getGesturesEnabled}
|
||||
routes={routes}
|
||||
openingRoutes={pushing}
|
||||
closingRoutes={popping}
|
||||
openingRoutes={opening}
|
||||
closingRoutes={closing}
|
||||
onGoBack={this.handleGoBack}
|
||||
onOpenRoute={this.handleOpenRoute}
|
||||
onCloseRoute={this.handleCloseRoute}
|
||||
|
||||
Reference in New Issue
Block a user