Android 实现数字向上滚动动画效果 屏幕数字滚动_html

需求分析:

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



Android 实现数字向上滚动动画效果 屏幕数字滚动_sed_02


实现方案:

  • 首先拆解需求,999可以视为3个9组成的数字,所以一串数字的滚动,其实相当于一组单个数字滚动的组合,故首先实现单个数字滚动的效果,再组装成组件就能实现需求的效果
  • 单个数字的滚动,有点类似于机械时钟的翻页,其实现为在现有展示数字的背面,存在别的数字,在适当时机,机械会控制翻转到新的数字,旧的数字被掩盖在新的数字下方


Android 实现数字向上滚动动画效果 屏幕数字滚动_javascript_03


  • 所以单个数字滚动,也可以用类似的方法,即准备从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值要取倒序


Android 实现数字向上滚动动画效果 屏幕数字滚动_css_04


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