Files
react-native/packages/rn-tester/js/examples/Modal/ModalPresentation.js
Mathieu Acthernoene 7a6c7a462a feat(android): Edge-to-edge Modal (navigationBarTranslucent prop) (#47254)
Summary:
The future of Android is [edge-to-edge](https://github.com/react-native-community/discussions-and-proposals/discussions/827) and to make the React Native developer experience seamless in this regard, the ecosystem needs to transition from “opaque system bars by default” to “edge-to-edge by default.”

Currently, there's no easy way to have edge-to-edge modals, as they are implemented using `Dialog` instances (a separate `Window`) and only provide a `statusBarTranslucent` prop.

I tried to implement it in [`react-native-edge-to-edge`](https://github.com/zoontek/react-native-edge-to-edge) by listening to the `topShow` `UIManager` event. But if it works well when there's a defined animation, we can see a quick jump when there's none, because there's too much delay before the event, and edge-to-edge cannot be applied quick enough to the dialog window.

### react-native-edge-to-edge implem with animation (no jump)

https://github.com/user-attachments/assets/4933a102-87a5-40e4-98d9-47f8c0817592

### react-native-edge-to-edge implem without animation (jump)

https://github.com/user-attachments/assets/e4675589-08fe-44fe-b9d8-0a6b3552b461

 ---

For this reason, and because listening to event feels a bit hacky, I think it will be better to go for a new prop directly on RN Modal component: `navigationBarTranslucent`

> [!NOTE]
> `navigationBarTranslucent` cannot be used without `statusBarTranslucent`, as setting both enable edge-to-edge, like [AndroidX would do](https://github.com/androidx/androidx/blob/androidx-main/activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt) and it would requires extra (and unecessary, given the direction Android is taking) work to find a way to keep the status bar opaque but the navigation bar transparent that work on Android 6 to 15+

### Additional infos

- Colors used for the buttons navigation bar in the PR are the default Android ones ([light](https://github.com/androidx/androidx/blob/androidx-main/activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt#L37) and [dark](https://github.com/androidx/androidx/blob/androidx-main/activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt#L42))
- Compared to the Google implementation, the light scrim is applied from `O_MR1` to `Q` (and not `O` to `Q`) as the [`android:windowLightNavigationBar`](https://developer.android.com/reference/android/R.attr#windowLightNavigationBar) style attribute is not available on `O` (it can only be applied programmatically on API 26).

## Changelog:

[ANDROID] [ADDED] - Add navigationBarTranslucent prop to Modal component

Pull Request resolved: https://github.com/facebook/react-native/pull/47254

Test Plan:
Run the tester app, toggle `navigationBarTranslucent`:

https://github.com/user-attachments/assets/286d173b-35a5-4951-9105-f9f7562d6764

-----
did some additional testing with RNTester using different justification

|flex-start|flex-end|
|https://pxl.cl/5Rd20|https://pxl.cl/5Rd21|

Reviewed By: javache

Differential Revision: D65103501

Pulled By: alanleedev

fbshipit-source-id: ef6473ecd785976d3e26c77bbc212222ec96c9f2
2024-11-06 21:09:36 -08:00

372 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
/* eslint-disable no-alert */
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
import type {Props as ModalProps} from 'react-native/Libraries/Modal/Modal';
import RNTesterButton from '../../components/RNTesterButton';
import RNTesterText from '../../components/RNTesterText';
import {RNTesterThemeContext} from '../../components/RNTesterTheme';
import RNTOption from '../../components/RNTOption';
import * as React from 'react';
import {useCallback, useContext, useState} from 'react';
import {Modal, Platform, StyleSheet, Switch, Text, View} from 'react-native';
const animationTypes = ['slide', 'none', 'fade'];
const presentationStyles = [
'fullScreen',
'pageSheet',
'formSheet',
'overFullScreen',
];
const supportedOrientations = [
'portrait',
'portrait-upside-down',
'landscape',
'landscape-left',
'landscape-right',
];
const backdropColors = ['red', 'blue', undefined];
function ModalPresentation() {
const onDismiss = useCallback(() => {
alert('onDismiss');
}, []);
const onShow = useCallback(() => {
alert('onShow');
}, []);
const onRequestClose = useCallback(() => {
console.log('onRequestClose');
}, []);
const [props, setProps] = useState<ModalProps>({
animationType: 'none',
transparent: false,
hardwareAccelerated: false,
statusBarTranslucent: false,
navigationBarTranslucent: false,
presentationStyle: Platform.select({
ios: 'fullScreen',
default: undefined,
}),
supportedOrientations: Platform.select({
ios: ['portrait'],
default: undefined,
}),
onDismiss: undefined,
onShow: undefined,
visible: false,
backdropColor: undefined,
});
const presentationStyle = props.presentationStyle;
const hardwareAccelerated = props.hardwareAccelerated;
const statusBarTranslucent = props.statusBarTranslucent;
const navigationBarTranslucent = props.navigationBarTranslucent;
const backdropColor = props.backdropColor;
const backgroundColor = useContext(RNTesterThemeContext).BackgroundColor;
const [currentOrientation, setCurrentOrientation] = useState('unknown');
type OrientationChangeEvent = Parameters<
$NonMaybeType<React.PropsOf<Modal>['onOrientationChange']>,
>[0];
const onOrientationChange = (event: OrientationChangeEvent) =>
setCurrentOrientation(event.nativeEvent.orientation);
const controls = (
<>
<View style={styles.inlineBlock}>
<RNTesterText style={styles.title}>
Status Bar Translucent 🟢
</RNTesterText>
<Switch
value={statusBarTranslucent}
onValueChange={enabled =>
setProps(prev => ({
...prev,
statusBarTranslucent: enabled,
navigationBarTranslucent: false,
}))
}
/>
</View>
<View style={styles.inlineBlock}>
<RNTesterText style={styles.title}>
Navigation Bar Translucent 🟢
</RNTesterText>
<Switch
value={navigationBarTranslucent}
onValueChange={enabled => {
setProps(prev => ({
...prev,
statusBarTranslucent: enabled,
navigationBarTranslucent: enabled,
}));
}}
/>
</View>
<View style={styles.inlineBlock}>
<RNTesterText style={styles.title}>
Hardware Acceleration 🟢
</RNTesterText>
<Switch
value={hardwareAccelerated}
onValueChange={enabled =>
setProps(prev => ({
...prev,
hardwareAccelerated: enabled,
}))
}
/>
</View>
<View style={styles.block}>
<RNTesterText style={styles.title}>Presentation Style </RNTesterText>
<View style={styles.row}>
{presentationStyles.map(type => (
<RNTOption
key={type}
disabled={Platform.OS !== 'ios'}
style={styles.option}
label={type}
multiSelect={true}
onPress={() =>
setProps(prev => {
if (type === 'overFullScreen' && prev.transparent === true) {
return {
...prev,
presentationStyle: type,
transparent: false,
};
}
return {
...prev,
presentationStyle:
type === prev.presentationStyle ? undefined : type,
};
})
}
selected={type === presentationStyle}
/>
))}
</View>
</View>
<View style={styles.block}>
<View style={styles.rowWithSpaceBetween}>
<RNTesterText style={styles.title}>Transparent</RNTesterText>
<Switch
value={props.transparent}
onValueChange={enabled =>
setProps(prev => ({...prev, transparent: enabled}))
}
/>
</View>
{Platform.OS === 'ios' && presentationStyle !== 'overFullScreen' ? (
<RNTesterText style={styles.warning}>
iOS Modal can only be transparent with 'overFullScreen' Presentation
Style
</RNTesterText>
) : null}
</View>
<View style={styles.block}>
<RNTesterText style={styles.title}>
Supported Orientation
</RNTesterText>
<View style={styles.row}>
{supportedOrientations.map(orientation => (
<RNTOption
key={orientation}
disabled={Platform.OS !== 'ios'}
style={styles.option}
label={orientation}
multiSelect={true}
onPress={() =>
setProps(prev => {
if (prev.supportedOrientations?.includes(orientation)) {
return {
...prev,
supportedOrientations: prev.supportedOrientations?.filter(
o => o !== orientation,
),
};
}
return {
...prev,
supportedOrientations: [
...(prev.supportedOrientations ?? []),
orientation,
],
};
})
}
selected={props.supportedOrientations?.includes(orientation)}
/>
))}
</View>
</View>
<View style={styles.block}>
<RNTesterText style={styles.title}>Actions</RNTesterText>
<View style={styles.row}>
<RNTOption
key="onShow"
style={styles.option}
label="onShow"
multiSelect={true}
onPress={() =>
setProps(prev => ({
...prev,
onShow: prev.onShow ? undefined : onShow,
}))
}
selected={!!props.onShow}
/>
<RNTOption
key="onDismiss"
style={styles.option}
label="onDismiss ⚫️"
disabled={Platform.OS !== 'ios'}
onPress={() =>
setProps(prev => ({
...prev,
onDismiss: prev.onDismiss ? undefined : onDismiss,
}))
}
selected={!!props.onDismiss}
/>
</View>
</View>
<View style={styles.block}>
<RNTesterText style={styles.title}>Backdrop Color </RNTesterText>
<View style={styles.row}>
{backdropColors.map(type => (
<RNTOption
key={type ?? 'default'}
style={styles.option}
label={type ?? 'default'}
multiSelect={true}
onPress={() =>
setProps(prev => ({
...prev,
backdropColor: type,
}))
}
selected={type === backdropColor}
/>
))}
</View>
</View>
</>
);
return (
<View>
<RNTesterButton
onPress={() => setProps(prev => ({...prev, visible: true}))}>
Show Modal
</RNTesterButton>
<Modal
{...props}
onRequestClose={onRequestClose}
onOrientationChange={onOrientationChange}>
<View style={styles.modalContainer}>
<View style={[styles.modalInnerContainer, {backgroundColor}]}>
<Text testID="modal_animationType_text">
This modal was presented with animationType: '
{props.animationType}'
</Text>
{Platform.OS === 'ios' ? (
<Text>
It is currently displayed in {currentOrientation} mode.
</Text>
) : null}
<RNTesterButton
onPress={() => setProps(prev => ({...prev, visible: false}))}>
Close
</RNTesterButton>
{controls}
</View>
</View>
</Modal>
<View style={styles.block}>
<RNTesterText style={styles.title}>Animation Type</RNTesterText>
<View style={styles.row}>
{animationTypes.map(type => (
<RNTOption
key={type}
style={styles.option}
label={type}
onPress={() => setProps(prev => ({...prev, animationType: type}))}
selected={type === props.animationType}
/>
))}
</View>
</View>
{controls}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexWrap: 'wrap',
flexDirection: 'row',
},
rowWithSpaceBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
},
block: {
borderColor: 'rgba(0,0,0, 0.1)',
borderBottomWidth: 1,
padding: 6,
},
inlineBlock: {
padding: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderColor: 'rgba(0,0,0, 0.1)',
borderBottomWidth: 1,
},
title: {
margin: 3,
fontWeight: 'bold',
},
option: {
marginRight: 8,
marginTop: 6,
},
modalContainer: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
modalInnerContainer: {
borderRadius: 10,
padding: 10,
},
warning: {
margin: 3,
fontSize: 12,
color: 'red',
},
});
export default ({
title: 'Modal Presentation',
name: 'basic',
description: 'Modals can be presented with or without animation',
render: (): React.Node => <ModalPresentation />,
}: RNTesterModuleExample);