Skip to content

Commit

Permalink
feat: add optional screens per navigator (#8805)
Browse files Browse the repository at this point in the history
Changes done here will work properly with software-mansion/react-native-screens#624 merged and released. The documentation of `screensEnabled` and `activeLimit` props should also be added. It also enabled `Screens` in iOS stack-navigator by default.

New things:
- `detachInactiveScreens` - prop for navigators with `react-native-screens` integration that can be set by user. It controls if the `react-native-screens` are used by the navigator.
- `detachPreviousScreen` - option that tells to keep the previous screen active. It can be set by user, defaults to `true` for normal mode and `false` for the last screen for mode = “modal”.

Co-authored-by: Satyajit Sahoo <satyajit.happy@gmail.com>
  • Loading branch information
WoLewicki and satya164 committed Oct 23, 2020
1 parent 7dc2f58 commit 7196889
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 27 deletions.
6 changes: 6 additions & 0 deletions packages/bottom-tabs/src/types.tsx
Expand Up @@ -170,6 +170,12 @@ export type BottomTabNavigationConfig<T = BottomTabBarOptions> = {
* Options for the tab bar which will be passed as props to the tab bar component.
*/
tabBarOptions?: T;
/**
* Whether inactive screens should be detached from the view hierarchy to save memory.
* Make sure to call `enableScreens` from `react-native-screens` to make it work.
* Defaults to `true`.
*/
detachInactiveScreens?: boolean;
/**
* Style object for the component wrapping the screen content.
*/
Expand Down
8 changes: 7 additions & 1 deletion packages/bottom-tabs/src/views/BottomTabView.tsx
Expand Up @@ -93,6 +93,7 @@ export default class BottomTabView extends React.Component<Props, State> {
descriptors,
navigation,
lazy,
detachInactiveScreens = true,
sceneContainerStyle,
} = this.props;
const { routes } = state;
Expand All @@ -102,7 +103,11 @@ export default class BottomTabView extends React.Component<Props, State> {
<NavigationHelpersContext.Provider value={navigation}>
<SafeAreaProviderCompat>
<View style={styles.container}>
<ScreenContainer style={styles.pages}>
<ScreenContainer
// @ts-ignore
enabled={detachInactiveScreens}
style={styles.pages}
>
{routes.map((route, index) => {
const descriptor = descriptors[route.key];
const { unmountOnBlur } = descriptor.options;
Expand All @@ -122,6 +127,7 @@ export default class BottomTabView extends React.Component<Props, State> {
key={route.key}
style={StyleSheet.absoluteFill}
isVisible={isFocused}
enabled={detachInactiveScreens}
>
<SceneContent
isFocused={isFocused}
Expand Down
21 changes: 18 additions & 3 deletions packages/bottom-tabs/src/views/ResourceSavingScene.tsx
@@ -1,10 +1,16 @@
import * as React from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import { Screen, screensEnabled } from 'react-native-screens';
import {
Screen,
screensEnabled,
// @ts-ignore
shouldUseActivityState,
} from 'react-native-screens';

type Props = {
isVisible: boolean;
children: React.ReactNode;
enabled: boolean;
style?: any;
};

Expand All @@ -16,8 +22,17 @@ export default class ResourceSavingScene extends React.Component<Props> {
if (screensEnabled?.() && Platform.OS !== 'web') {
const { isVisible, ...rest } = this.props;

// @ts-expect-error: stackPresentation is incorrectly marked as required
return <Screen active={isVisible ? 1 : 0} {...rest} />;
if (shouldUseActivityState) {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screen activityState={isVisible ? 2 : 0} {...rest} />
);
} else {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screen active={isVisible ? 1 : 0} {...rest} />
);
}
}

const { isVisible, children, style, ...rest } = this.props;
Expand Down
6 changes: 6 additions & 0 deletions packages/drawer/src/types.tsx
Expand Up @@ -86,6 +86,12 @@ export type DrawerNavigationConfig<T = DrawerContentOptions> = {
* You can pass a custom background color for a drawer or a custom width here.
*/
drawerStyle?: StyleProp<ViewStyle>;
/**
* Whether inactive screens should be detached from the view hierarchy to save memory.
* Make sure to call `enableScreens` from `react-native-screens` to make it work.
* Defaults to `true`.
*/
detachInactiveScreens?: boolean;
};

export type DrawerNavigationOptions = {
Expand Down
5 changes: 4 additions & 1 deletion packages/drawer/src/views/DrawerView.tsx
Expand Up @@ -83,6 +83,7 @@ export default function DrawerView({
gestureHandlerProps,
minSwipeDistance,
sceneContainerStyle,
detachInactiveScreens = true,
}: Props) {
const [loaded, setLoaded] = React.useState([state.routes[state.index].key]);
const dimensions = useWindowDimensions();
Expand Down Expand Up @@ -152,7 +153,8 @@ export default function DrawerView({

const renderContent = () => {
return (
<ScreenContainer style={styles.content}>
// @ts-ignore
<ScreenContainer enabled={detachInactiveScreens} style={styles.content}>
{state.routes.map((route, index) => {
const descriptor = descriptors[route.key];
const { unmountOnBlur } = descriptor.options;
Expand All @@ -172,6 +174,7 @@ export default function DrawerView({
key={route.key}
style={[StyleSheet.absoluteFill, { opacity: isFocused ? 1 : 0 }]}
isVisible={isFocused}
enabled={detachInactiveScreens}
>
{descriptor.render()}
</ResourceSavingScene>
Expand Down
21 changes: 18 additions & 3 deletions packages/drawer/src/views/ResourceSavingScene.tsx
@@ -1,10 +1,16 @@
import * as React from 'react';
import { Platform, StyleSheet, View } from 'react-native';
import { Screen, screensEnabled } from 'react-native-screens';
import {
Screen,
screensEnabled,
// @ts-ignore
shouldUseActivityState,
} from 'react-native-screens';

type Props = {
isVisible: boolean;
children: React.ReactNode;
enabled: boolean;
style?: any;
};

Expand All @@ -16,8 +22,17 @@ export default class ResourceSavingScene extends React.Component<Props> {
if (screensEnabled?.() && Platform.OS !== 'web') {
const { isVisible, ...rest } = this.props;

// @ts-expect-error: stackPresentation is incorrectly marked as required
return <Screen active={isVisible ? 1 : 0} {...rest} />;
if (shouldUseActivityState) {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screen activityState={isVisible ? 2 : 0} {...rest} />
);
} else {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screen active={isVisible ? 1 : 0} {...rest} />
);
}
}

const { isVisible, children, style, ...rest } = this.props;
Expand Down
13 changes: 13 additions & 0 deletions packages/stack/src/types.tsx
Expand Up @@ -344,6 +344,13 @@ export type StackNavigationOptions = StackHeaderOptions &
bottom?: number;
left?: number;
};
/**
* Whether to detach the previous screen from the view hierarchy to save memory.
* Set it to `false` if you need the previous screen to be seen through the active screen.
* Only applicable if `detachInactiveScreens` isn't set to `false`.
* Defaults to `false` for the last screen when mode='modal', otherwise `true`.
*/
detachPreviousScreen?: boolean;
};

export type StackNavigationConfig = {
Expand All @@ -354,6 +361,12 @@ export type StackNavigationConfig = {
* Defaults to `true`.
*/
keyboardHandlingEnabled?: boolean;
/**
* Whether inactive screens should be detached from the view hierarchy to save memory.
* Make sure to call `enableScreens` from `react-native-screens` to make it work.
* Defaults to `true`.
*/
detachInactiveScreens?: boolean;
};

export type StackHeaderLeftButtonProps = {
Expand Down
27 changes: 21 additions & 6 deletions packages/stack/src/views/Screens.tsx
Expand Up @@ -34,15 +34,21 @@ class WebScreen extends React.Component<

const AnimatedWebScreen = Animated.createAnimatedComponent(WebScreen);

// @ts-ignore
export const shouldUseActivityState = Screens?.shouldUseActivityState;

export const MaybeScreenContainer = ({
enabled,
...rest
}: ViewProps & {
enabled: boolean;
children: React.ReactNode;
}) => {
if (enabled && Platform.OS !== 'web' && Screens && Screens.screensEnabled()) {
return <Screens.ScreenContainer {...rest} />;
if (enabled && Platform.OS !== 'web' && Screens?.screensEnabled()) {
return (
// @ts-ignore
<Screens.ScreenContainer enabled={enabled} {...rest} />
);
}

return <View {...rest} />;
Expand All @@ -54,16 +60,25 @@ export const MaybeScreen = ({
...rest
}: ViewProps & {
enabled: boolean;
active: 0 | 1 | Animated.AnimatedInterpolation;
active: 0 | 1 | 2 | Animated.AnimatedInterpolation;
children: React.ReactNode;
}) => {
if (enabled && Platform.OS === 'web') {
return <AnimatedWebScreen active={active} {...rest} />;
}

if (enabled && Screens && Screens.screensEnabled()) {
// @ts-expect-error: stackPresentation is incorrectly marked as required
return <Screens.Screen active={active} {...rest} />;
if (enabled && Screens?.screensEnabled()) {
if (shouldUseActivityState) {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screens.Screen enabled={enabled} activityState={active} {...rest} />
);
} else {
return (
// @ts-expect-error: there was an `active` prop and no `activityState` in older version and stackPresentation was required
<Screens.Screen enabled={enabled} active={active} {...rest} />
);
}
}

return <View {...rest} />;
Expand Down
79 changes: 66 additions & 13 deletions packages/stack/src/views/Stack/CardStack.tsx
Expand Up @@ -13,7 +13,11 @@ import type {
StackNavigationState,
} from '@react-navigation/native';

import { MaybeScreenContainer, MaybeScreen } from '../Screens';
import {
MaybeScreenContainer,
MaybeScreen,
shouldUseActivityState,
} from '../Screens';
import { getDefaultHeaderHeight } from '../Header/HeaderSegment';
import type { Props as HeaderContainerProps } from '../Header/HeaderContainer';
import CardContainer from './CardContainer';
Expand Down Expand Up @@ -67,6 +71,7 @@ type Props = {
onGestureStart?: (props: { route: Route<string> }) => void;
onGestureEnd?: (props: { route: Route<string> }) => void;
onGestureCancel?: (props: { route: Route<string> }) => void;
detachInactiveScreens?: boolean;
};

type State = {
Expand All @@ -80,6 +85,10 @@ type State = {

const EPSILON = 0.01;

const STATE_INACTIVE = 0;
const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
const STATE_ON_TOP = 2;

const FALLBACK_DESCRIPTOR = Object.freeze({ options: {} });

const getHeaderHeights = (
Expand Down Expand Up @@ -388,6 +397,9 @@ export default class CardStack extends React.Component<Props, State> {
onGestureStart,
onGestureEnd,
onGestureCancel,
detachInactiveScreens = Platform.OS === 'ios'
? false // Disable `react-native-screens` on iOS by default since it's buggy
: shouldUseActivityState || mode !== 'modal', // Enable on new versions of screens or for non modals on older versions
} = this.props;

const { scenes, layout, gestures, headerHeights } = this.state;
Expand All @@ -414,9 +426,22 @@ export default class CardStack extends React.Component<Props, State> {
left = insets.left,
} = focusedOptions.safeAreaInsets || {};

// Screens is buggy on iOS and web, so we only enable it on Android
// For modals, usually we want the screen underneath to be visible, so also disable it there
const isScreensEnabled = Platform.OS !== 'ios' && mode !== 'modal';
let activeScreensLimit = 1;

for (let i = scenes.length - 1; i >= 0; i--) {
const {
// By default, we don't want to detach the previous screen of the active one for modals
detachPreviousScreen = mode === 'modal'
? i !== scenes.length - 1
: true,
} = scenes[i].descriptor.options;

if (detachPreviousScreen === false) {
activeScreensLimit++;
} else {
break;
}
}

const isFloatHeaderAbsolute =
headerMode === 'float'
Expand Down Expand Up @@ -471,7 +496,7 @@ export default class CardStack extends React.Component<Props, State> {
<React.Fragment>
{isFloatHeaderAbsolute ? null : floatingHeader}
<MaybeScreenContainer
enabled={isScreensEnabled}
enabled={detachInactiveScreens}
style={styles.container}
onLayout={this.handleLayout}
>
Expand All @@ -480,13 +505,41 @@ export default class CardStack extends React.Component<Props, State> {
const gesture = gestures[route.key];
const scene = scenes[index];

const isScreenActive = scene.progress.next
? scene.progress.next.interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
})
: 1;
// For the screens that shouldn't be active, the value is 0
// For those that should be active, but are not the top screen, the value is 1
// For those on top of the stack and with interaction enabled, the value is 2
// For the old implementation, it stays the same it was
let isScreenActive: Animated.AnimatedInterpolation | 2 | 1 | 0 = 1;

if (shouldUseActivityState) {
if (index < self.length - activeScreensLimit - 1) {
// screen should be inactive because it is too deep in the stack
isScreenActive = STATE_INACTIVE;
} else {
const sceneForActivity = scenes[self.length - 1];
const outputValue =
index === self.length - 1
? STATE_ON_TOP // the screen is on top after the transition
: index >= self.length - activeScreensLimit
? STATE_TRANSITIONING_OR_BELOW_TOP // the screen should stay active after the transition, it is not on top but is in activeLimit
: STATE_INACTIVE; // the screen should be active only during the transition, it is at the edge of activeLimit
isScreenActive = sceneForActivity
? sceneForActivity.progress.current.interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [1, 1, outputValue],
extrapolate: 'clamp',
})
: STATE_TRANSITIONING_OR_BELOW_TOP;
}
} else {
isScreenActive = scene.progress.next
? scene.progress.next.interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
})
: 1;
}

const {
safeAreaInsets,
Expand Down Expand Up @@ -563,7 +616,7 @@ export default class CardStack extends React.Component<Props, State> {
<MaybeScreen
key={route.key}
style={StyleSheet.absoluteFill}
enabled={isScreensEnabled}
enabled={detachInactiveScreens}
active={isScreenActive}
pointerEvents="box-none"
>
Expand Down

0 comments on commit 7196889

Please sign in to comment.