前言
移动端反馈组件一般会有Toast Dialog ActionSheet Popup 等。Toast 是一个轻提示, 对操作结果的轻量级反馈,无需用户操作即可自行消失。
在自己动手实现之前一直以为 Toast 组件是基于 Popup 组件实现的。在实现过程中发现不太合适。
因为 Popup 是基于 ReactDOM.createPortal 实现的。
如果是纯组件调用的方式是可以用 createPortal 这种方式去实现的,
例如:
<Toast>
提示哦
</Toast>
但是如果是需要简洁的方法调用则需要用ReactDOM.render去实现更合适,所以 类似于Toast.info(), Dialog.confirm() 这种形式使用 ReactDOM.render 去实现。
例如:
Toast.info('提示哦')
ReactDOM.createPortal 与 ReactDOM.render 差别是 createPortal 是增量添加而 render 是替换,所以使用场景是有一定的区别的。
ReactDOM.createPortal(<Component />, container); // 会对 container appendChild
ReactDOM.render(<Component />, container); // 会对 container replaceChild
效果
实现
我们先分析下 Toast 有哪些东西,从UI展现形式就一个黑色透明的小块,支持纯文本和图标文本的方式展示,里面包含图标和文本,一般位于视窗居中展示,当然也可以自己控制位置。然后再添加一些其他的属性比如duration,vertical, placement之类的。所以html结构比较简单,
<div className={classNamePro}>
{iconElement && <div className="toast__icon">{iconElement}</div>}
<div className="toast__content">{content}</div>
</div>
调用方式:
// 1 入参为字符串
Toast.info('ABC');
// 2 入参为配置项
Toast.info({
icon: 'loading',
content: '加载中...',
// ...other config
});
// 提供别名调用
Toast.loading('加载中...');
Toast.error('失败');
Toast.success('成功');
Toast.warn('警告');
实现方式一般都是先写好组件,然后再给组件添加对应的方法。简单实现如下:
import * as ReactDOM from 'react-dom/client';
import { CSSTransition } from 'react-transition-group';
import React, { ReactNode, useRef } from 'react';
import classNames from 'classnames';
import './toast.scss';
const withContent = (props: string | object): object => typeof props === 'string' ? { content: props } : props;
/**
* Toast Component Properties.
*/
export interface IToastProps {
/** className */
className?: string;
/** 展示方向 */
direction?: 'horizontal' | 'vertical';
/** 显隐 */
visible?: boolean;
/** 图标 */
icon?: 'error' | 'warn' | 'loading' | 'success' | 'string' | ReactNode;
/** 文本内容 */
content?: ReactNode;
/** 隐藏之后的回调 */
onExited?: () => void;
}
/**
* Toast Methods Properties.
*/
export type ToastMethodsProps =
| (Omit<IToastProps, 'visible'> & {
duration?: number | null | undefined;
})
| string;
export interface IToastInstance {
destory: () => void;
update: (props: ToastMethodsProps) => void;
}
// 模拟 icon
const defaultIcons = {
error: '❎',
warn: '⚠️',
loading: '😇',
success: '🎉',
};
/**
* Toast.
*/
const Toast = (props: IToastProps) => {
const {
icon,
content,
className,
visible,
direction = 'vertical',
onExited,
} = props;
const nodeRef = useRef(null);
const classNamePro = classNames('toast', `toast--${direction}`, className);
const iconElement = defaultIcons[icon] || icon;
return (
<CSSTransition
in={visible}
timeout={300}
nodeRef={nodeRef}
classNames="zoom"
appear={true}
unmountOnExit
onExited={onExited}
>
<div className={classNamePro} ref={nodeRef}>
{iconElement && <div className="toast__icon">{iconElement}</div>}
<div className="toast__content">{content}</div>
</div>
</CSSTransition>
);
};
const container = document.createElement('div');
container.classList.add('toast-wrapper');
document.body.appendChild(container);
let instance: IToastInstance | null = null;
let timer;
let root;
Toast.info = (p: ToastMethodsProps) => {
const lastProps = withContent(p);
if (!root) {
root = ReactDOM.createRoot(container);
}
const showToast = (props) => {
if (timer) clearTimeout(timer);
props = withContent(props);
const { duration = 3000 } = props;
root.render(<Toast {...{ ...props, visible: true }} />);
if (duration) {
timer = setTimeout(() => {
hideToast();
}, duration);
}
};
const hideToast = () => {
root.render(
<Toast
{...{ ...lastProps, visible: false }}
onExited={() => {
// Delay to unmount to avoid React 18 sync warning
Promise.resolve().then(() => {
root.unmount();
root = null;
instance = null;
});
}}
/>
);
};
showToast(lastProps);
instance = {
destory: hideToast,
update: showToast,
};
return instance;
};
Toast.loading = (props: ToastMethodsProps) => Toast.info({ icon: 'loading', ...withContent(props) });
Toast.error = (props: ToastMethodsProps) => Toast.info({ icon: 'error', ...withContent(props) });
Toast.warn = (props: ToastMethodsProps) => Toast.info({ icon: 'warn', ...withContent(props) });
Toast.success = (props: ToastMethodsProps) => Toast.info({ icon: 'success', ...withContent(props) });
Toast.defaultProps = {
direction: 'vertical',
visible: true,
};
export default Toast;
样式
.toast {
position: fixed;
top: 50%;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 10px;
color: #fff;
background: rgb(0 0 0 / 70%);
border-radius: 4px;
transform: translate(-50%, -50%);
&--horizontal {
flex-direction: row;
}
&--vertical {
flex-direction: column;
}
}
.zoom-enter {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
.zoom-enter-active {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
transition: 300ms all linear;
}
.zoom-exit {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
.zoom-exit-active {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
transition: 300ms all linear;
}
测试
import { Button, Toast } from 'thor';
import React, { useState } from 'react';
/**
* Demo1
*/
const Demo1 = (props) => {
const { ...restProps } = props;
const [visible, setVisible] = useState(false);
return (
<div {...restProps}>
<Button
type="primary"
onClick={() => {
Toast.info('纯文字哦');
}}
>文字</Button>
<Button
type="primary"
onClick={() => {
Toast.error('错误哦');
}}
>错误提示❎</Button>
<Button
type="primary"
onClick={() => {
Toast.warn('警告哦');
}}
>警告提示⚠️</Button>
<Button
type="primary"
onClick={() => {
Toast.success('成功哦');
}}
>成功提示</Button>
<Button
type="primary"
onClick={() => {
Toast.success({
direction: 'horizontal',
content: '水平展示哦'
});
}}
>水平 horizontal</Button>
<Button
type="primary"
onClick={() => {
Toast.success({
icon: '🤡',
content: '自定义icon😯'
});
}}
>自定义icon</Button>
<Button
type="primary"
onClick={() => {
let n = 0;
let timer:any = null;
const instance = Toast.success(`${n}`);
timer = setInterval(() => {
n++;
instance.update({ icon: 'success', content: `${n}` });
if (n===4) {
instance.destory();
timer && clearInterval(timer);
}
}, 1000);
}}
>更新</Button>
<Button
type="primary"
onClick={() => {
Toast.success({ content: '不隐藏哦', duration: 0 });
}}
>不隐藏</Button>
<Button
onClick={() => {
setVisible(true);
}}
>显示</Button>
<Button
onClick={() => {
setVisible(false);
}}
>隐藏</Button>
<Toast visible={visible} content="手动测试哦"></Toast>
</div>
);
};
export default Demo1;
结语
🎉 🎉 大功告成 🎉 🎉,这样初版就完成了。当然还有很多细节的功能没有实现,这里只是提供一种思路,可以根据自己的反馈组件的功能去逐步的打磨完善Toast组件。