Skip to content

Commit

Permalink
feat: add a ScreenStackItem component (#2433)
Browse files Browse the repository at this point in the history
Currently there is a lot of logic to make `Screens` work properly in
native-stack, including:

- Making sure screens is enabled and mark it for native-stack
- Making sure screens knows that it contains a large title
- Rendering a `DebugContainer` for `LogBox`
- Making sure the `DebugContainer` contains appropriate logic and
wrapper
- Rendering a nested stack to be able to show header in modals

This diff consolidates this logic to a new component `ScreenStackItem`.
This makes it possible to move the above logic to the
`react-native-screens` package by moving this component. Moving the
logic will make it easier to fix bugs in screens.

Extracted from
react-navigation/react-navigation@fbc8635

---------

Co-authored-by: Kacper Kafara <[email protected]>
  • Loading branch information
satya164 and kkafar authored Oct 28, 2024
1 parent d33687e commit 437f6ea
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 15 deletions.
63 changes: 49 additions & 14 deletions guides/GUIDE_FOR_LIBRARY_AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,23 @@ When `activityState` is set to `0`, the parent container will detach its views f
</ScreenContainer>
```

When used in `<ScreenStack />` `activityState` can only be increased. The checks are added (in both native sides and JS part) to prevent situation when it's being removed, but still exists in in React Tree or if someones tries to preload already displayed screen.

When used in `<ScreenStack />` `activityState` can only be increased. The checks are added (in both native sides and JS part) to prevent situation when it's being removed, but still exists in in React Tree or if someones tries to preload already displayed screen.

## `<ScreenStack>`

Screen stack component expects one or more `Screen` components as direct children and renders them in a platform-native stack container (for iOS it is `UINavigationController` and for Android inside `Fragment` container). For `Screen` components placed as children of `ScreenStack` the `activityState` property is ignored and instead the screen that corresponds to the last child is rendered as active. All types of updates done to the list of children are acceptable when the top element is exchanged the container will use platform default (unless customized) animation to transition between screens.
Screen stack component expects one or more `ScreenStackItem` components as direct children and renders them in a platform-native stack container (for iOS it is `UINavigationController` and for Android inside `Fragment` container). For `ScreenStackItem` components placed as children of `ScreenStack` the `activityState` property is ignored and instead the screen that corresponds to the last child is rendered as active. All types of updates done to the list of children are acceptable when the top element is exchanged the container will use platform default (unless customized) animation to transition between screens.

## `<ScreenStackItem>`

The `ScreenStackItem` component is a convenience wrapper around `Screen` that's meant to be used as a direct child of `ScreenStack`. It takes care of setting the appropriate props necessary to work with `ScreenStack`, adds functionality such as displaying header in modals, as well as workarounds such as proper handling of `LogBox`. It is recommended to use `ScreenStackItem` instead of `Screen` when working with `ScreenStack`.

Below is the list of additional properties that can be used for `Screen` component:
Below is the list of additional properties that can be used for `ScreenStackItem` component:

### `customAnimationOnSwipe` (iOS only)

Boolean indicating that swipe dismissal should trigger animation provided by `stackAnimation`. Defaults to `false`.

### freezeOnBlur
### `freezeOnBlur`

Whether inactive screens should be suspended from re-rendering.

Expand Down Expand Up @@ -153,7 +156,7 @@ Sets the current screen's available orientations and forces rotation if current

Defaults to `default` on iOS.

### `sheetAllowedDetents`
### `sheetAllowedDetents`

Describes heights where a sheet can rest.
Works only when `presentation` is set to `formSheet`.
Expand All @@ -166,9 +169,9 @@ Please note that the array **must** be sorted in ascending order.

There are also legacy & **deprecated** options available:

* `medium` - corresponds to `[0.5]` detent value, around half of the screen height,
* `large` - corresponds to `[1.0]` detent value, maximum height,
* `all` - corresponds to `[0.5, 1.0]` value, the name is deceiving due to compatibility reasons.
- `medium` - corresponds to `[0.5]` detent value, around half of the screen height,
- `large` - corresponds to `[1.0]` detent value, maximum height,
- `all` - corresponds to `[0.5, 1.0]` value, the name is deceiving due to compatibility reasons.

Defaults to `[1.0]` literal.

Expand Down Expand Up @@ -204,8 +207,8 @@ there won't be a dimming view beneath the sheet.

Additionaly there are following options available:

* `none` - there will be dimming view for all detents levels,
* `largest` - there won't be a dimming view for any detent level.
- `none` - there will be dimming view for all detents levels,
- `largest` - there won't be a dimming view for any detent level.

There also legacy & **deprecated** prop values available: `medium`, `large` (don't confuse with `largest`), `all`, which work in tandem with
corresponding legacy prop values for `sheetAllowedDetents` prop.
Expand Down Expand Up @@ -259,7 +262,7 @@ For Android:

### `statusBarAnimation`

Sets the status bar animation (similar to the `StatusBar` component). Requires enabling (or deleting) `View controller-based status bar appearance` in your Info.plist file. Possible values: `fade`, `none`, `slide`. On Android, this prop considers the transition of changing status bar color (see https://reactnative.dev/docs/statusbar#animated). There will be no animation if `none` provided.
Sets the status bar animation (similar to the `StatusBar` component). Requires enabling (or deleting) `View controller-based status bar appearance` in your Info.plist file. Possible values: `fade`, `none`, `slide`. On Android, this prop considers the transition of changing status bar color (see <https://reactnative.dev/docs/statusbar#animated>). There will be no animation if `none` provided.

Defaults to `fade` on iOS and `none` on Android.

Expand Down Expand Up @@ -378,7 +381,7 @@ function Home() {
}
```

### unstable_sheetFooter (Android only)
### `unstable_sheetFooter` (Android only)

Footer component that can be used alongside form sheet stack presentation style.

Expand All @@ -390,6 +393,38 @@ even removal.

Currently supported on Android only.

### `contentStyle`

Style object that will be applied to the view that wraps the content of the screen.

### `headerConfig`

The `headerConfig` prop is an alternative to `ScreenStackHeaderConfig` component. It is recommended to use `headerConfig` prop instead of `ScreenStackHeaderConfig` so that `ScreenStackItem` can configure the screen appropriately.

It takes an object that can contain the props accepted by `ScreenStackHeaderConfig` component:

```jsx
<ScreenStack>
<ScreenStackItem
headerConfig={{
title: 'First screen',
headerLargeTitle: true,
children: <>
<ScreenStackHeaderRightView>
<Button title="Save" />
</ScreenStackHeaderRightView>,
</>,
}}>
{/* content of the first screen */}
</ScreenStackItem>
<ScreenStackItem
headerConfig={{
title: 'Second screen',
}}>
{/* content of the second screen */}
</ScreenStackItem>
</ScreenStackIte>
```

## `<ScreenStackHeaderConfig>`

Expand Down Expand Up @@ -598,4 +633,4 @@ Please refer to [SampleLifecycleAwareViewManager.java](https://github.com/softwa

## Android hardware back button

In order to properly handle the hardware back button on Android, you should implement the navigation logic concerning it. You can see an example of how it is done in `react-navigation` here: https://github.com/react-navigation/react-navigation/blob/6cba517b74f5fd092db21d5574b558ef2d80897b/packages/native/src/useBackButton.tsx.
In order to properly handle the hardware back button on Android, you should implement the navigation logic concerning it. You can see an example of how it is done in `react-navigation` here: <https://github.com/react-navigation/react-navigation/blob/6cba517b74f5fd092db21d5574b558ef2d80897b/packages/native/src/useBackButton.tsx>.
2 changes: 1 addition & 1 deletion react-navigation
Submodule react-navigation updated 43 files
+6 −0 example/App.tsx
+8 −1 example/__typechecks__/common.check.tsx
+4 −0 example/app.json
+7 −4 example/package.json
+4 −4 example/src/Screens/BottomTabs.tsx
+5 −1 example/src/index.tsx
+3 −3 package.json
+16 −0 packages/bottom-tabs/CHANGELOG.md
+5 −5 packages/bottom-tabs/package.json
+75 −6 packages/bottom-tabs/src/__tests__/index.test.tsx
+8 −0 packages/bottom-tabs/src/types.tsx
+14 −0 packages/bottom-tabs/src/views/BottomTabView.tsx
+4 −0 packages/core/CHANGELOG.md
+1 −1 packages/core/package.json
+10 −0 packages/devtools/CHANGELOG.md
+1 −1 packages/devtools/package.json
+4 −0 packages/devtools/src/index.tsx
+69 −0 packages/devtools/src/useLogger.tsx
+10 −0 packages/drawer/CHANGELOG.md
+5 −5 packages/drawer/package.json
+10 −0 packages/elements/CHANGELOG.md
+3 −3 packages/elements/package.json
+8 −9 packages/elements/src/PlatformPressable.tsx
+107 −0 packages/elements/src/__tests__/PlatformPressable.test.tsx
+10 −0 packages/material-top-tabs/CHANGELOG.md
+5 −5 packages/material-top-tabs/package.json
+18 −0 packages/native-stack/CHANGELOG.md
+5 −5 packages/native-stack/package.json
+46 −153 packages/native-stack/src/views/NativeStackView.native.tsx
+1 −1 packages/native-stack/src/views/NativeStackView.tsx
+121 −0 packages/native-stack/src/views/ScreenStackContent.tsx
+44 −50 packages/native-stack/src/views/useHeaderConfigProps.tsx
+16 −0 packages/native/CHANGELOG.md
+3 −3 packages/native/package.json
+6 −0 packages/react-native-drawer-layout/CHANGELOG.md
+3 −3 packages/react-native-drawer-layout/package.json
+12 −0 packages/react-native-tab-view/CHANGELOG.md
+3 −3 packages/react-native-tab-view/package.json
+11 −0 packages/stack/CHANGELOG.md
+5 −5 packages/stack/package.json
+73 −9 packages/stack/src/__tests__/index.test.tsx
+11 −4 packages/stack/src/views/Stack/CardContainer.tsx
+1,296 −675 yarn.lock
47 changes: 47 additions & 0 deletions src/components/DebugContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as React from 'react';
import { Platform, type ViewProps } from 'react-native';
// @ts-expect-error importing private component
// eslint-disable-next-line import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member
import AppContainer from 'react-native/Libraries/ReactNative/AppContainer';
import ScreenContentWrapper from './ScreenContentWrapper';
import { StackPresentationTypes } from '../types';

type ContainerProps = ViewProps & {
stackPresentation: StackPresentationTypes;
children: React.ReactNode;
};

/**
* This view must *not* be flattened.
* See https://github.com/software-mansion/react-native-screens/pull/1825
* for detailed explanation.
*/
let DebugContainer: React.ComponentType<ContainerProps> = (props) => {
return <ScreenContentWrapper {...props} />;
}

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react/display-name
DebugContainer = (props: ContainerProps) => {
const { stackPresentation, ...rest } = props;

if (
Platform.OS === 'ios' &&
stackPresentation !== 'push' &&
stackPresentation !== 'formSheet'
) {
// This is necessary for LogBox
return (
<AppContainer>
<ScreenContentWrapper {...rest} />
</AppContainer>
);
}

return <ScreenContentWrapper {...rest} />;
};

DebugContainer.displayName = 'DebugContainer';
}

export default DebugContainer;
7 changes: 7 additions & 0 deletions src/components/DebugContainer.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';
import { type ViewProps } from 'react-native';
import ScreenContentWrapper from './ScreenContentWrapper';

export default function DebugContainer(props: ViewProps) {
return <ScreenContentWrapper {...props} />;
}
122 changes: 122 additions & 0 deletions src/components/ScreenStackItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as React from 'react';
import {
Platform,
type StyleProp,
StyleSheet,
type ViewStyle,
View,
} from 'react-native';
import warnOnce from 'warn-once';

import DebugContainer from './DebugContainer';
import { ScreenProps, ScreenStackHeaderConfigProps } from '../types';
import { ScreenStackHeaderConfig } from './ScreenStackHeaderConfig';
import Screen from './Screen';
import ScreenStack from './ScreenStack';

type Props = Omit<
ScreenProps,
'enabled' | 'isNativeStack' | 'hasLargeHeader'
> & {
headerConfig?: ScreenStackHeaderConfigProps;
contentStyle?: StyleProp<ViewStyle>;
};

function ScreenStackItem({
children,
headerConfig,
activityState,
stackPresentation,
contentStyle,
...rest
}: Props, ref: React.ForwardedRef<View>) {
const isHeaderInModal =
Platform.OS === 'android'
? false
: stackPresentation !== 'push' && headerConfig?.hidden === false;

const headerHiddenPreviousRef = React.useRef(headerConfig?.hidden);

React.useEffect(() => {
warnOnce(
Platform.OS !== 'android' &&
stackPresentation !== 'push' &&
headerHiddenPreviousRef.current !== headerConfig?.hidden,
`Dynamically changing header's visibility in modals will result in remounting the screen and losing all local state.`
);

headerHiddenPreviousRef.current = headerConfig?.hidden;
}, [headerConfig?.hidden, stackPresentation]);

const content = (
<>
<DebugContainer
style={[
stackPresentation === 'formSheet'
? Platform.OS === 'ios'
? styles.absolute
: null
: styles.container,
contentStyle,
]}
stackPresentation={stackPresentation ?? 'push'}
>
{children}
</DebugContainer>
{/**
* `HeaderConfig` needs to be the direct child of `Screen` without any intermediate `View`
* We don't render it conditionally based on visibility to make it possible to dynamically render a custom `header`
* Otherwise dynamically rendering a custom `header` leaves the native header visible
*
* https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screenstackheaderconfig
*
* HeaderConfig must not be first child of a Screen.
* See https://github.com/software-mansion/react-native-screens/pull/1825
* for detailed explanation.
*/}
<ScreenStackHeaderConfig {...headerConfig} />
</>
);

return (
<Screen
ref={ref}
enabled
isNativeStack
activityState={activityState}
stackPresentation={stackPresentation}
hasLargeHeader={headerConfig?.largeTitle ?? false}
{...rest}
>
{isHeaderInModal ? (
<ScreenStack style={styles.container}>
<Screen
enabled
isNativeStack
activityState={activityState}
hasLargeHeader={headerConfig?.largeTitle ?? false}
style={StyleSheet.absoluteFill}
>
{content}
</Screen>
</ScreenStack>
) : (
content
)}
</Screen>
);
}

export default React.forwardRef(ScreenStackItem);

const styles = StyleSheet.create({
container: {
flex: 1,
},
absolute: {
position: 'absolute',
top: 0,
start: 0,
end: 0,
},
});
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
export { default as SearchBar } from './components/SearchBar';
export { default as ScreenContainer } from './components/ScreenContainer';
export { default as ScreenStack } from './components/ScreenStack';
export { default as ScreenStackItem } from './components/ScreenStackItem';
export { default as FullWindowOverlay } from './components/FullWindowOverlay';
export { default as ScreenFooter } from './components/ScreenFooter';
export { default as ScreenContentWrapper } from './components/ScreenContentWrapper';
Expand Down

0 comments on commit 437f6ea

Please sign in to comment.