@wordpress/components
Version:
UI components for WordPress.
647 lines (582 loc) • 17.7 kB
JavaScript
/**
* External dependencies
*/
import {
Dimensions,
Keyboard,
LayoutAnimation,
PanResponder,
Platform,
ScrollView,
StatusBar,
Text,
TouchableHighlight,
View,
} from 'react-native';
import Modal from 'react-native-modal';
import SafeArea from 'react-native-safe-area';
/**
* WordPress dependencies
*/
import {
subscribeAndroidModalClosed,
showAndroidSoftKeyboard,
} from '@wordpress/react-native-bridge';
import { Component } from '@wordpress/element';
import { withPreferredColorScheme } from '@wordpress/compose';
/**
* Internal dependencies
*/
import styles from './styles.scss';
import Button from './button';
import Cell from './cell';
import CyclePickerCell from './cycle-picker-cell';
import PickerCell from './picker-cell';
import SwitchCell from './switch-cell';
import RangeCell from './range-cell';
import ColorCell from './color-cell';
import LinkCell from './link-cell';
import LinkSuggestionItemCell from './link-suggestion-item-cell';
import RadioCell from './radio-cell';
import NavigationScreen from './bottom-sheet-navigation/navigation-screen';
import NavigationContainer from './bottom-sheet-navigation/navigation-container';
import KeyboardAvoidingView from './keyboard-avoiding-view';
import BottomSheetSubSheet from './sub-sheet';
import NavBar from './nav-bar';
import { BottomSheetProvider } from './bottom-sheet-context';
const DEFAULT_LAYOUT_ANIMATION = LayoutAnimation.Presets.easeInEaseOut;
class BottomSheet extends Component {
constructor() {
super( ...arguments );
this.onSafeAreaInsetsUpdate = this.onSafeAreaInsetsUpdate.bind( this );
this.onScroll = this.onScroll.bind( this );
this.isScrolling = this.isScrolling.bind( this );
this.onShouldEnableScroll = this.onShouldEnableScroll.bind( this );
this.onDismiss = this.onDismiss.bind( this );
this.onShouldSetBottomSheetMaxHeight =
this.onShouldSetBottomSheetMaxHeight.bind( this );
this.setIsFullScreen = this.setIsFullScreen.bind( this );
this.onDimensionsChange = this.onDimensionsChange.bind( this );
this.onHeaderLayout = this.onHeaderLayout.bind( this );
this.onCloseBottomSheet = this.onCloseBottomSheet.bind( this );
this.onHandleClosingBottomSheet =
this.onHandleClosingBottomSheet.bind( this );
this.onHardwareButtonPress = this.onHardwareButtonPress.bind( this );
this.onHandleHardwareButtonPress =
this.onHandleHardwareButtonPress.bind( this );
this.keyboardShow = this.keyboardShow.bind( this );
this.keyboardHide = this.keyboardHide.bind( this );
this.headerHeight = 0;
this.keyboardHeight = 0;
this.lastLayoutAnimation = null;
this.lastLayoutAnimationFinished = false;
this.state = {
safeAreaBottomInset: 0,
safeAreaTopInset: 0,
bounces: false,
maxHeight: 0,
scrollEnabled: true,
isScrolling: false,
handleClosingBottomSheet: null,
handleHardwareButtonPress: null,
isMaxHeightSet: true,
isFullScreen: this.props.isFullScreen || false,
};
}
keyboardShow( e ) {
if ( ! this.props.isVisible ) {
return;
}
const { height } = e.endCoordinates;
this.keyboardHeight = height;
this.performKeyboardLayoutAnimation( e );
this.onSetMaxHeight();
this.props.onKeyboardShow?.();
}
keyboardHide( e ) {
if ( ! this.props.isVisible ) {
return;
}
this.keyboardHeight = 0;
this.performKeyboardLayoutAnimation( e );
this.onSetMaxHeight();
this.props.onKeyboardHide?.();
}
performKeyboardLayoutAnimation( event ) {
const { duration, easing } = event;
if ( duration && easing ) {
// This layout animation is the same as the React Native's KeyboardAvoidingView component.
// Reference: https://github.com/facebook/react-native/blob/266b21baf35e052ff28120f79c06c4f6dddc51a9/Libraries/Components/Keyboard/KeyboardAvoidingView.js#L119-L128.
const animationConfig = {
// We have to pass the duration equal to minimal accepted duration defined here: RCTLayoutAnimation.m.
duration: duration > 10 ? duration : 10,
type: LayoutAnimation.Types[ easing ] || 'keyboard',
};
const layoutAnimation = {
duration: animationConfig.duration,
update: animationConfig,
create: {
...animationConfig,
property: LayoutAnimation.Properties.opacity,
},
delete: {
...animationConfig,
property: LayoutAnimation.Properties.opacity,
},
};
this.lastLayoutAnimationFinished = false;
LayoutAnimation.configureNext( layoutAnimation, () => {
this.lastLayoutAnimationFinished = true;
} );
this.lastLayoutAnimation = layoutAnimation;
} else {
// TODO: Reinstate animations, possibly replacing `LayoutAnimation` with
// more nuanced `Animated` usage or replacing our custom `BottomSheet`
// with `@gorhom/bottom-sheet`. This animation was disabled to avoid a
// preexisting bug: https://github.com/WordPress/gutenberg/issues/30562
// this.performRegularLayoutAnimation( {
// useLastLayoutAnimation: false,
// } );.
}
}
performRegularLayoutAnimation( { useLastLayoutAnimation } ) {
// On Android, we should prevent triggering multiple layout animations at the same time because it can produce visual glitches.
if (
Platform.OS === 'android' &&
this.lastLayoutAnimation &&
! this.lastLayoutAnimationFinished
) {
return;
}
const layoutAnimation = useLastLayoutAnimation
? this.lastLayoutAnimation || DEFAULT_LAYOUT_ANIMATION
: DEFAULT_LAYOUT_ANIMATION;
this.lastLayoutAnimationFinished = false;
LayoutAnimation.configureNext( layoutAnimation, () => {
this.lastLayoutAnimationFinished = true;
} );
this.lastLayoutAnimation = layoutAnimation;
}
componentDidMount() {
SafeArea.getSafeAreaInsetsForRootView().then(
this.onSafeAreaInsetsUpdate
);
if ( Platform.OS === 'android' ) {
this.androidModalClosedSubscription = subscribeAndroidModalClosed(
() => {
this.props.onClose();
}
);
}
this.dimensionsChangeSubscription = Dimensions.addEventListener(
'change',
this.onDimensionsChange
);
// 'Will' keyboard events are not available on Android.
// Reference: https://reactnative.dev/docs/0.61/keyboard#addlistener.
this.keyboardShowListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
this.keyboardShow
);
this.keyboardHideListener = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
this.keyboardHide
);
this.safeAreaEventSubscription = SafeArea.addEventListener(
'safeAreaInsetsForRootViewDidChange',
this.onSafeAreaInsetsUpdate
);
this.onSetMaxHeight();
}
componentWillUnmount() {
this.dimensionsChangeSubscription.remove();
this.keyboardShowListener.remove();
this.keyboardHideListener.remove();
if ( this.androidModalClosedSubscription ) {
this.androidModalClosedSubscription.remove();
}
if ( this.props.isVisible ) {
showAndroidSoftKeyboard();
}
if ( this.safeAreaEventSubscription === null ) {
return;
}
this.safeAreaEventSubscription.remove();
this.safeAreaEventSubscription = null;
}
onSafeAreaInsetsUpdate( result ) {
const { safeAreaBottomInset, safeAreaTopInset } = this.state;
if ( this.safeAreaEventSubscription === null ) {
return;
}
const { safeAreaInsets } = result;
if (
safeAreaBottomInset !== safeAreaInsets.bottom ||
safeAreaTopInset !== safeAreaInsets.top
) {
this.setState( {
safeAreaBottomInset: safeAreaInsets.bottom,
safeAreaTopInset: safeAreaInsets.top,
} );
}
}
onSetMaxHeight() {
const { height, width } = Dimensions.get( 'window' );
const { safeAreaBottomInset } = this.state;
const statusBarHeight =
Platform.OS === 'android' ? StatusBar.currentHeight : 0;
// `maxHeight` when modal is opened along with a keyboard.
const maxHeightWithOpenKeyboard =
0.95 *
( Dimensions.get( 'window' ).height -
this.keyboardHeight -
statusBarHeight -
this.headerHeight );
// In landscape orientation, set `maxHeight` to ~96% of the height.
if ( width > height ) {
this.setState( {
maxHeight: Math.min(
0.96 * height - this.headerHeight,
maxHeightWithOpenKeyboard
),
} );
// In portrait orientation, set `maxHeight` to ~59% of the height.
} else {
this.setState( {
maxHeight: Math.min(
height * 0.59 - safeAreaBottomInset - this.headerHeight,
maxHeightWithOpenKeyboard
),
} );
}
}
onDimensionsChange() {
this.onSetMaxHeight();
this.setState( { bounces: false } );
}
onHeaderLayout( { nativeEvent } ) {
const { height } = nativeEvent.layout;
// The layout animation should only be triggered if the header
// height has changed after being mounted.
if (
this.headerHeight !== 0 &&
Math.round( height ) !== Math.round( this.headerHeight )
) {
this.performRegularLayoutAnimation( {
useLastLayoutAnimation: true,
} );
}
this.headerHeight = height;
this.onSetMaxHeight();
}
isCloseToBottom( { layoutMeasurement, contentOffset, contentSize } ) {
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - contentOffset.y
);
}
isCloseToTop( { contentOffset } ) {
return contentOffset.y < 10;
}
onScroll( { nativeEvent } ) {
if ( this.isCloseToTop( nativeEvent ) ) {
this.setState( { bounces: false } );
} else {
this.setState( { bounces: true } );
}
}
onDismiss() {
const { onDismiss } = this.props;
// Restore Keyboard Visibility
showAndroidSoftKeyboard();
if ( onDismiss ) {
onDismiss();
}
this.onCloseBottomSheet();
}
onShouldEnableScroll( value ) {
this.setState( { scrollEnabled: value } );
}
onShouldSetBottomSheetMaxHeight( value ) {
this.setState( { isMaxHeightSet: value } );
}
isScrolling( value ) {
this.setState( { isScrolling: value } );
}
onHandleClosingBottomSheet( action ) {
this.setState( { handleClosingBottomSheet: action } );
}
onHandleHardwareButtonPress( action ) {
this.setState( { handleHardwareButtonPress: action } );
}
onCloseBottomSheet() {
const { onClose } = this.props;
const { handleClosingBottomSheet } = this.state;
if ( handleClosingBottomSheet ) {
handleClosingBottomSheet();
this.onHandleClosingBottomSheet( null );
}
if ( onClose ) {
onClose();
}
this.onShouldSetBottomSheetMaxHeight( true );
}
setIsFullScreen( isFullScreen ) {
if ( isFullScreen !== this.state.isFullScreen ) {
if ( isFullScreen ) {
this.setState( { isFullScreen, isMaxHeightSet: false } );
} else {
this.setState( { isFullScreen, isMaxHeightSet: true } );
}
}
}
onHardwareButtonPress() {
const { onClose } = this.props;
const { handleHardwareButtonPress } = this.state;
if ( handleHardwareButtonPress && handleHardwareButtonPress() ) {
return;
}
if ( onClose ) {
return onClose();
}
}
getContentStyle() {
const { safeAreaBottomInset } = this.state;
return {
paddingBottom:
( safeAreaBottomInset || 0 ) +
styles.scrollableContent.paddingBottom,
};
}
render() {
const {
title = '',
isVisible,
leftButton,
rightButton,
header,
hideHeader,
style = {},
contentStyle = {},
getStylesFromColorScheme,
children,
withHeaderSeparator = false,
hasNavigation,
onDismiss,
...rest
} = this.props;
const {
maxHeight,
bounces,
safeAreaBottomInset,
safeAreaTopInset,
isScrolling,
scrollEnabled,
isMaxHeightSet,
isFullScreen,
} = this.state;
const panResponder = PanResponder.create( {
onMoveShouldSetPanResponder: ( evt, gestureState ) => {
// 'swiping-to-close' option is temporarily and partially disabled
// on Android ( swipe / drag is still available in the top most area - near drag indicator).
if ( Platform.OS === 'ios' ) {
// Activates swipe down over child Touchables if the swipe is long enough.
// With this we can adjust sensibility on the swipe vs tap gestures.
if ( gestureState.dy > 3 && ! bounces ) {
gestureState.dy = 0;
return true;
}
}
return false;
},
} );
const backgroundStyle = getStylesFromColorScheme(
styles.background,
styles.backgroundDark
);
const bottomSheetHeaderTitleStyle = getStylesFromColorScheme(
styles.bottomSheetHeaderTitle,
styles.bottomSheetHeaderTitleDark
);
let listStyle = {};
if ( isFullScreen ) {
listStyle = { flexGrow: 1, flexShrink: 1 };
} else if ( isMaxHeightSet ) {
listStyle = { maxHeight };
// Allow setting a "static" height of the bottom sheet
// by setting the min height to the max height.
if ( this.props.setMinHeightToMaxHeight ) {
listStyle.minHeight = maxHeight;
}
}
const listProps = {
disableScrollViewPanResponder: true,
bounces,
onScroll: this.onScroll,
onScrollBeginDrag: this.onScrollBeginDrag,
onScrollEndDrag: this.onScrollEndDrag,
scrollEventThrottle: 16,
contentContainerStyle: [
styles.content,
hideHeader && styles.emptyHeader,
contentStyle,
isFullScreen && { flexGrow: 1 },
],
style: listStyle,
safeAreaBottomInset,
scrollEnabled,
automaticallyAdjustContentInsets: false,
};
const WrapperView = hasNavigation ? View : ScrollView;
const getHeader = () => (
<>
{ header || (
<View style={ styles.bottomSheetHeader }>
<View style={ styles.flex }>{ leftButton }</View>
<Text
style={ bottomSheetHeaderTitleStyle }
maxFontSizeMultiplier={ 3 }
>
{ title }
</Text>
<View style={ styles.flex }>{ rightButton }</View>
</View>
) }
{ withHeaderSeparator && <View style={ styles.separator } /> }
</>
);
const showDragIndicator = () => {
// If iOS or not fullscreen show the drag indicator.
if ( Platform.OS === 'ios' || ! this.state.isFullScreen ) {
return true;
}
// Otherwise check the allowDragIndicator.
return this.props.allowDragIndicator;
};
return (
<Modal
isVisible={ isVisible }
style={ styles.bottomModal }
animationInTiming={ 400 }
animationOutTiming={ 300 }
backdropTransitionInTiming={ 50 }
backdropTransitionOutTiming={ 50 }
backdropOpacity={ 0.2 }
onBackdropPress={ this.onCloseBottomSheet }
onBackButtonPress={ this.onHardwareButtonPress }
onSwipeComplete={ this.onCloseBottomSheet }
onDismiss={ Platform.OS === 'ios' ? this.onDismiss : undefined }
onModalHide={
Platform.OS === 'android' ? this.onDismiss : undefined
}
swipeDirection="down"
onMoveShouldSetResponder={
scrollEnabled &&
panResponder.panHandlers.onMoveShouldSetResponder
}
onMoveShouldSetResponderCapture={
scrollEnabled &&
panResponder.panHandlers.onMoveShouldSetResponderCapture
}
onAccessibilityEscape={ this.onCloseBottomSheet }
testID="bottom-sheet"
hardwareAccelerated
useNativeDriverForBackdrop
{ ...rest }
>
<KeyboardAvoidingView
behavior={ Platform.OS === 'ios' && 'padding' }
style={ {
...backgroundStyle,
borderColor: 'rgba(0, 0, 0, 0.1)',
marginTop:
Platform.OS === 'ios' && isFullScreen
? safeAreaTopInset
: 0,
flex: isFullScreen ? 1 : undefined,
...( Platform.OS === 'android' && isFullScreen
? styles.backgroundFullScreen
: {} ),
...style,
} }
keyboardVerticalOffset={ -safeAreaBottomInset }
>
<View
style={ styles.header }
onLayout={ this.onHeaderLayout }
testID={ `${ rest.testID || 'bottom-sheet' }-header` }
>
{ showDragIndicator() && (
<View style={ styles.dragIndicator } />
) }
{ ! hideHeader && getHeader() }
</View>
<WrapperView
{ ...( hasNavigation
? { style: listProps.style }
: listProps ) }
>
<BottomSheetProvider
value={ {
shouldEnableBottomSheetScroll:
this.onShouldEnableScroll,
shouldEnableBottomSheetMaxHeight:
this.onShouldSetBottomSheetMaxHeight,
isBottomSheetContentScrolling: isScrolling,
onHandleClosingBottomSheet:
this.onHandleClosingBottomSheet,
onHandleHardwareButtonPress:
this.onHandleHardwareButtonPress,
listProps,
setIsFullScreen: this.setIsFullScreen,
safeAreaBottomInset,
maxHeight,
isMaxHeightSet,
} }
>
{ hasNavigation ? (
<>{ children }</>
) : (
<TouchableHighlight accessible={ false }>
<>{ children }</>
</TouchableHighlight>
) }
</BottomSheetProvider>
{ ! hasNavigation && (
<View
style={ {
height:
safeAreaBottomInset ||
styles.scrollableContent.paddingBottom,
} }
/>
) }
</WrapperView>
</KeyboardAvoidingView>
</Modal>
);
}
}
function getWidth() {
return Math.min(
Dimensions.get( 'window' ).width,
styles.background.maxWidth
);
}
const ThemedBottomSheet = withPreferredColorScheme( BottomSheet );
ThemedBottomSheet.getWidth = getWidth;
ThemedBottomSheet.Button = Button;
ThemedBottomSheet.Cell = Cell;
ThemedBottomSheet.SubSheet = BottomSheetSubSheet;
ThemedBottomSheet.NavBar = NavBar;
ThemedBottomSheet.CyclePickerCell = CyclePickerCell;
ThemedBottomSheet.PickerCell = PickerCell;
ThemedBottomSheet.SwitchCell = SwitchCell;
ThemedBottomSheet.RangeCell = RangeCell;
ThemedBottomSheet.ColorCell = ColorCell;
ThemedBottomSheet.LinkCell = LinkCell;
ThemedBottomSheet.LinkSuggestionItemCell = LinkSuggestionItemCell;
ThemedBottomSheet.RadioCell = RadioCell;
ThemedBottomSheet.NavigationScreen = NavigationScreen;
ThemedBottomSheet.NavigationContainer = NavigationContainer;
export default ThemedBottomSheet;