react-native-buttered-toast
Version:
A customizable toast notification for React Native.
616 lines (602 loc) • 16 kB
JavaScript
import React from 'react';
import {
Easing,
StyleSheet,
View,
Animated,
Dimensions,
Alert,
} from 'react-native';
import PropTypes from 'prop-types';
import { merge as mergeDeep } from 'lodash';
import uuidv4 from 'uuid/v4';
import Butter from './Butter';
const styles = StyleSheet
.create(
{
containerStyle: {
},
},
);
const makeOptions = {
containerStyle: styles.containerStyle,
duration: 1200,
easing: Easing.bounce,
// XXX: By default, toasts must be dismissed.
lifespan: -1,
dismissable: true,
};
const consumeOptions = {
duration: 500,
easing: Easing.cubic,
dismissRight: true,
};
const ButteredToastContext = React
.createContext(
null,
);
export const withButter = (Toast) => {
class ButteredToast extends React.Component {
static contextType = ButteredToastContext;
render() {
const {
...extraProps
} = this.context;
return (
<Toast
{...extraProps}
/>
);
}
}
ButteredToast.propTypes = {
};
ButteredToast.defaultProps = {
};
return ButteredToast;
};
class ButteredToastProvider extends React.Component {
static hasLayout = (width, height) => (
width !== null && height !== null && width !== undefined && height !== undefined && !Number.isNaN(width) && !Number.isNaN(height)
);
static hasLifespan = lifespan => (
lifespan !== null && lifespan !== undefined && !Number.isNaN(lifespan) && lifespan >= 0
);
state = {
children: [],
animValues: [],
dimensions: [],
width: undefined,
height: undefined,
uuids: [],
processing: false,
pendingTasks: [],
dragging: false,
};
processPendingTasks = (extraTask) => {
const {
width,
height,
processing,
dragging,
} = this.state;
return new Promise(
(resolve) => {
if (!!extraTask) {
Object.assign(
this.state,
{
pendingTasks: [
...this.state.pendingTasks,
() => extraTask()
.then(resolve),
],
},
);
}
if (!dragging && !processing && ButteredToastProvider.hasLayout(width, height)) {
const [
nextTask,
...extraTasks
] = this.state.pendingTasks;
const hasNextTask = (!!nextTask);
Object.assign(
this.state,
{
processing: hasNextTask,
pendingTasks: extraTasks,
},
);
if (hasNextTask) {
return nextTask()
.then(() => {
Object.assign(
this.state,
{
processing: false,
},
);
return this.processPendingTasks(null);
});
}
return resolve();
}
},
);
};
consumeToast = (toastId, options = consumeOptions) => {
const { uuids } = this.state;
if (uuids.indexOf(toastId) >= 0) {
const shouldConsumeToast = () => Promise
.resolve()
.then(
() => mergeDeep(
{ ...consumeOptions },
options || {},
),
)
.then(
({ duration, easing }) => {
const { uuids } = this.state;
const index = uuids
.indexOf(
toastId,
);
return {
index,
duration,
easing,
};
},
)
.then(
({ index, duration, easing }) => {
const {
paddingRight,
paddingBottom,
paddingBetween,
} = this.props;
const {
width,
height,
children,
animValues,
dimensions,
uuids,
} = this.state;
const animValue = animValues[index];
const {
width: viewWidth,
height: viewHeight,
} = dimensions[index];
const y = dimensions
.filter((_, i) => i > index)
.reduce(
(n, { height }) => (
(height + n) + paddingBetween
),
paddingBottom,
);
const { dismissRight } = options;
return new Promise(
resolve => Animated
.timing(
animValue,
{
toValue: {
x: (dismissRight ? 1 : -1) * width,
y: (height - y) - viewHeight,
},
duration,
easing,
useNativeDriver: true,
},
)
.start(resolve),
)
.then(() => index);
},
)
.then(
(index) => new Promise(
resolve => this.setState(
{
children: this.state.children
.filter((_, i) => i !== index),
animValues: this.state.animValues
.filter((_, i) => i !== index),
dimensions: this.state.dimensions
.filter((_, i) => i !== index),
uuids: this.state.uuids
.filter((_, i) => i !== index),
},
resolve,
),
),
)
.then(
() => {
const {
paddingRight,
paddingBottom,
paddingBetween,
duration,
easing,
} = this.props;
const {
width,
animValues,
dimensions,
height,
} = this.state;
const targets = dimensions
.slice()
.reverse()
.reduce(
(arr, { height }, i) => (
[
...arr,
((arr[i - 1] || (-1 * paddingBottom)) - height) - ((!!arr[i - 1]) ? paddingBetween : 0),
]
),
[],
)
.reverse();
return new Promise(
resolve => Animated
.parallel(
[
...animValues
.map(
(animValue, i) => Animated.timing(
animValue,
{
toValue: {
x: width - (dimensions[i].width + paddingRight),
y: targets[i] + height,
},
duration,
useNativeDriver: true,
easing,
},
),
),
],
)
.start(resolve),
);
},
);
return this.processPendingTasks(
shouldConsumeToast,
);
}
console.warn(
`Ignoring request to consumeToast "${toastId}".`,
);
return this.processPendingTasks(
null,
);
};
requestDrag = (toastId) => {
const {
processing,
dragging,
} = this.state;
const allowDrag = !processing && !dragging;
if (allowDrag) {
Object.assign(
this.state,
{
dragging: true,
},
);
}
return allowDrag;
};
finishDrag = (toastId, animDrag, shouldConsume) => {
Object.assign(
this.state,
{
dragging: false,
},
);
if (shouldConsume) {
const { x } = animDrag
.__getValue();
return this.consumeToast(
toastId,
{
dismissRight: (x > 0),
},
);
}
return this.processPendingTasks(
null,
);
};
makeToast = (Bread, options = makeOptions) => {
const shouldMakeToast = () => Promise
.resolve()
.then(
() => {
const { width, height } = this.state;
if (!Bread) {
return Promise
.reject(
new Error(
`Expected valid element Bread, encountered ${typeof Toast}.`,
),
);
} else if (!ButteredToastProvider.hasLayout(width, height)) {
return Promise
.reject(
new Error(
`Layout is not yet ready!`,
),
);
}
return mergeDeep(
{ ...makeOptions },
options || {},
);
},
)
.then(
({
containerStyle,
duration,
easing,
lifespan,
dismissable,
}) => {
const { height } = this.state;
const animValue = new Animated
.ValueXY(
{
x: 0,
y: height,
},
);
const animLifespan = ButteredToastProvider.hasLifespan(lifespan) ? new Animated.Value(0) : null;
const toastId = uuidv4();
const shouldRequestDrag = () => (!!dismissable) && this.requestDrag(toastId);
const shouldFinishDrag = (animDrag, shouldConsume) => this.finishDrag(
toastId,
animDrag,
shouldConsume,
);
return new Promise(
resolve => this.setState(
{
children: [
...this.state.children,
<Butter
key={`${this.state.children.length}`}
containerStyle={containerStyle}
animValue={animValue}
requestDrag={shouldRequestDrag}
finishDrag={shouldFinishDrag}
>
<Bread
animLifespan={animLifespan}
consumeToast={() => this.consumeToast(
toastId,
)}
onLayout={({ nativeEvent: { layout: { width, height } }}) => resolve(
{
width,
height,
animValue,
duration,
easing,
lifespan,
animLifespan,
toastId,
},
)}
/>
</Butter>
],
animValues: [
...this.state.animValues,
animValue,
],
uuids: [
...this.state.uuids,
toastId,
],
},
),
);
},
)
.then(
({ width, height, animValue, duration, easing, lifespan, animLifespan, toastId }) => new Promise(
resolve => this.setState(
{
dimensions: [
...this.state.dimensions,
{
width,
height,
},
],
},
() => resolve(
{
animValue,
duration,
easing,
lifespan,
animLifespan,
toastId,
},
)
),
),
)
.then(
({ animValue, duration, easing, lifespan, animLifespan, toastId }) => {
const {
paddingBottom,
paddingRight,
paddingBetween,
} = this.props;
const {
width,
height,
animValues,
dimensions,
} = this.state;
const { width: viewWidth, height: viewHeight } = dimensions[dimensions.length - 1];
animValue
.setValue(
{
x: width - (viewWidth + paddingRight),
y: height,
},
);
const targets = dimensions
.slice()
.reverse()
.reduce(
(arr, { height }, i) => (
[
...arr,
((arr[i - 1] || (-1 * paddingBottom)) - height) - ((!!arr[i - 1]) ? paddingBetween : 0),
]
),
[],
)
.reverse();
return new Promise(
resolve => Animated.parallel(
[
...animValues
.map(
(animValue, i) => Animated
.timing(
animValue,
{
toValue: {
x: width - (dimensions[i].width + paddingRight),
y: targets[i] + height,
},
useNativeDriver: true,
duration,
easing,
},
),
),
]
.filter(e => !!e),
)
.start(() => resolve({ lifespan, animLifespan, toastId })),
);
},
)
.then(
({ lifespan, animLifespan, toastId }) => {
const { uuids } = this.state;
const doesHaveLifespan = ButteredToastProvider.hasLifespan(
lifespan,
);
// XXX: Schedule a deletion.
if (doesHaveLifespan) {
Animated
.timing(
animLifespan,
{
duration: lifespan,
toValue: 1,
useNativeDriver: true,
},
)
.start(
() => {
const { uuids } = this.state;
// XXX: Verify the toast is still active.
if (uuids.indexOf(toastId) >= 0) {
return this.consumeToast(
toastId,
);
}
return Promise
.resolve();
},
);
}
return uuids[uuids.length - 1];
},
);
return this.processPendingTasks(
shouldMakeToast,
);
};
onLayout = ({ nativeEvent: { layout: { width, height } } }) => {
const shouldInit = !ButteredToastProvider
.hasLayout(
this.state.width,
this.state.height,
);
return this.setState(
{
width,
height,
},
() => (!!shouldInit) && this.processPendingTasks(
null,
),
);
};
render() {
const {
children,
paddingBottom,
paddingRight,
...extraProps
} = this.props;
const {
children: toastedChildren,
width,
height,
} = this.state;
const {
makeToast,
consumeToast,
} = this;
return (
<ButteredToastContext.Provider
value={{
makeToast,
consumeToast,
}}
>
{children}
<View
onLayout={this.onLayout}
style={StyleSheet.absoluteFill}
pointerEvents="box-none"
>
{toastedChildren}
</View>
</ButteredToastContext.Provider>
);
}
};
ButteredToastProvider.propTypes = {
paddingBottom: PropTypes.number,
paddingRight: PropTypes.number,
paddingBetween: PropTypes.number,
duration: PropTypes.number,
easing: PropTypes.func,
};
ButteredToastProvider.defaultProps = {
paddingBottom: 30,
paddingRight: 10,
paddingBetween: 10,
duration: 500,
easing: Easing.bounce,
};
export default ButteredToastProvider;