手写一个 OnBoarding 组件
点击关注公众号,“技术干货”及时达!当应用加了新功能的时候,都会通过这种方式来告诉用户怎么用:
这种组件叫做 OnBoarding 或者 Tour。
在 antd5 也加入了这种组件:
那它是怎么实现的呢?
调试下可以发现,遮罩层由 4 个 react 元素组成。
当点击上一步、下一步的时候,遮罩层的宽高会变化:
加上 transition,就产生了上面的动画效果。
其实还可以进一步简化一下:
用一个 div,设置 width、height 还有上下左右不同的 border-width。
点击上一步、下一步的时候,修改 width、height、border-width,也能达到一样的效果。
比起 antd 用 4 个 rect 来实现,更简洁一些。
原理就是这样,还是挺简单的。
下面我们来写一下:
npxcreate-vite
创建个 vite + react 的项目。
进入项目,把 index.css 的样式去掉:
然后新建 OnBoarding/Mask.tsx
importReact,{CSSProperties,useEffect,useState}from'react';
import{getMaskStyle}from'./getMaskStyle'
interfaceMaskProps{
element:HTMLElement;
container?:HTMLElement;
renderMaskContent?:(wrapper:React.ReactNode)=React.ReactNode;
}
exportconstMask:React.FCMaskProps=(props)={
const{
element,
renderMaskContent,
container
}=props;
const[style,setStyle]=useStateCSSProperties
useEffect(()={
if(!element){
return;
}
element.scrollIntoView({
block:'center',
inline:'center'
conststyle=getMaskStyle(element,container||document.documentElement);
setStyle(style);
},[element,container]);
constgetContent=()={
if(!renderMaskContent){
returnnull;
}
returnrenderMaskContent(
divclassName={'mask-content'}style={{width:'100%',height:'100%'}}/
return(
div
style={style}
className='mask'
{getContent()}
/div
};
这里传入的 element、container 分别是目标元素、遮罩层所在的容器。
而 getMaskContent 是用来定制这部分内容的:
可以是 Popover 也可以是别的。
前面分析过,主要是确定目标元素的 width、height、border-width。
首先,把目标元素滚动到可视区域:
这个用 scrollIntoView 方法实现:
在 MDN 上可以看到它的介绍:
设置 block、inline 为 center 是把元素中心滚动到可视区域中心的意思:
滚动完成后,就可以拿到元素的位置,计算 width、height、border-width 的样式了:
新建 OnBoarding/getMaskStyle.ts
exportconstgetMaskStyle=(element:HTMLElement,container:HTMLElement)={
if(!element){
return
}
const{height,width,left,top}=element.getBoundingClientRect();
constelementTopWithScroll=container.scrollTop+
constelementLeftWithScroll=container.scrollLeft+left;
return{
width:container.scrollWidth,
height:container.scrollHeight,
borderTopWidth:Math.max(elementTopWithScroll,0),
borderLeftWidth:Math.max(elementLeftWithScroll,0),
borderBottomWidth:Math.max(container.scrollHeight-height-elementTopWithScroll,0),
borderRightWidth:Math.max(container.scrollWidth-width-elementLeftWithScroll,0)
};
width、height 就是容器的包含滚动区域的宽高。
然后 border-width 分为上下左右 4 个方向:
top 和 left 的分别用 scrollTop、scrollLeft 和元素在可视区域里的 left、top 相加计算出来。
bottom 和 right 的就用容器的包含滚动区域的高度宽度 scrollHeight、scrollWidth 减去 height、width 再减去 scrollTop、scrollLeft 计算出来。
然后我们在内部又加了一个宽高为 100% 的 div,把它暴露出去,外部就可以用它来加 Popover 或者其他内容:
然后在 OnBoarding/index.scss 里写下样式:
.mask{
position:absolute;
left:0;
top:0;
z-index:999;
border-style:solid;
box-sizing:border-box;
border-color:rgba(0,0,0,0.6);
transition:all0.2sease-in-out;
}
mask 要绝对定位,然后设置下 border 的颜色。
我们先测试下现在的 Mark 组件:
把开发服务跑起来:
npminstall
npmrundev
我们就在 logo 上试一下吧:
Mask
element={document.getElementById('xxx')!}
renderMaskContent={(wrapper)={
returnwrapper
}}
/Mask
container 就是默认的根元素。
内容我们先不加 Popover。
看一下效果:
没啥问题。
然后加上 Popover 试试。
安装 antd:
npm install --save antd
然后引入下:
Mask
element={document.getElementById('xxx')!}
renderMaskContent={(wrapper)={
returnPopover
content={
divstyle={{width:300}}
phello/p
Buttontype='primary'下一步/Button
/div
}
open={true}
{wrapper}/Popover
}}
/Mask
没啥问题。
接下来在外面包装一层,改下 Popover 的样式就行了。
我们希望 OnBoarding 组件可以这么用:
传入 steps,包含每一步在哪个元素(selector),显示什么内容(renderConent),在什么方位(placement)。
所以类型这样写:
并且还有 beforeForward、beforeBack 也就是点上一步、下一步的回调。
step 是可以直接指定显示第几步。
onStepsEnd 是在全部完成后的回调。
内部有一个 state 来记录 currentStep,点击上一步、下一步会切换:
在切换前也会调用 beforeBack、beforeForward 的回调。
然后准备下 Popover 的内容:
渲染下:
这里用 createPortal 把 mask 渲染到容器元素下,比如 document.body。
注意,我们要给元素加上引导,那得元素渲染完才行。
所以这里加个 setState,在 useEffect 里执行。
效果就是在 dom 渲染完之后,触发重新渲染,从而渲染这个 OnBoarding 组件:
第一次渲染的时候,元素是 null,触发重新渲染之后,就会渲染下面的 Mask 了:
Onboarding/index.tsx 的全部代码如下:
importReact,{FC,useEffect,useState}from'react';
import{createPortal}from'react-dom';
import{Button,Popover}from'antd';
import{Mask}from'./Mask'
import{TooltipPlacement}from'antd/es/tooltip';
import'./index.scss';
exportinterfaceOnBoardingStepConfig{
selector:()=HTMLElement|null;
placement?:TooltipPlacement;
renderContent?:(currentStep:number)=React.ReactNode;
beforeForward?:(currentStep:number)=void;
beforeBack?:(currentStep:number)=void;
}
exportinterfaceOnBoardingProps{
step?:number;
steps:OnBoardingStepConfig[];
getContainer?:()=HTMLElement;
onStepsEnd?:()=void;
}
exportconstOnBoarding:FCOnBoardingProps=(props)={
const{
step=0,
steps,
onStepsEnd,
getContainer
}=props;
const[currentStep,setCurrentStep]=useStatenumber(0);
constcurrentSelectedElement=steps[currentStep]?.selector();
constcurrentContainerElement=getContainer?.()||document.documentElement;
constgetCurrentStep=()={
returnsteps[currentStep];
constback=async()={
if(currentStep===0){
return;
}
const{beforeBack}=getCurrentStep();
awaitbeforeBack?.(currentStep);
setCurrentStep(currentStep-1);
constforward=async()={
if(currentStep===steps.length-1){
awaitonStepsEnd?.();
return;
}
const{beforeForward}=getCurrentStep();
awaitbeforeForward?.(currentStep);
setCurrentStep(currentStep+1);
useEffect(()={
setCurrentStep(step!);
},[step]);
constrenderPopover=(wrapper:React.ReactNode)={
constconfig=getCurrentStep();
if(!config){
returnwrapper;
}
const{renderContent}=config;
constcontent=renderContent?renderContent(currentStep):null;
constoperation=(
divclassName={'onboarding-operation'}
{
currentStep!==0
Button
className={'back'}
onClick={()=back()}
{'上一步'}
/Button
}
Button
className={'forward'}
type={'primary'}
onClick={()=forward()}
{currentStep===steps.length-1?'我知道了':'下一步'}
/Button
/div
return(
Popover
content={div
{content}
{operation}
/div}
open={true}
placement={getCurrentStep()?.placement}
{wrapper}
/Popover
const[,setRenderTick]=useStatenumber(0);
useEffect(()={
setRenderTick(1)
},
if(!currentSelectedElement){
returnnull;
}
constmask=Mask
container={currentContainerElement}
element={currentSelectedElement}
renderMaskContent={(wrapper)=renderPopover(wrapper)}
/;
returncreatePortal(mask,currentContainerElement);
}
其实这个组件主要就是切换上一步下一步用的。
然后加下上一步下一步按钮的样式:
.onboarding-operation{
width:100%;
display:flex;
justify-content:flex-end;
margin-top:12px;
.back{
margin-right:12px;
min-width:80px;
}
.forward{
min-width:80px;
}
}
在 App.tsx 里测试下:
import{OnBoarding}from'./OnBoarding'
import{Button,Flex}from'antd';
functionApp(){
returndivclassName='App'
Flexgap="small"wrap="wrap"id="btn-group1"
Buttontype="primary"PrimaryButton/Button
ButtonDefaultButton/Button
Buttontype="dashed"DashedButton/Button
Buttontype="text"TextButton/Button
Buttontype="link"LinkButton/Button
/Flex
divstyle={{height:'1000px'}}/div
Flexwrap="wrap"gap="small"
Buttontype="primary"danger
Primary
/Button
ButtondangerDefault/Button
Buttontype="dashed"dangerid="btn-group2"
Dashed
/Button
Buttontype="text"danger
Text
/Button
Buttontype="link"danger
Link
/Button
/Flex
divstyle={{height:'500px'}}/div
Flexwrap="wrap"gap="small"
Buttontype="primary"ghost
Primary
/Button
ButtonghostDefault/Button
Buttontype="dashed"ghost
Dashed
/Button
Buttontype="primary"dangerghostid="btn-group3"
Danger
/Button
/Flex
OnBoarding
steps={
[
{
selector:()={
returndocument.getElementById('btn-group1');
},
renderContent:()={
return"神说要有光";
},
placement:'bottom'
},
{
selector:()={
returndocument.getElementById('btn-group2');
},
renderContent:()={
return"于是就有了光";
},
placement:'bottom'
},
{
selector:()={
returndocument.getElementById('btn-group3');
},
renderContent:()={
return"你相信光么";
},
placement:'bottom'
}
]
}/
/div
}
exportdefaultApp
我用 id 选中了三个元素:
指定三步的元素和渲染的内容:
跑一下:
没啥问题,选中的元素、mask 的样式都是对的。
只是现在结束后,mask 不会消失:
这个加个状态标识就好了:
此外,还有两个小问题:
一个是在窗口改变大小的时候,没有重新计算 mask 样式:
这个在 Mask 组件里用 ResizeObserver 监听下 container 大小改变就好了:
useEffect(()={
constobserver=newResizeObserver(()={
conststyle=getMaskStyle(element,container||document.documentElement);
setStyle(style);
observer.observe(container||document.documentElement);
},
变了重新计算和设置 mask 的 style。
再就是现在 popover 位置会闪一下:
那是因为 mask 的样式变化有个动画的过程,要等动画结束计算的 style 才准确。
所以给 Mask 组件加一个动画开始和结束的回调:
importReact,{CSSProperties,useEffect,useState}from'react';
import{getMaskStyle}from'./getMaskStyle'
import'./index.scss';
interfaceMaskProps{
element:HTMLElement;
container?:HTMLElement;
renderMaskContent?:(wrapper:React.ReactNode)=React.ReactNode;
onAnimationStart?:()=void;
onAnimationEnd?:()=void;
}
exportconstMask:React.FCMaskProps=(props)={
const{
element,
renderMaskContent,
container,
onAnimationStart,
onAnimationEnd
}=props;
useEffect(()={
onAnimationStart?.();
consttimer=setTimeout(()={
onAnimationEnd?.();
},200);
return()={
window.clearTimeout(timer);
},[element]);
const[style,setStyle]=useStateCSSProperties
useEffect(()={
constobserver=newResizeObserver(()={
conststyle=getMaskStyle(element,container||document.documentElement);
setStyle(style);
observer.observe(container||document.documentElement);
},
useEffect(()={
if(!element){
return;
}
element.scrollIntoView({
block:'center',
inline:'center'
conststyle=getMaskStyle(element,container||document.documentElement);
setStyle(style);
},[element,container]);
constgetContent=()={
if(!renderMaskContent){
returnnull;
}
returnrenderMaskContent(
divclassName={'mask-content'}style={{width:'100%',height:'100%'}}/
return(
div
style={style}
className='mask'
{getContent()}
/div
};
然后在 OnBoarding 组件加一个 state:
动画开始和结束修改这个 state:
动画结束才会渲染 Popover:
这样 Popover 位置就不会闪了:
importReact,{FC,useEffect,useState}from'react';
import{createPortal}from'react-dom';
import{Button,Popover}from'antd';
import{Mask}from'./Mask'
import{TooltipPlacement}from'antd/es/tooltip';
exportinterfaceOnBoardingStepConfig{
selector:()=HTMLElement|null;
placement?:TooltipPlacement;
renderContent?:(currentStep:number)=React.ReactNode;
beforeForward?:(currentStep:number)=void;
beforeBack?:(currentStep:number)=void;
}
exportinterfaceOnBoardingProps{
step?:number;
steps:OnBoardingStepConfig[];
getContainer?:()=HTMLElement;
onStepsEnd?:()=void;
}
exportconstOnBoarding:FCOnBoardingProps=(props)={
const{
step=0,
steps,
onStepsEnd,
getContainer
}=props;
const[currentStep,setCurrentStep]=useStatenumber(0);
constcurrentSelectedElement=steps[currentStep]?.selector();
constcurrentContainerElement=getContainer?.()||document.documentElement;
const[done,setDone]=useState(false);
const[isMaskMoving,setIsMaskMoving]=useStateboolean(false);
constgetCurrentStep=()={
returnsteps[currentStep];
constback=async()={
if(currentStep===0){
return;
}
const{beforeBack}=getCurrentStep();
awaitbeforeBack?.(currentStep);
setCurrentStep(currentStep-1);
constforward=async()={
if(currentStep===steps.length-1){
awaitonStepsEnd?.();
setDone(true);
return;
}
const{beforeForward}=getCurrentStep();
awaitbeforeForward?.(currentStep);
setCurrentStep(currentStep+1);
useEffect(()={
setCurrentStep(step!);
},[step]);
constrenderPopover=(wrapper:React.ReactNode)={
constconfig=getCurrentStep();
if(!config){
returnwrapper;
}
const{renderContent}=config;
constcontent=renderContent?renderContent(currentStep):null;
constoperation=(
divclassName={'onboarding-operation'}
{
currentStep!==0
Button
className={'back'}
onClick={()=back()}
{'上一步'}
/Button
}
Button
className={'forward'}
type={'primary'}
onClick={()=forward()}
{currentStep===steps.length-1?'我知道了':'下一步'}
/Button
/div
return(
isMaskMoving?wrapper:Popover
content={div
{content}
{operation}
/div}
open={true}
placement={getCurrentStep()?.placement}
{wrapper}
/Popover
const[,setRenderTick]=useStatenumber(0);
useEffect(()={
setRenderTick(1)
},
if(!currentSelectedElement||done){
returnnull;
}
constmask=Mask
onAnimationStart={()={
setIsMaskMoving(true);
}}
onAnimationEnd={()={
setIsMaskMoving(false);
}}
container={currentContainerElement}
element={currentSelectedElement}
renderMaskContent={(wrapper)=renderPopover(wrapper)}
/;
returncreatePortal(mask,currentContainerElement);
}
案例代码上传了 react 小册仓库:https://github.com/QuarkGluonPlasma/react-course-code/tree/main/onboarding-component
总结今天我们实现了 OnBoarding 组件,就是 antd5 里加的 Tour 组件。
antd 里是用 4 个 rect 元素实现的,我们是用一个 div 设置 width、height、四个方向不同的 border-width 实现的。
通过设置 transition,然后改变 width、height、border-width 就可以实现 mask 移动的动画。
然后我们在外层封装了一层,加上了上一步下一步的切换。
并且用 ResizeObserver 在窗口改变的时候重新计算 mask 样式。
此外,还要注意,mask 需要在 dom 树渲染完之后才能拿到 dom 来计算样式,所以需要 useEffect + setState 来触发一次额外渲染。
这样,OnBoarding 组件就完成了。
?更多内容可以看我的小册《React 通关秘籍》
?点击关注公众号,“技术干货”及时达!
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线