ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

数值滚动效果实现方案,支持可视范围判断

2021-09-22 12:02:19  阅读:157  来源: 互联网

标签:滚动 数字 可视 数值 height 组件 return


file

本文首发于:https://github.com/bigo-frontend/blog/ 欢迎关注、转载。

需求分析:

  • 主要有两点,滚动,以及可视范围的判断
  • 滚动即提供一个组件,接收两个参数from与to,组件实现从数字from变为数字to,变化过程为上下滚动
  • 可视范围的判断即,当组件位于可视范围时,才开始滚动
  • 其余实现要求包括,当组件渲染后,数字to的变化,会触发下一次滚动,用vue实现
  • 效果如下图

实现方案:

  • 首先拆解需求,999可以视为3个9组成的数字,所以一串数字的滚动,其实相当于一组单个数字滚动的组合,故首先实现单个数字滚动的效果,再组装成组件就能实现需求的效果
  • 单个数字的滚动,有点类似于机械时钟的翻页,其实现为在现有展示数字的背面,存在别的数字,在适当时机,机械会控制翻转到新的数字,旧的数字被掩盖在新的数字下方
  • 所以单个数字滚动,也可以用类似的方法,即准备从0到9十个数字,纵向排列,但可视范围只有一个数字的大小,称其为窗口,这十个数字都根据窗口定位,在数字变化的时候,改变其定位,并添加动画效果,就可以实现滚动的效果

样式

  • 根据上面的方案,我们可以写出下面的样式布局
/* html */
  <div class="_single-digi-scroller">
    <div class="placeholder" ref="myPlaceHolder">0</div>
    <div class="display-panel" ref="myDigiPanel">
      <div class="num">0</div>
      <div class="num">1</div>
      <div class="num">2</div>
      <div class="num">3</div>
      <div class="num">4</div>
      <div class="num">5</div>
      <div class="num">6</div>
      <div class="num">7</div>
      <div class="num">8</div>
      <div class="num">9</div>
    </div>
  </div>

/* style */
._single-digi-scroller {
  display: inline-block;
  position: relative;
  overflow: hidden;
  .placeholder {
    opacity: 0;
  }
  .display-panel {
    position: absolute;
  }
}
  • 这里留意display: inline-block;是为了使多个该组件组合时可以并排展示而不换行,overflow: hidden;则是保证可视范围,即上文提及的窗口,只有一个数字的大小
  • 另外值得注意的是,由于滚动区域display-panel是绝对定位的,这会导致其父元素高度坍塌而使得高度为0,所以html中添加了一个透明的placeholder,其作用是用来撑开组件高度的,这样我们就无需给组件另外设置高度,而让其与父元素的文本高度一致
  • 那么现在基本的单数字滚动组件结构就写好了,接下来给其添加滚动逻辑

滚动逻辑

  • 根据上面的样式,我们只需要改变display-panel的top属性,就可以改变其相对于窗口的位置,再加上transition的css样式,就会有动画效果,实现滚动
  • 首先我们需要知道数字从多少滚到多少,所以增加两个props,from与to,然后根据from与to计算top值,具体代码如下:
  props: {
    from: {
      type: [Number, String],
      default: 0
    },
    to: {
      type: [Number, String],
      default: 0
    },
    height: {
      type: [Number, String],
      default: 0
    },
    speed: {
      type: [Number, String],
      default: 2
    }
  },
  data: () => ({
    toPos: false,
    fromPos: false,
    transitionStyle: {}
  }),
  watch: {
    changeInfo: {
      immediate: true,
      handler(val) {
        if (val) {
          this.fromPos = { top: `-${val.from * this.height}px` };
          setTimeout(() => {
            // 不用nexttick是因为其间隔太小,浏览器未渲染就改变了pos,导致动画没生效
            this.toPos = { top: `-${val.to * this.height}px` };
            this.transitionStyle = { transition: `${this.speed}s` };
          }, 200);
        }
      }
    }
  },
  computed: {
    numStyle() {
      return {
        height: `${this.height}px`,
        lineHeight: `${this.height}px`
      };
    },
    panelStyle() {
      if (this.toPos) return { ...this.toPos, ...this.transitionStyle };
      if (this.fromPos) return { ...this.fromPos, ...this.transitionStyle };
      return {};
    },
    changeInfo() {
      if ((this.from || this.from === 0) && (this.to || this.to === 0)) {
        return { to: this.to, from: this.from };
      }
      return false;
    }
  }
  • 这里可以看到我们新增了一个changeInfo的computed属性,这是因为我们并不知道from和to哪个先接收到,所以为了保证计算top属性时,from和to都已经得到了,添加的这个属性
  • 然后changeInfo数值变化的时候,会触发watch的逻辑,首先根据from计算top属性,然后通过setTimeout回调计算to对应的top值,并增加动画样式
  • top值的计算很简单,默认一开始窗口展示的是0,这时top也是0,然后如果是别的数字,只需要用该数字乘以height然后取反即可,height即窗口高度,也即每个数字的高度,如3和0,相差3个数字,向上移三个数字窗口的高度就会显示3,也即top = -3 * height
  • 上述计算值最终汇总到panelStyle这个computed属性中,如果有to对应的值则先取to的,没有则取from的,由于第一次to的计算会晚于from,所以第一次必然先取的from对应的值,后续则都取的to对应的值,最后把panelStyle赋给display-panel,则滚动逻辑就完成了
  • 注意这里用到的this.height,这个值可以通过计算placeholder的clientHeight直接获得,但由于我们后续还要将多个单数字滚动组装在一起,如果每个单数字滚动组件都计算一边clientHeight,必然会造成性能的浪费,而组装后的单数字高度必然是相等的,所以可以统一在组装好的组件中计算,然后作为props传给各个单数字滚动组件
  • 同时为了保证display-panel中每个数字的高度都是一致的,我们计算了numStyle这个属性,并将其赋给display-panel里的每个数字
    <div class="display-panel" :style="panelStyle" ref="myDigiPanel">
      <div class="num" :style="numStyle">0</div>
      <div class="num" :style="numStyle">1</div>
      ...
    </div>

