Merge pull request #2932 from saghul/refactor-bottomsheet

[RN] Refactor SimpleBottomSheet
This commit is contained in:
Zoltan Bettenbuk 2018-05-09 10:59:34 +02:00 committed by GitHub
commit ab7e572162
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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
}
});