🤔提升用户体验,给你的模态弹窗加个小细节

5,235 阅读7分钟

什么是模态组件?

大家在开发后台网站应用时,应该常常会使用到模态组件(Modal),也可以称为对话框组件。模态组件一般用于展示一些简单的操作,例如字段较少的表单编辑,或者删除确认框等。一般由一个展示的容器以及一个遮罩层组成,如下图:

image.png

模态组件的问题?

有时模态组件需要承载比较多的内容,例如展示一个很长的表单或者通知信息。那么这时候就会遇到一个模态组件高度太高的问题,而这种问题一般又有两种解决方式,下面以 Chakra UI 组件库为例进行展示。

  1. 第一种是限制展示容器的最大高度,当内容高度大于展示容器的高度时内部的内容进行滚动展示,如下图:

image.png

  1. 第二种则是不限制组件的最大高度,直接滚动整个窗口,直至滚动到模态组件的底部完全展示在窗口中,如下图:

image.png

image.png

以上两种解决方式可以根据项目整体的 UI 风格进行选择,我们在实际的项目中选择的是第一种方案,也就是限制窗口大小并滚动的方案。

但是在实际使用的过程中,我们发现当在模态组件中展示表单时,如果有一个表单项恰好在最下面被遮挡了,用户很有可能在填写完上面的字段后直接点击提交,而不会去尝试滚动查看下方是否还有表单项,例如下图(红圈中即是被遮挡的表单项):

image.png

解决方式?

解决的方式有两种,一种是直接采用第二种展示长内容的模态组件,不限制组件的最大高度,直接滚动整个窗口,用户可以很直观的知道当前的内容是否已经到底。

但如果你们的 UI 风格已经定了是第二种展示方式,那么可以采取与我一样的解决方式:在模态组件的底部增加一个小提示,当内部容器的高度大于模态组件的最大高度时,用户还没有滚动到最底部时现实,当滚动到底部时就隐藏,如下图:

temp.gif

这个简单的效果,看似简单,其实还是有很多小细节需要注意的。下面讲讲我是如何实现这一的一个小提示的,大家可以看看是否和自己想象的实现方式相符。

实现过程

需求梳理

在实现前,我们需要梳理一下需求,把考虑的点列出来:

  • 图标需要固定在模态容器底部,并且需要水平居中,不能随着内容滚动
  • 当内容高度小于模态组件高度时不需要现实这个滚动提示
  • 滚动到底部时需要隐藏滚动提示,向上滚动时需要重新显示

下文中的演示代码都是在 react 中使用 tsx 及 ts 实现。

样式实现

图标需要固定在模态容器底部,并且需要水平居中,不能随着内容滚动

想要将一个元素固定在窗口底部有两种实现方式:

  1. 使用相对定位(relative)和绝对定位(absolute)实现
  2. 使用相对定位(relative)和粘性定位(sticky)实现

两种方式实现并没有什么区别,我使用的是第一种方式来实现,简化后的代码如下:

<div style={{position: 'relative'; height: '596px'}} >
    <div
              style={{
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                position: 'absolute',
                bottom: '0',
                left: '0',
                backgroundImage:
                  'linear-gradient(to bottom,rgba(255,255,255,0.6),rgba(255,255,255,1))',
                width: '100%',
              }}
            >
              <Image src="/icons/scroll-tip.svg" />
      </div>
</div>
 

这样就可以实现将图标固定在模态组件底部居中的位置了,实际使用需要考虑边距等样式进行调整。

这里还使用了 linear-gradient 为这个小图标的容器增加了一个从上到下,从透明到白色的渐变效果,看起来就会更加自然,并且不会遮挡内容。

image.png

逻辑实现

当内容高度小于模态组件高度时不需要现实这个滚动提示

作为一个通用组件,内部内容的高度应该是动态改变的,因此我们需要动态获取这个高度,并且与模态的高度进行对比,我的实现方式如下:

import { useSize } from 'ahooks';

const contentRef = useRef(null);
const contentSize = useSize(contentRef); // 内容尺寸

const bodyRef = useRef(null);
const bodySize = useSize(bodyRef); // 外部容器尺寸
  
// 判断内部内容高度是否大于外部容器高度
const isScroll = useMemo(() => {
    if (
      contentSize?.height &&
      bodySize?.height &&
      contentSize!.height > bodySize!.height + 20
    ) {
      return true;
    }
    return false;
  }, [contentSize?.height, bodySize?.height]);
  
<ChakraModalBody
        maxHeight="596px"
        p="24px"
        ref={bodyRef}
        onScroll={handleScroll}
      >
   <Box ref={contentRef}>{children}</Box>
</ChakraModalBody>

容器的高度我们通过 ahookuseSize 这个 hook 来获取。通过内外部高度对比判断模态组件是否可滚动,定义一个 isScroll 用来存储这个状态。

滚动到底部时需要隐藏滚动提示,向上滚动时需要重新显示

既然要判断容器如何滚动,就需要绑定一下容器的滚动事件 onScroll ,判断容器滚动事件触发时上一次距离顶部的高度与下一次的大小,就可以得到容器滚动的方向,实现的方式如下:

const [showScrollTip, setShowScrollTip] = useState(true);

const onScroll = () => {
      if (isScroll) {
        // Triggered when scrolling to 28px from the bottom
        if (
          e.target.scrollHeight - e.target.scrollTop - e.target.offsetHeight <=
          28
        ) {
          setShowScrollTip(false);
          // Triggered when scrolling up
        } else if (e.target.scrollTop < scrollTop) {
          setShowScrollTip(true);
        }
      }
      setScrollTop(e.target.scrollTop);
    }
   }

这一步定义的一个新变量 showScrollTip 来控制滚动提示的状态,通过 isScrollshowScrollTip 两个变量同时控制滚动提示的显示或隐藏。

{isScroll && showScrollTip && (
    <div style={{position: 'relative'; height: '596px'}} >
        <div
                  style={{
                    display: 'flex',
                    justifyContent: 'center',
                    alignItems: 'center',
                    position: 'absolute',
                    bottom: '0',
                    left: '0',
                    backgroundImage:
                      'linear-gradient(to bottom,rgba(255,255,255,0.6),rgba(255,255,255,1))',
                    width: '100%',
                  }}
                >
                  <Image src="/icons/scroll-tip.svg" />
          </div>
    </div>
)}
 

temp.gif

在完成上面的逻辑后,我们可以看到效果已经实现了,但是图标的突然出现和消失会很不自然,因此我们还需要补充一下进入和离开的动画。

动画实现

动画的实现是使用了 framer-motion 这个库实现,我们需要实现在元素出现是有一个从下到上的渐入效果,元素消失时又一个从上到下的渐出效果。

最终实现的代码如下:

import { AnimatePresence, motion } from 'framer-motion';

<AnimatePresence>
    {isScroll && showScrollTip && (
      <motion.div
        style={{
          display: 'flex',
          justifyContent: 'center',
          alignItems: 'center',
          position: 'absolute',
          bottom: '74px',
          left: '0',
          backgroundImage:
            'linear-gradient(to bottom,rgba(255,255,255,0.6),rgba(255,255,255,1))',
          width: '100%',
        }}
        initial={{ opacity: 0, y: 10 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: 10 }}
        transition={{ duration: 0.2 }}
      >
        <Image src="/icons/scroll-tip.svg" />
      </motion.div>
    )}
  </AnimatePresence>

这里我通过 opacity 和 translateY 调整整个提示容器的样式动画实现了渐入渐出的效果,<AnimatePresence> 这个标签非常重要,它的作用是用于实现动画的渐出效果,为什么需要这个标签才能实现渐出效果呢?

当我们需要隐藏提示时,通过条件判断 react 会直接将这个 dom 节点删除,这时即便时你给了这个 dom 一个动画效果也没有用了,因为元素已经无了。如果想要实现渐出效果,就需要先触发渐出动画,然后在动画结束后再删除这个 dom 节点。比如当触发隐藏事件时先为节点添加隐藏的动画,然后设置一个定时器,时间为动画的持续时长,定时器时间一到就触发事件将元素给删除掉。

<AnimatePresence> 标签做的大致就是这样的一个事情,具体可以看官方文档的介绍www.framer.com/docs/animat…

最终实现的效果如下,可以看到加上动画后提示的出现和消失就很自然了。