组装

/* html */
  <div class="_digi-scroller" 
    :style="{ height: `${height}px`, width: `${ changeInfo ? width * changeInfo.to.length : 0}px` }"
    ref="myDigiScroller"
  >
    <div class="placeholder" ref="myPlaceHolder">0</div>
    <div :style="{ left: `-${width}px` }" class="digi-zone" v-if="changeInfo">
      <single-digi-scroller
        class="single-digi"
        v-for="(digi, index) in changeInfo.to"
        :key="changeInfo.to.length - index"
        :from="changeInfo.from[index]"
        :to="digi"
        :height="height"
      />
    </div>
  </div>

/* style */
._digi-scroller {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
  .placeholder {
    display: inline-block;
    opacity: 0;
  }
  .digi-zone {
    position: relative;
    display: inline-block;
  }
}
  • single-digi-scroller就是前面的单数字滚动组件,与前面类似,placeholder也是用来撑起高度的,样式基本与单数字滚动组件类似,不同之处为display为inline-flex,主要是为了行内垂直对齐,white-space: nowrap;则保证不换行
  • 另外可以看到digi-zone上有一个left的属性,这是因为digi-zone是相对布局(这样才能撑起组件的宽度),而如前所说其左边有一个透明的占位符,故左移一个数字的位置将其覆盖,使digi-zone位于整个组件的最开始位置
  • 同时注意到这里给single-digi-scroller的key值为其index的倒序,这是由于vue会根据key值进行元素复用,而当数值位数增加或减少时,变化的位数为最高位,即假设从99变为100时,位数加一,新增的single-digi-scroller排在最开头,而如果key值等于index,则被复用的元素为第一和第二位,视觉上就会呈现出从990变化为100的效果(如下图所示),而不是099变为100,所以为了保证复用元素为后两位,这里key值要取倒序
  • 接下来一个个看每个属性的计算
  • height与width可以通过直接计算placeholder的高度与宽度值得到,其为data中的属性,在组件首次渲染完的时候计算
  mounted() {
    const { clientHeight, clientWidth } = this.$refs.myPlaceHolder;
    this.height = clientHeight;
    this.width = clientWidth;
  },
  • width是单个数字的宽度,用于计算整个组件的长度,单数字滚动组件的个数由changeInfo决定,changeInfo由接收的参数from与to计算得来,具体代码如下
  props: {
    from: {
      type: [Number, String],
      default: 0
    },
    to: {
      type: [Number, String],
      default: 0
    }
  },
  computed: {
    changeInfo() {
      if ((this.from || this.from === 0) && (this.to || this.to === 0)) {
        // from 和 to 都接收到了之后
        const len = Math.max(String(this.to).length, String(this.from).length);
        // eslint-disable-next-line prefer-spread
        const from = `${Array.apply(null, Array(len)).map(() => '0').join('')}${this.from}`.slice(-len);
        // eslint-disable-next-line prefer-spread
        const to = `${Array.apply(null, Array(len)).map(() => '0').join('')}${this.to}`.slice(-len);
        return { from, to };
      }
      return false;
    }
  },
  • 这里设置changeInfo的原因首先同前面一样,保证from和to都已经接收到,其次则是由于from和to位数不一定相同,所以需要计算最长的位数,然后较短的一方在其前面补零,保证changeInfo中from和to的位数是相同的
  • Array.apply(null, Array(len))是用来生成一组长度为len,元素都为undefined的数组
  • 这样处理完之后,我们将from和to切分成了单个数字,由于from和to处理完后位数相同,分别将对应位置的数字传入单数字滚动组件中,然后由单数字组件里的逻辑就可以开启滚动效果
  • 到这里,数值的滚动效果就实现了,其由多个单数字滚动组件组装而成

