[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:
parent
8f5ec20da8
commit
4fdd71d1bd
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -1,4 +1,4 @@
|
|||
export { default as BottomSheet } from './BottomSheet';
|
||||
export { default as DialogContainer } from './DialogContainer';
|
||||
export { default as Dialog } from './Dialog';
|
||||
export { default as SimpleBottomSheet } from './SimpleBottomSheet';
|
||||
export { default as StatelessDialog } from './StatelessDialog';
|
||||
|
|
|
@ -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:
|
||||
* {@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.
|
||||
*/
|
||||
container: {
|
||||
alignItems: 'flex-end',
|
||||
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: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
|
@ -46,57 +61,13 @@ export const simpleBottomSheet = createStyleSheet({
|
|||
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.
|
||||
*/
|
||||
sheet: {
|
||||
alignSelf: 'flex-end',
|
||||
flex: 1,
|
||||
backgroundColor: ColorPalette.white,
|
||||
flex: 1
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8
|
||||
}
|
||||
});
|
||||
|
|
|
@ -2,15 +2,45 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
import React, { Component } from 'react';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { NativeModules, Text, TouchableHighlight, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { hideDialog, SimpleBottomSheet } from '../../../base/dialog';
|
||||
import { hideDialog, BottomSheet } from '../../../base/dialog';
|
||||
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 = {
|
||||
|
||||
|
@ -25,12 +55,15 @@ type Props = {
|
|||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* {@code AudioRoutePickerDialog}'s React {@code Component} state types.
|
||||
*/
|
||||
type State = {
|
||||
|
||||
/**
|
||||
* Array of available devices.
|
||||
*/
|
||||
devices: Array<string>
|
||||
devices: Array<Device>
|
||||
};
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
|
@ -87,12 +120,11 @@ class AudioRoutePickerDialog extends Component<Props, State> {
|
|||
* @param {Props} props - The read-only React {@code Component} props with
|
||||
* which the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once per instance.
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -146,19 +178,49 @@ class AudioRoutePickerDialog extends Component<Props, State> {
|
|||
this._hide();
|
||||
}
|
||||
|
||||
_onSubmit: (?Object) => void;
|
||||
_onSelectDeviceFn: (Device) => Function;
|
||||
|
||||
/**
|
||||
* Handles the selection of a device on the sheet. The selected device will
|
||||
* be used by {@code AudioMode}.
|
||||
* Builds and returns a function which handles the selection of a device
|
||||
* 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
|
||||
* @returns {void}
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onSubmit(device) {
|
||||
_onSelectDeviceFn(device: Device) {
|
||||
return () => {
|
||||
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 (
|
||||
<SimpleBottomSheet
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onSubmit }
|
||||
options = { devices } />
|
||||
<BottomSheet onCancel = { this._onCancel }>
|
||||
{ this.state.devices.map(this._renderDevice, this) }
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue