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


    什么是模态组件?

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

    模态组件的问题?

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

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

    请添加图片描述

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

    请添加图片描述

    请添加图片描述

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

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

    请添加图片描述

    解决方式?

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

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

    请添加图片描述

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

    实现过程

    需求梳理

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

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

    下文中的演示代码都是在 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>
     
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

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

    请添加图片描述

    逻辑实现

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

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

    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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    容器的高度我们通过 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);
        }
       }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    这一步定义的一个新变量 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>
    )}
     
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    请添加图片描述

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

    动画实现

    动画的实现是使用了 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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

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

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

    标签做的大致就是这样的一个事情,具体可以看官方文档的介绍https://www.framer.com/docs/animate-presence/

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

    请添加图片描述

    性能优化

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

    请添加图片描述

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

    由于我前面引入了 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 }
      );
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

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

    请添加图片描述

    总结

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

    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83

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

  • 相关阅读:
    2013年11月10日 Go生态洞察:Go语言四周年回顾
    酷宇宙观点:万物金融化,定义下一个金融服务时代
    java计算机毕业设计供求信息网MyBatis+系统+LW文档+源码+调试部署
    【FLASH存储器系列十】ONFI数据接口的时序参数与时序图
    Moment.js 如何对时间进行比较获得不同的天数
    同源策略和跨域问题
    如何快速通过pmp考试,求攻略?
    JVM 访问对象的两种方式
    搭建Nacos集群
    Go语言面经进阶10问
  • 原文地址:https://blog.csdn.net/weixin_47077674/article/details/126002244