[RN] Refactor SimpleBottomSheet

Make it more generic by accepting any content except of just rows with text and
icons.

In addition, rework its structure so the animation is smoother, by putting the
background overlay outside of the Modal. This way, the animation doesn't affect
the background, which won't slide down.
This commit is contained in:
Saúl Ibarra Corretgé 2018-05-07 22:55:17 +02:00
parent 8f5ec20da8
commit 4fdd71d1bd
7 changed files with 236 additions and 276 deletions

View File

@ -0,0 +1,84 @@
// @flow
import React, { Component, type Node } from 'react';
import { Modal, TouchableWithoutFeedback, View } from 'react-native';
import { bottomSheetStyles as styles } from './styles';
/**
* The type of {@code BottomSheet}'s React {@code Component} prop types.
*/
type Props = {
/**
* The children to be displayed within this component.
*/
children: Node,
/**
* Handler for the cancel event, which happens when the user dismisses
* the sheet.
*/
onCancel: ?Function
};
/**
* A component emulating Android's BottomSheet. For all intents and purposes,
* this component has been designed to work and behave as a {@code Dialog}.
*/
export default class BottomSheet extends Component<Props> {
/**
* Initializes a new {@code BottomSheet} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onCancel = this._onCancel.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return [
<View
key = 'overlay'
style = { styles.overlay } />,
<Modal
animationType = { 'slide' }
key = 'modal'
onRequestClose = { this._onCancel }
transparent = { true }
visible = { true }>
<View style = { styles.container }>
<TouchableWithoutFeedback
onPress = { this._onCancel } >
<View style = { styles.backdrop } />
</TouchableWithoutFeedback>
<View style = { styles.sheet }>
{ this.props.children }
</View>
</View>
</Modal>
];
}
_onCancel: () => void;
/**
* Cancels the dialog by calling the onCancel prop callback.
*
* @private
* @returns {void}
*/
_onCancel() {
const { onCancel } = this.props;
onCancel && onCancel();
}
}

View File

@ -1,206 +0,0 @@
// @flow
import React, { Component } from 'react';
import {
Modal,
Text,
TouchableHighlight,
TouchableWithoutFeedback,
View
} from 'react-native';
import { connect } from 'react-redux';
import { Icon } from '../../font-icons';
import { simpleBottomSheet as styles } from './styles';
/**
* Underlay color for the buttons on the sheet.
*
* @type {string}
*/
const BUTTON_UNDERLAY_COLOR = '#eee';
type Option = {
/**
* Name of the icon which will be rendered on the right.
*/
iconName: string,
/**
* True if the element is selected (will be highlighted in blue),
* false otherwise.
*/
selected: boolean,
/**
* Text which will be rendered in the row.
*/
text: string
};
/**
* The type of {@code SimpleBottomSheet}'s React {@code Component} prop types.
*/
type Props = {
/**
* Handler for the cancel event, which happens when the user dismisses
* the sheet.
*/
onCancel: Function,
/**
* Handler for the event when an option has been selected in the sheet.
*/
onSubmit: Function,
/**
* Array of options which will be rendered as rows.
*/
options: Array<Option>
};
/**
* A component emulating Android's BottomSheet, in a simplified form.
* It supports text options with an icon, which the user can tap. The style has
* been implemented following the Material Design guidelines for bottom
* sheets: https://material.io/guidelines/components/bottom-sheets.html
*
* For all intents and purposes, this component has been designed to work and
* behave as a {@code Dialog}.
*/
class SimpleBottomSheet extends Component<Props> {
/**
* Initializes a new {@code SimpleBottomSheet} instance.
*
* @param {Object} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props) {
super(props);
this._onButtonPress = this._onButtonPress.bind(this);
this._onCancel = this._onCancel.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Modal
animationType = { 'slide' }
onRequestClose = { this._onCancel }
transparent = { true }
visible = { true }>
<View style = { styles.container }>
<TouchableWithoutFeedback
onPress = { this._onCancel } >
<View style = { styles.overlay } />
</TouchableWithoutFeedback>
<View style = { styles.sheet }>
<View style = { styles.rowsWrapper }>
{ this._renderOptions() }
</View>
</View>
</View>
</Modal>
);
}
_onButtonPress: (?Object) => void;
/**
* Handle pressing of one of the options. The sheet will be hidden and the
* onSubmit prop will be called with the selected option.
*
* @param {Object} option - The option which the user selected.
* @private
* @returns {void}
*/
_onButtonPress(option) {
const { onSubmit } = this.props;
onSubmit && onSubmit(option);
}
_onCancel: () => void;
/**
* Cancels the dialog by calling the onCancel prop callback.
*
* @private
* @returns {void}
*/
_onCancel() {
const { onCancel } = this.props;
onCancel && onCancel();
}
/**
* Renders sheet rows based on the options prop.
*
* @private
* @returns {Array} - Array of rows to be rendered in the sheet.
*/
_renderOptions() {
return this.props.options.map(
(option, index) => this._renderRow(option, index));
}
/**
* Renders a single row of the sheet.
*
* @param {Object} option - Single option which needs to be rendered.
* @param {int} index - Option index, used as a key for React.
* @private
* @returns {ReactElement} - A row element with an icon and text.
*/
_renderRow(option, index) {
const { iconName, selected, text } = option;
const selectedStyle = selected ? styles.rowSelectedText : {};
return (
<TouchableHighlight
key = { index }
// TODO The following disables an eslint error alerting about a
// known potential/theoretical performance pernalty:
//
// A bind call or arrow function in a JSX prop will create a
// brand new function on every single render. This is bad for
// performance, as it will result in the garbage collector being
// invoked way more than is necessary. It may also cause
// unnecessary re-renders if a brand new function is passed as a
// prop to a component that uses reference equality check on the
// prop to determine if it should update.
//
// I'm not addressing the potential/theoretical performance
// penalty at the time of this writing because it doesn't seem
// to me that it's a practical performance penalty in the case.
//
// eslint-disable-next-line react/jsx-no-bind
onPress = { this._onButtonPress.bind(this, option) }
underlayColor = { BUTTON_UNDERLAY_COLOR } >
<View style = { styles.row } >
<Icon
name = { iconName }
style = { [ styles.rowIcon, selectedStyle ] } />
<View style = { styles.rowPadding } />
<Text style = { [ styles.rowText, selectedStyle ] } >
{ text }
</Text>
</View>
</TouchableHighlight>
);
}
}
export default connect()(SimpleBottomSheet);