可视范围判断

  • 这里可以使用性能较好的IntersectionObserver进行实现,对于不支持该方法的系统,采用监听滚动的方法兜底
  • 首先判断系统是否支持IntersectionObserver
  checkObserverSupport() {
    return 'IntersectionObserver' in window
     && 'IntersectionObserverEntry' in window
     && 'intersectionRatio' in window.IntersectionObserverEntry.prototype;
  }
  • 新增一个inView的data属性,用来标记当前组件是否已抵达可视区域,以及listener用来记录IntersectionObserver的监听实例,在页面销毁时进行清除,故目前data如下:
  data: () => ({
    height: 0,
    width: 0,
    inView: false,
    listener: undefined,
    scrollTimer: undefined // 滚动监听节流用
  })
  • 在mounted中添加checkIntoView方法,对于支持IntersectionObserver方法的系统,通过实例化该方法来判断可视范围,当判断元素到达可视范围时,清除监听,标记inview为true,然后开始计算changeInfo,执行滚动
  checkIntoView() {
    if (this.checkObserverSupport()) {
      // eslint-disable-next-line no-unused-expressions
      this.listener && this.listener.disconnect();
      this.listener = new IntersectionObserver(
        entries => {
          const { intersectionRatio } = entries[0];
          if (intersectionRatio > 0) {
            this.listener.disconnect();
            console.log('intersection observer: digi scroller into view');
            this.listener = undefined;
            this.inView = true;
          }
        }
      );
      this.listener.observe(this.$refs.myDigiScroller);
    } else if (this.checkRectBounding()) {
      this.inView = true;
    } else {
      if (!this.scrollContainer) {
        this.scrollListenContainer = window;
      } else if (typeof this.scrollContainer === 'string') {
        this.scrollListenContainer = document.querySelector(this.scrollContainer);
      } else {
        this.scrollListenContainer = this.scrollContainer;
      }
      this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
      this.scrollListenContainer.addEventListener('scroll', this.checkIntoViewPollyfill);
    }
  }

/* computed */
  changeInfo() {
    if ((this.from || this.from === 0)
      && (this.to || this.to === 0)
      && this.inView // 只有this.inView为true时才会开始计算
    ) {
      ...
    }
  }
  • 对于不支持IntersectionObserver的系统,这里首先计算了checkRectBounding,该方法用于元素位于页面的相对位置,从而判断其是否在可视范围内,代码如下,对于已经在可视范围里的,直接执行滚动即可,而还不在可视范围的则进行滚动监听
  checkRectBounding() {
    if (!this.$refs.myDigiScroller) return false;
    const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
    const rect = this.$refs.myDigiScroller.getBoundingClientRect() || {};
    const { top } = rect;
    return +top <= viewPortHeight + 100; // 由于存在节流,这里判定范围扩大一点
  }
  • 回到checkIntoView,这里this.scrollContainer是一个props属性,用户可以指定监听的容器,其初始值为空,默认监听window的滚动
  • checkIntoViewPollyfill代码如下,为了避免重复监听,注册监听事件前,进行了remove操作,监听方法本质也是调用checkRectBounding,判断是否在可视范围,在可视范围的话,同上,对监听进行清除操作
  checkIntoViewPollyfill() {
    if (this.scrollTimer) return;
    const isInView = this.checkRectBounding();
    this.scrollTimer = setTimeout(() => { this.scrollTimer = undefined; }, 100); // 节流
    if (isInView) {
      console.log('scroll listener: digi scroller into view');
      this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
      // eslint-disable-next-line no-unused-expressions
      this.scrollTimer && clearTimeout(this.scrollTimer);
      this.scrollTimer = undefined;
      this.inView = true;
    }
  }
  • 最后,在页面销毁时,清除所有监听以及计时器,避免对别的页面产生影响
  beforeDestroy() {
    if (this.listener) {
      this.listener.disconnect();
      this.listener = undefined;
    }
    if (this.scrollListenContainer) {
      this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
    }
    // eslint-disable-next-line no-unused-expressions
    this.scrollTimer && clearTimeout(this.scrollTimer);
    this.scrollTimer = undefined;
  }

结语

  • 这样,一个具有滚动监听能力的数值滚动组件就实现了,只有当组件位于可视范围内的时候,才执行滚动的动画,比一渲染便执行滚动的组件,用户体验会更好一些,其可以放置在页面的任何位置。
  • 同时它也可以放置在文本段落里,因为其高度以及字体大小都由其父元素决定,外观上与纯文本是一致的。

欢迎大家留言讨论,祝工作顺利、生活愉快!

我是bigo前端,下期见。

标签:滚动,数字,可视,数值,height,组件,return
来源: https://blog.csdn.net/yeyeye0525/article/details/120412500

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有