refactor: keep transition states locally and implement animated replace

This commit is contained in:
satyajit.happy
2019-06-08 01:18:48 +02:00
parent 0589275f53
commit 1a14c22a01
6 changed files with 225 additions and 769 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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;

View File

@@ -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 }))

View File

@@ -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,
};

View File

@@ -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',

View File

@@ -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}