View File

@ -1,4 +1,4 @@
export { default as BottomSheet } from './BottomSheet';
export { default as DialogContainer } from './DialogContainer'; export { default as DialogContainer } from './DialogContainer';
export { default as Dialog } from './Dialog'; export { default as Dialog } from './Dialog';
export { default as SimpleBottomSheet } from './SimpleBottomSheet';
export { default as StatelessDialog } from './StatelessDialog'; export { default as StatelessDialog } from './StatelessDialog';

View File

@ -21,21 +21,36 @@ export const dialog = createStyleSheet({
}); });
/** /**
* The React {@code Component} styles of {@code SimpleBottomSheet}. These have * The React {@code Component} styles of {@code BottomSheet}. These have
* been implemented as per the Material Design guidelines: * been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}. * {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/ */
export const simpleBottomSheet = createStyleSheet({ export const bottomSheetStyles = createStyleSheet({
/**
* Style for a backdrop which dims the view in the background. This view
* will also be clickable. The backgroundColor is applied to the overlay
* view instead, so the modal animation doesn't affect the backdrop.
*/
backdrop: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
/** /**
* Style for the container of the sheet. * Style for the container of the sheet.
*/ */
container: { container: {
alignItems: 'flex-end',
flex: 1, flex: 1,
flexDirection: 'row' flexDirection: 'row',
justifyContent: 'center'
}, },
/** /**
* Style for a backdrop overlay covering the screen while the * Style for an overlay on top of which the sheet will be displayed.
*/ */
overlay: { overlay: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
@ -46,57 +61,13 @@ export const simpleBottomSheet = createStyleSheet({
top: 0 top: 0
}, },
/**
* Base style for each row.
*/
row: {
alignItems: 'center',
flexDirection: 'row',
height: 48
},
/**
* Style for the {@code Icon} element in a row.
*/
rowIcon: {
fontSize: 24
},
/**
* Helper for adding some padding between the icon and text in a row.
*/
rowPadding: {
width: 32
},
/**
* Style for a row which is marked as selected.
*/
rowSelectedText: {
color: ColorPalette.blue
},
/**
* Style for the {@code Text} element in a row.
*/
rowText: {
fontSize: 16
},
/**
* Wrapper for all rows, it adds a margin to the sheet container.
*/
rowsWrapper: {
marginHorizontal: 16,
marginVertical: 8
},
/** /**
* Bottom sheet's base style. * Bottom sheet's base style.
*/ */
sheet: { sheet: {
alignSelf: 'flex-end', flex: 1,
backgroundColor: ColorPalette.white, backgroundColor: ColorPalette.white,
flex: 1 paddingHorizontal: 16,
paddingVertical: 8
} }
}); });

View File

@ -2,15 +2,45 @@
import _ from 'lodash'; import _ from 'lodash';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { NativeModules } from 'react-native'; import { NativeModules, Text, TouchableHighlight, View } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { hideDialog, SimpleBottomSheet } from '../../../base/dialog'; import { hideDialog, BottomSheet } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Icon } from '../../../base/font-icons';
import styles, { UNDERLAY_COLOR } from './styles';
/** /**
* {@code PasswordRequiredPrompt}'s React {@code Component} prop types. * Type definition for a single entry in the device list.
*/
type Device = {
/**
* Name of the icon which will be rendered on the right.
*/
iconName: string,
/**
* True if the element is selected (will be highlighted in blue),
* false otherwise.
*/
selected: boolean,
/**
* Text which will be rendered in the row.
*/
text: string,
/**
* Device type.
*/
type: string
};
/**
* {@code AudioRoutePickerDialog}'s React {@code Component} prop types.
*/ */
type Props = { type Props = {
@ -25,12 +55,15 @@ type Props = {
t: Function t: Function
}; };
/**
* {@code AudioRoutePickerDialog}'s React {@code Component} state types.
*/
type State = { type State = {
/** /**
* Array of available devices. * Array of available devices.
*/ */
devices: Array<string> devices: Array<Device>
}; };
const { AudioMode } = NativeModules; const { AudioMode } = NativeModules;
@ -87,12 +120,11 @@ class AudioRoutePickerDialog extends Component<Props, State> {
* @param {Props} props - The read-only React {@code Component} props with * @param {Props} props - The read-only React {@code Component} props with
* which the new instance is to be initialized. * which the new instance is to be initialized.
*/ */
constructor(props) { constructor(props: Props) {
super(props); super(props);
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onCancel = this._onCancel.bind(this); this._onCancel = this._onCancel.bind(this);
this._onSubmit = this._onSubmit.bind(this);
} }
/** /**
@ -146,19 +178,49 @@ class AudioRoutePickerDialog extends Component<Props, State> {
this._hide(); this._hide();
} }
_onSubmit: (?Object) => void; _onSelectDeviceFn: (Device) => Function;
/** /**
* Handles the selection of a device on the sheet. The selected device will * Builds and returns a function which handles the selection of a device
* be used by {@code AudioMode}. * on the sheet. The selected device will be used by {@code AudioMode}.
* *
* @param {Object} device - Object representing the selected device. * @param {Device} device - Object representing the selected device.
* @private * @private
* @returns {void} * @returns {Function}
*/ */
_onSubmit(device) { _onSelectDeviceFn(device: Device) {
this._hide(); return () => {
AudioMode.setAudioDevice(device.type); this._hide();
AudioMode.setAudioDevice(device.type);
};
}
/**
* Renders a single device.
*
* @param {Device} device - Object representing a single device.
* @private
* @returns {ReactElement}
*/
_renderDevice(device: Device) {
const { iconName, selected, text } = device;
const selectedStyle = selected ? styles.selectedText : {};
return (
<TouchableHighlight
key = { device.type }
onPress = { this._onSelectDeviceFn(device) }
underlayColor = { UNDERLAY_COLOR } >
<View style = { styles.deviceRow } >
<Icon
name = { iconName }
style = { [ styles.deviceIcon, selectedStyle ] } />
<Text style = { [ styles.deviceText, selectedStyle ] } >
{ text }
</Text>
</View>
</TouchableHighlight>
);
} }
/** /**
@ -175,10 +237,9 @@ class AudioRoutePickerDialog extends Component<Props, State> {
} }
return ( return (
<SimpleBottomSheet <BottomSheet onCancel = { this._onCancel }>
onCancel = { this._onCancel } { this.state.devices.map(this._renderDevice, this) }
onSubmit = { this._onSubmit } </BottomSheet>
options = { devices } />
); );
} }
} }

View File

@ -0,0 +1,50 @@
// @flow
import { ColorPalette, createStyleSheet } from '../../../base/styles';
/**
* Underlay color for the buttons on the sheet.
*
* @type {string}
*/
export const UNDERLAY_COLOR = '#eee';
/**
* The React {@code Component} styles of {@code AudioRoutePickerDialog}.
*
* It uses a {@code BottomSheet} and these have been implemented as per the
* Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
export default createStyleSheet({
/**
* Base style for each row.
*/
deviceRow: {
alignItems: 'center',
flexDirection: 'row',
height: 48
},
/**
* Style for the {@code Icon} element in a row.
*/
deviceIcon: {
fontSize: 24
},
/**
* Style for the {@code Text} element in a row.
*/
deviceText: {
fontSize: 16,
marginLeft: 32
},
/**
* Style for a row which is marked as selected.
*/
selectedText: {
color: ColorPalette.blue
}
});