查看原文
其他

手写一个 OnBoarding 组件

神说要有光zxg 神光的编程秘籍
2024-08-22

当应用加了新功能的时候,都会通过这种方式来告诉用户怎么用:

这种组件叫做 OnBoarding 或者 Tour。

在 antd5 也加入了这种组件:

那它是怎么实现的呢?

调试下可以发现,遮罩层由 4 个 react 元素组成。

当点击上一步、下一步的时候,遮罩层的宽高会变化:

加上 transition,就产生了上面的动画效果。

其实还可以进一步简化一下:

用一个 div,设置 width、height 还有上下左右不同的 border-width。

点击上一步、下一步的时候,修改 width、height、border-width,也能达到一样的效果。

比起 antd 用 4 个 rect 来实现,更简洁一些。

原理就是这样,还是挺简单的。

下面我们来写一下:

npx create-vite

创建个 vite + react 的项目。

进入项目,把 index.css 的样式去掉:

然后新建 OnBoarding/Mask.tsx

import React, { CSSProperties, useEffect, useState } from 'react';
import { getMaskStyle } from './getMaskStyle'

interface MaskProps {
  element: HTMLElement;

  container?: HTMLElement;

  renderMaskContent?: (wrapper: React.ReactNode) => React.ReactNode;
}

export const Mask: React.FC<MaskProps> = (props) => {
  const {
    element,
    renderMaskContent,
    container
  } = props;

  const [style, setStyle] = useState<CSSProperties>({});

  useEffect(() => {
    if (!element) {
      return;
    }

    element.scrollIntoView({
        block'center',
        inline'center'
    });
  
    const style = getMaskStyle(element, container || document.documentElement);
  
    setStyle(style);
    
  }, [element, container]);

  const getContent = () => {
    if (!renderMaskContent) {
      return null;
    }
    return renderMaskContent(
      <div className={'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

export const getMaskStyle = (element: HTMLElement, container: HTMLElement) => {
    if (!element) {
      return {};
    }

    const { height, width, left, top } = element.getBoundingClientRect();

    const elementTopWithScroll = container.scrollTop + top;
    const elementLeftWithScroll = container.scrollLeft + left;

    return {
      width: container.scrollWidth,
      height: container.scrollHeight,
      borderTopWidthMath.max(elementTopWithScroll, 0),
      borderLeftWidthMath.max(elementLeftWithScroll, 0),
      borderBottomWidthMath.max(container.scrollHeight - height - elementTopWithScroll, 0),
      borderRightWidthMath.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;
    left0;
    top0;

    z-index999;

    border-style: solid;
    box-sizing: border-box;
    border-colorrgba(0000.6);

    transition: all 0.2s ease-in-out;
}

mask 要绝对定位,然后设置下 border 的颜色。

我们先测试下现在的 Mark 组件:

把开发服务跑起来:

npm install
npm run dev

我们就在 logo 上试一下吧:

<Mask
    element={document.getElementById('xxx')!}
    renderMaskContent={(wrapper) => {
      return wrapper
    }}
></Mask>

container 就是默认的根元素。

内容我们先不加 Popover。

看一下效果:

没啥问题。

然后加上 Popover 试试。

安装 antd:

npm install --save antd

然后引入下:

<Mask
    element={document.getElementById('xxx')!}
    renderMaskContent={(wrapper) => {
      return <Popover
        content={
          <div style={{width: 300}}>
            <p>hello</p>
            <Button type='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 的全部代码如下:

import React, { 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';

export interface OnBoardingStepConfig {
    selector() => HTMLElement | null;
  
    placement?: TooltipPlacement;
  
    renderContent?: (currentStep: number) => React.ReactNode;
  
    beforeForward?: (currentStep: number) => void;
  
    beforeBack?: (currentStep: number) => void;
}

  
export interface OnBoardingProps {
  step?: number;

  steps: OnBoardingStepConfig[];

  getContainer?: () => HTMLElement;

  onStepsEnd?: () => void;
}

export const OnBoarding:FC<OnBoardingProps> = (props) => {
  const {
    step = 0,
    steps,
    onStepsEnd,
    getContainer
  } = props;

  const [currentStep, setCurrentStep] = useState<number>(0);

  const currentSelectedElement = steps[currentStep]?.selector();

  const currentContainerElement = getContainer?.() || document.documentElement;

  const getCurrentStep = () => {
    return steps[currentStep];
  };

  const back = async () => {
    if (currentStep === 0) {
      return;
    }

    const { beforeBack } = getCurrentStep();
    await beforeBack?.(currentStep);
    setCurrentStep(currentStep - 1);
  };

  const forward = async () => {
    if (currentStep === steps.length - 1) {
      await onStepsEnd?.();
      return;
    }

    const { beforeForward } = getCurrentStep();
    await beforeForward?.(currentStep);
    setCurrentStep(currentStep + 1);
  };

  useEffect(() => {
    setCurrentStep(step!);
  }, [step]);

  const renderPopover = (wrapper: React.ReactNode) => {
    const config = getCurrentStep();
    if (!config) {
      return wrapper;
    }

    const { renderContent } = config;
    const content = renderContent ? renderContent(currentStep) : null;

    const operation = (
      <div className={'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] = useState<number>(0);

  useEffect(() => {
    setRenderTick(1)    
  }, []);
  
  if(!currentSelectedElement) {
    return null;
  }

  const mask = <Mask
    container={currentContainerElement}
    element={currentSelectedElement}
    renderMaskContent={(wrapper) => renderPopover(wrapper)}
  />
;

  return createPortal(mask, currentContainerElement);
}

其实这个组件主要就是切换上一步下一步用的。

然后加下上一步下一步按钮的样式:

.onboarding-operation {
    width100%;
    display: flex;
    justify-content: flex-end;
    margin-top12px;

    .back {
        margin-right12px;
        min-width80px;
    }

    .forward {
        min-width80px;
    }
}

在 App.tsx 里测试下:

import { OnBoarding } from './OnBoarding'
import { Button, Flex } from 'antd';

function App() {

  return <div className='App'>
    <Flex gap="small" wrap="wrap" id="btn-group1">
      <Button type="primary">Primary Button</Button>
      <Button>Default Button</Button>
      <Button type="dashed">Dashed Button</Button>
      <Button type="text">Text Button</Button>
      <Button type="link">Link Button</Button>
    </Flex>

  <div style={{height: '1000px'}}></div>

  <Flex wrap="wrap" gap="small">
    <Button type="primary" danger>
      Primary
    </Button>
    <Button danger>Default</Button>
    <Button type="dashed" danger  id="btn-group2">
      Dashed
    </Button>
    <Button type="text" danger>
      Text
    </Button>
    <Button type="link" danger>
      Link
    </Button>
  </Flex>

  <div style={{height: '500px'}}></div>

  <Flex wrap="wrap" gap="small">
    <Button type="primary" ghost>
      Primary
    </Button>
    <Button ghost>Default</Button>
    <Button type="dashed" ghost>
      Dashed
    </Button>
    <Button type="primary" danger ghost id="btn-group3">
      Danger
    </Button>
  </Flex>

  <OnBoarding
      steps={
        [
          {
            selector: () => {
              return document.getElementById('btn-group1');
            },
            renderContent: () => {
              return "神说要有光";
            },
            placement: 'bottom'
          },
          {
            selector: () => {
              return document.getElementById('btn-group2');
            },
            renderContent: () => {
              return "于是就有了光";
            },
            placement: 'bottom'
          },
          {
            selector: () => {
              return document.getElementById('btn-group3');
            },
            renderContent: () => {
              return "你相信光么";
            },
            placement: 'bottom'
          }
        ]
      } />
  </div>

}

export default App

我用 id 选中了三个元素:

指定三步的元素和渲染的内容:

跑一下:

没啥问题,选中的元素、mask 的样式都是对的。

只是现在结束后,mask 不会消失:

这个加个状态标识就好了:

此外,还有两个小问题:

一个是在窗口改变大小的时候,没有重新计算 mask 样式:

这个在 Mask 组件里用 ResizeObserver 监听下 container 大小改变就好了:

useEffect(() => {
    const observer = new ResizeObserver(() => {
      const style = getMaskStyle(element, container || document.documentElement);

      setStyle(style);
    });
    observer.observe(container || document.documentElement);
}, []);

变了重新计算和设置 mask 的 style。

再就是现在 popover 位置会闪一下:

那是因为 mask 的样式变化有个动画的过程,要等动画结束计算的 style 才准确。

所以给 Mask 组件加一个动画开始和结束的回调:

import React, { CSSProperties, useEffect, useState } from 'react';
import { getMaskStyle } from './getMaskStyle'

import './index.scss';

interface MaskProps {
  element: HTMLElement;

  container?: HTMLElement;

  renderMaskContent?: (wrapper: React.ReactNode) => React.ReactNode;

  onAnimationStart?: () => void;

  onAnimationEnd?: () => void;
}

export const Mask: React.FC<MaskProps> = (props) => {
  const {
    element,
    renderMaskContent,
    container,
    onAnimationStart,
    onAnimationEnd
  } = props;

  useEffect(() => {
    onAnimationStart?.();
    const timer = setTimeout(() => {
      onAnimationEnd?.();
    }, 200);

    return () => {
      window.clearTimeout(timer);
    };
  }, [element]);

  const [style, setStyle] = useState<CSSProperties>({});

  useEffect(() => {
    const observer = new ResizeObserver(() => {
      const style = getMaskStyle(element, container || document.documentElement);
  
      setStyle(style);
    });
    observer.observe(container || document.documentElement);
  }, []);

  useEffect(() => {
    if (!element) {
      return;
    }

    element.scrollIntoView({
        block'center',
        inline'center'
    });
  
    const style = getMaskStyle(element, container || document.documentElement);
  
    setStyle(style);
    
  }, [element, container]);

  const getContent = () => {
    if (!renderMaskContent) {
      return null;
    }
    return renderMaskContent(
      <div className={'mask-content'} style={{ width: '100%', height: '100%' }} />
    );
  };

  return (
    <div
      style={style}
      className='mask'>
      {getContent()}
    </div>

  );
};

然后在 OnBoarding 组件加一个 state:

动画开始和结束修改这个 state:

动画结束才会渲染 Popover:

这样 Popover 位置就不会闪了:

import React, { 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';

export interface OnBoardingStepConfig {
    selector() => HTMLElement | null;
  
    placement?: TooltipPlacement;
  
    renderContent?: (currentStep: number) => React.ReactNode;
  
    beforeForward?: (currentStep: number) => void;
  
    beforeBack?: (currentStep: number) => void;
}

  
export interface OnBoardingProps {
  step?: number;

  steps: OnBoardingStepConfig[];

  getContainer?: () => HTMLElement;

  onStepsEnd?: () => void;
}

export const OnBoarding:FC<OnBoardingProps> = (props) => {
  const {
    step = 0,
    steps,
    onStepsEnd,
    getContainer
  } = props;

  const [currentStep, setCurrentStep] = useState<number>(0);

  const currentSelectedElement = steps[currentStep]?.selector();

  const currentContainerElement = getContainer?.() || document.documentElement;

  const [done, setDone] = useState(false);

  const [isMaskMoving, setIsMaskMoving] = useState<boolean>(false);

  const getCurrentStep = () => {
    return steps[currentStep];
  };

  const back = async () => {
    if (currentStep === 0) {
      return;
    }

    const { beforeBack } = getCurrentStep();
    await beforeBack?.(currentStep);
    setCurrentStep(currentStep - 1);
  };

  const forward = async () => {
    if (currentStep === steps.length - 1) {
      await onStepsEnd?.();
      setDone(true);
      return;
    }

    const { beforeForward } = getCurrentStep();
    await beforeForward?.(currentStep);
    setCurrentStep(currentStep + 1);
  };

  useEffect(() => {
    setCurrentStep(step!);
  }, [step]);

  const renderPopover = (wrapper: React.ReactNode) => {
    const config = getCurrentStep();

    if (!config) {
      return wrapper;
    }

    const { renderContent } = config;
    const content = renderContent ? renderContent(currentStep) : null;

    const operation = (
      <div className={'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] = useState<number>(0);

  useEffect(() => {
    setRenderTick(1)    
  }, []);
  
  if(!currentSelectedElement || done) {
    return null;
  }

  const mask = <Mask
    onAnimationStart={() => {
        setIsMaskMoving(true);
    }}
    onAnimationEnd={() => {
        setIsMaskMoving(false);
    }}
    container={currentContainerElement}
    element={currentSelectedElement}
    renderMaskContent={(wrapper) => renderPopover(wrapper)}
  />
;

  return createPortal(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 组件就完成了。

继续滑动看下一个
神光的编程秘籍
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存