UNPKG

@mitesh-v2stech/react-native-toast-message

Version:
508 lines (438 loc) 11.9 kB
import React, { Component } from 'react'; import { Animated, PanResponder, Keyboard } from 'react-native'; import PropTypes from 'prop-types'; import SuccessToast from './components/success'; import ErrorToast from './components/error'; import InfoToast from './components/info'; import Dialog from './components/dialog'; import { complement } from './utils/arr'; import { includeKeys } from './utils/obj'; import { stylePropType } from './utils/prop-types'; import { isIOS } from './utils/platform'; import styles from './styles'; const FRICTION = 8; const defaultComponentsConfig = { // eslint-disable-next-line react/prop-types success: ({ hide, ...rest }) => ( <SuccessToast {...rest} onTrailingIconPress={hide} /> ), // eslint-disable-next-line react/prop-types error: ({ hide, ...rest }) => ( <ErrorToast {...rest} onTrailingIconPress={hide} /> ), // eslint-disable-next-line react/prop-types info: ({ hide, ...rest }) => ( <InfoToast {...rest} onTrailingIconPress={hide} /> ), // eslint-disable-next-line react/prop-types dialog: ({ cancel, ...rest }) => ( <Dialog {...rest} onTrailingIconPress={cancel} /> ) }; function shouldSetPanResponder(gesture) { const { dx, dy } = gesture; // Fixes onPress handler https://github.com/calintamas/react-native-toast-message/issues/113 return Math.abs(dx) > 2 || Math.abs(dy) > 2; } const getInitialState = ({ topOffset, bottomOffset, keyboardOffset, visibilityTime, height, autoHide, swipeable, position, type }) => ({ // layout topOffset, bottomOffset, keyboardOffset, height, position, type, // timing (in ms) visibilityTime, autoHide, swipeable, // content title: undefined, message: undefined, content: undefined, leadingIcon: undefined, showLoadingIcon: true, onPress: undefined, onCancel: f => f, onOk: f => f, onShow: undefined, onHide: undefined }); class Toast extends Component { static _ref = null; static setRef(ref = {}) { Toast._ref = ref; } static getRef() { return Toast._ref; } static clearRef() { Toast._ref = null; } static show(options = {}) { Toast._ref.show(options); } static hide() { Toast._ref.hide(); } static cancel() { Toast._ref.cancel(); } static ok() { Toast._ref.ok(); } constructor(props) { super(props); this._setState = this._setState.bind(this); this._animateMovement = this._animateMovement.bind(this); this._animateRelease = this._animateRelease.bind(this); this.startTimer = this.startTimer.bind(this); this.animate = this.animate.bind(this); this.show = this.show.bind(this); this.hide = this.hide.bind(this); this.cancel = this.cancel.bind(this); this.ok = this.ok.bind(this); this.onLayout = this.onLayout.bind(this); this.state = { ...getInitialState(props), inProgress: false, isVisible: false, animation: new Animated.Value(0), keyboardHeight: 0, keyboardVisible: false, customProps: {} }; this.panResponder = PanResponder.create({ onMoveShouldSetPanResponder: (event, gesture) => shouldSetPanResponder(gesture), onMoveShouldSetPanResponderCapture: (event, gesture) => shouldSetPanResponder(gesture), onPanResponderMove: (event, gesture) => { this._animateMovement(gesture); }, onPanResponderRelease: (event, gesture) => { this._animateRelease(gesture); } }); } componentDidMount() { const noop = { remove: () => {} }; this.keyboardDidShowListener = isIOS ? Keyboard.addListener('keyboardDidShow', this.keyboardDidShow) : noop; this.keyboardDidHideListner = isIOS ? Keyboard.addListener('keyboardDidHide', this.keyboardDidHide) : noop; } componentWillUnmount() { this.keyboardDidShowListener.remove(); this.keyboardDidHideListner.remove(); clearTimeout(this.timer); } keyboardDidShow = (e) => { const { isVisible, position } = this.state; this.setState({ keyboardHeight: e.endCoordinates.height, keyboardVisible: true }); if (isVisible && position === 'bottom') { this.animate({ toValue: 2 }); } }; keyboardDidHide = () => { const { isVisible, position } = this.state; this.setState({ keyboardVisible: false }); if (isVisible && position === 'bottom') { this.animate({ toValue: 1 }); } }; _setState(reducer) { return new Promise((resolve) => this.setState(reducer, () => resolve())); } _animateMovement(gesture) { const { position, swipeable, animation, keyboardVisible, type } = this.state; if (!swipeable || type === 'dialog') { return; } const { dy } = gesture; let value = 1 + dy / 100; const start = keyboardVisible && position === 'bottom' ? 2 : 1; if (position === 'bottom') { value = start - dy / 100; } if (value <= start) { animation.setValue(value); } } _animateRelease(gesture) { const { position, animation, keyboardVisible, swipeable, type } = this.state; if (!swipeable || type === 'dialog') { return; } const { dy, vy } = gesture; const isBottom = position === 'bottom'; let value = 1 + dy / 100; if (isBottom) { value = 1 - dy / 100; } const treshold = 0.65; if (value <= treshold || Math.abs(vy) >= treshold) { this.hide({ speed: Math.abs(vy) * 3 }); } else { Animated.spring(animation, { toValue: keyboardVisible && isBottom ? 2 : 1, velocity: vy, useNativeDriver: true }).start(); } } async show(options = {}) { const { inProgress, isVisible } = this.state; if (inProgress || isVisible) { await this.hide(); } await this._setState((prevState) => ({ ...prevState, ...getInitialState(this.props), // Reset layout /* Preserve the previously computed height (via onLayout). If the height of the component corresponding to this `show` call is different, onLayout will be called again and `height` state will adjust. This fixes an issue where a succession of calls to components with custom heights (custom Toast types) fails to hide them completely due to always resetting to the default component height */ height: prevState.height, inProgress: true, ...options, ...(options?.props ? { customProps: options.props } : { customProps: {} }) })); await this.animateShow(); await this._setState((prevState) => ({ ...prevState, isVisible: true, inProgress: false })); this.clearTimer(); const { autoHide, onShow } = this.state; if (autoHide) { this.startTimer(); } if (onShow) { onShow(); } } async hide({ speed } = {}) { await this._setState((prevState) => ({ ...prevState, inProgress: true })); await this.animateHide({ speed }); this.clearTimer(); await this._setState((prevState) => ({ ...prevState, isVisible: false, inProgress: false })); const { onHide } = this.state; if (onHide) { onHide(); } } async cancel({ speed } = {}) { await this._setState((prevState) => ({ ...prevState, inProgress: true })); await this.animateHide({ speed }); this.clearTimer(); await this._setState((prevState) => ({ ...prevState, isVisible: false, inProgress: false })); const { onCancel } = this.state; if (onCancel) { onCancel(); } } async ok({ speed } = {}) { await this._setState((prevState) => ({ ...prevState, inProgress: true })); await this.animateHide({ speed }); this.clearTimer(); await this._setState((prevState) => ({ ...prevState, isVisible: false, inProgress: false })); const { onOk } = this.state; if (onOk) { onOk(); } } animateShow = () => { const { keyboardVisible, position } = this.state; const toValue = keyboardVisible && position === 'bottom' ? 2 : 1; return this.animate({ toValue }); }; animateHide({ speed } = {}) { return this.animate({ toValue: 0, speed }); } animate({ toValue, speed = 0 }) { const { animation } = this.state; return new Promise((resolve) => { const config = { toValue, useNativeDriver: true, ...(speed > 0 ? { speed } : { friction: FRICTION }) }; Animated.spring(animation, config).start(() => resolve()); }); } startTimer() { const { visibilityTime } = this.state; this.timer = setTimeout(() => this.hide(), visibilityTime); } clearTimer() { clearTimeout(this.timer); this.timer = null; } renderContent({ config }) { const componentsConfig = { ...defaultComponentsConfig, ...config }; const { type, customProps } = this.state; const toastComponent = componentsConfig[type]; if (!toastComponent) { // eslint-disable-next-line no-console console.error( `Type '${type}' does not exist. Make sure to add it to your 'config'. You can read the documentation here: https://github.com/calintamas/react-native-toast-message/blob/master/README.md` ); return null; } return toastComponent({ ...includeKeys({ obj: this.state, keys: [ 'type', 'inProgress', 'isVisible', 'content', 'showLoadingIcon', 'leadingIcon', 'title', 'message', 'hide', 'show', 'cancel', 'ok', 'onPress' ] }), position: type === 'dialog' ? 'bottom' : this.state['position'], props: { ...customProps }, cancel: this.cancel, ok: this.ok, hide: this.hide, show: this.show }); } getBaseStyle(position = 'bottom', keyboardHeight) { const { topOffset, bottomOffset, keyboardOffset, height, animation } = this.state; const offset = position === 'bottom' ? bottomOffset : topOffset; // +5 px to completely hide the toast under StatusBar (on Android) const range = [height + 5, -offset, -(keyboardOffset + keyboardHeight)]; const outputRange = position === 'bottom' ? range : complement(range); const translateY = animation.interpolate({ inputRange: [0, 1, 2], outputRange }); return [ styles.base, styles[position], { transform: [{ translateY }] } ]; } onLayout(e) { this.setState({ height: e.nativeEvent.layout.height }); } render() { const { style } = this.props; const { position, keyboardHeight, type, isVisible } = this.state; const baseStyle = this.getBaseStyle(type === 'dialog' ? 'bottom' : position, keyboardHeight); return ( isVisible && ( <Animated.View testID='animatedView' onLayout={this.onLayout} style={[baseStyle, style]} {...this.panResponder.panHandlers}> {this.renderContent(this.props)} </Animated.View> ) ); } } Toast.propTypes = { config: PropTypes.objectOf(PropTypes.func), style: stylePropType, topOffset: PropTypes.number, bottomOffset: PropTypes.number, keyboardOffset: PropTypes.number, visibilityTime: PropTypes.number, autoHide: PropTypes.bool, swipeable: PropTypes.bool, height: PropTypes.number, position: PropTypes.oneOf(['top', 'bottom']), type: PropTypes.string }; Toast.defaultProps = { config: {}, style: undefined, topOffset: 30, bottomOffset: 40, keyboardOffset: 15, visibilityTime: 4000, autoHide: true, swipeable: true, height: 60, position: 'top', type: 'success' }; export default Toast;