temp.gif

性能优化

最后我们再优化一下这个小提示的性能,因为我们在判读内容滚动的方向时,使用了 onScroll 这个事件,当我们滚动窗口时,大概每个 10-20 ms 就会触发一次,这样的频率太高了用户也无法感知。如下图,我们只是滚动了一下就触发了近两百次滚动事件。

temp.gif

因此我们需要为滚动事件添加一个 节流 的效果,降低事件的触发频率,关于节流和防抖这里不展开介绍,主要讲讲我是怎么实现的。

由于我前面引入了 ahook 这个库获取元素的宽高,因此我们也可以直接使用 ahook 中 useThrottleFn 这个 hook 函数来快速实现一个节流效果,代码如下:

const { run: handleScroll } = useThrottleFn(
    (e: any) => {
      if (isScroll) {
        // Triggered when scrolling to 28px from the bottom
        if (
          e.target.scrollHeight - e.target.scrollTop - e.target.offsetHeight <=
          28
        ) {
          setShowScrollTip(false);
          // Triggered when scrolling up
        } else if (e.target.scrollTop < scrollTop) {
          setShowScrollTip(true);
        }
      }
      setScrollTop(e.target.scrollTop);
    },
    { wait: 200, leading: true }
  );

将原本的滚动事件作为 useThrottleFn 的第一个函数,第二个函数中 wait 为触发的频率,我设置为 200ms 触发一次,而 leading 则是在延迟开始前调用函数,避免让用户感觉到有粘滞感。

接下来我们在看看滚动事件触发的频率,可以看到现在的触发频率已经低了很多了,不至于一下子触发上百次,如下图:

temp.gif

总结

最终所有的实现代码如下:

import { useState, useRef, useMemo } from 'react';

import { Box, Image, ModalBody as ChakraModalBody } from '@chakra-ui/react';
import { useSize, useThrottleFn } from 'ahooks';
import { AnimatePresence, motion } from 'framer-motion';

const ModalBody: React.FC = ({ children }) => {
  const [scrollTop, setScrollTop] = useState(0);
  const [showScrollTip, setShowScrollTip] = useState(true);
  const contentRef = useRef(null);
  const contentSize = useSize(contentRef);

  const bodyRef = useRef(null);
  const bodySize = useSize(bodyRef);

  // Determine if the current container is scrollable
  const isScroll = useMemo(() => {
    if (
      contentSize?.height &&
      bodySize?.height &&
      contentSize!.height > bodySize!.height + 20
    ) {
      return true;
    }
    return false;
  }, [contentSize?.height, bodySize?.height]);

  const { run: handleScroll } = useThrottleFn(
    (e: any) => {
      if (isScroll) {
        // Triggered when scrolling to 28px from the bottom
        if (
          e.target.scrollHeight - e.target.scrollTop - e.target.offsetHeight <=
          28
        ) {
          setShowScrollTip(false);
          // Triggered when scrolling up
        } else if (e.target.scrollTop < scrollTop) {
          setShowScrollTip(true);
        }
      }
      setScrollTop(e.target.scrollTop);
    },
    { wait: 200, leading: true }
  );

  return (
    <>
      <ChakraModalBody
        maxHeight="596px"
        p="24px"
        ref={bodyRef}
        onScroll={handleScroll}
      >
        <Box ref={contentRef}>{children}</Box>
      </ChakraModalBody>
      <AnimatePresence>
        {isScroll && showScrollTip && (
          <motion.div
            style={{
              display: 'flex',
              justifyContent: 'center',
              alignItems: 'center',
              position: 'absolute',
              bottom: '74px',
              left: '0',
              backgroundImage:
                'linear-gradient(to bottom,rgba(255,255,255,0.6),rgba(255,255,255,1))',
              width: '100%',
            }}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 10 }}
            transition={{ duration: 0.2 }}
          >
            <Image src="/icons/scroll-tip.svg" />
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
};
export default ModalBody;

这个看似简单的小功能我在实现的过程中还是踩了很多坑的,其中不乏样式的调整或者功能库的选型和使用这种基础问题,希望大家看完文章能有所收获,如果有更好的实现方式也希望大家能多多指正!看完不妨点个赞👍