0

让留言气泡动起来!

 2 years ago
source link: https://jelly.jd.com/article/630f1de83c3bd1006a1b8cda
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
让留言气泡动起来!
上传日期:2022.09.01
本文主要介绍 NutUI-Biz ScrollMessage 滚动留言组件的设计与实现,ScrollMessage 目前支持上下左右无缝滚动,弹幕滚动,并且为开发者提供了丰富的 API,包括文字、图标、插槽等,支持开发者自定义交互等。下面我为大家介绍组件的重点和难点功能。

让留言气泡动起来!

本文主要介绍 NutUI-Biz ScrollMessage 滚动留言组件的设计与实现,ScrollMessage 目前支持上下左右无缝滚动,弹幕滚动,并且为开发者提供了丰富的 API,包括文字、图标、插槽等,支持开发者自定义交互等。下面我为大家介绍组件的重点和难点功能。

滚动留言组件的诞生

在很多场景下我们需要展示留言板,留言板的内容是可以滚动的,也可以是动态刷新实时请求的,还有一些是根据用户的交互产生变化的数字。之前我们网站在数字发生变化时是用anime.js做的类似于这样的一种动画:

如下图:进入页面时气泡消息开始向上滚动,依次展示,持续冒泡至最后一条气泡消息触顶消失后。

11d36b920f7b79f2.png

实现思路分析

要实现上面的效果,我们先拆分下实现要素:

  • 1、留言气泡是从留言板区域的底部从下往上滚动,出于性能要求,如果不在屏幕内的,应该移除,不能无限追加到内存里面
  • 2、留言气泡要支持循环滚动

拆分完需求要素之后,针对上面的需求要素,做一下实现思路:

  • 1、对于留言气泡滚动,可以通过 JavaScript 动画来实现,例如定时器,监听当动画结束,则消除定时器。
  • 2、无限循环效果,可以使用两个链表实现,一个保存加入到屏幕的弹幕数据(A),另一个保存未添加到屏幕的弹幕数据(B)。让进入屏幕前将布局从B中poll出来,添加到A中。反之,屏幕移除的时候从A中poll出来,添加到B中。

滚动留言组件的实现

滚动的原理

滚动的原理是什么👇

11d36b920f7b79f2.png

wrapper 是父容器,它一定要有「固定高度」,content是内容区域,它是父元素的子元素,content会随着内容的大小撑开而撑高,只有这个高度大于wrapper父容器高度时,才会出现滚动,也就是它的原理。

一说到 JavaScript 动画,我们常常想到的方法是使用定时器来实现,原理类似电影一样,让元素在很短的时间内发生位移,然后在几毫秒后调用 setTimeout() 重复这个过程,我们看起来的话这个元素就像是在不停地运动。

这种方式的主要问题是虽然我们明确指定了精确的间隔时间,不过浏览器可能在忙于执行其他操作,我们的 setTimeout 可能无法及时在重绘时调用,会后延到下个周期。

这样是有问题的,因为发生了丢帧,下次动画时执行了2次,肉眼会注意到动画的卡顿。

除了用定时器传统的动画实现方式,我们还可以用 requestAnimationFrame ,是一个较新的浏览器 API。这个 API 不止用于动画,不过用处最多的地方还是动画。

requestAnimationFrame 与 setTimeout/setInterval 方法类似,区别是 setTimeout 是用户指定的,而 requestAnimationFrame 是浏览器刷新频率决定的,一般遵循 W3C 标准,它在浏览器每次刷新页面之前执行。对 CPU 友好的,动画会在当前窗口或者标签页不可见时停止运行。

从上面基本用法的描述中,我们可以得出在做动画时的性能上:requestAnimationFrame > setTimeout > setInterval,虽然 requestAnimationFrame 是新方法,不过兼容性还是很不错的:

11d36b920f7b79f2.png

对于不支持的浏览器,可以借助这个 兼容库

关于怎么让元素产生位移,通过 JavaScript 修改元素的 transform 就可以实现,例如

<div :style="styles" ref="wrapper">
    <div :style="swiperStyle" ref="swiperContainer">
      // 留言气泡内容
    </div>
</div>
const state = reactive({
    tranY: 0,  // 垂直方向的偏移量
    autoplayTimer: 0,
});
const swiperStyle = computed(() => {
    return {
      transform: `translateY(${state.tranY}px)`
    };
});

定义一个运动函数,这里的运动为匀速运动,因此比较简单,只需要一直+1即可。

// 运动函数
const wrapper = ref(); // 最外层容器
const scrollContainer = ref(); // 留言内容容器

state.wrapperHeight = wrapper.value.getBoundingClientRect().height;
state.scrollHeight = scrollContainer.value.getBoundingClientRect().height;

const move = ()=>{
    if (direction === "top") { // 上
      if(Math.abs(state.tranY) < (state.scrollHeight - state.wrapperHeight)) {
        state.tranY--;
      } else {
        emit('scrollStop');
      }
    } else if (direction === "bottom") { // 下
      if(state.tranY < 0) {
        state.tranY++;
      } else {
        emit('scrollStop');
      }
   }
};

运行这个函数就可以实现滚动啦。

move();

从最后一张到第一张或从第一张到最后一张时为了看起来像是直接滚动过去,通常会在尾部加入第一张的复制版,我们这个也不例外:

以向上滚动为例:

在可视区域内,list1向上滚动,假设刚好滚动到在可是区域内完全隐藏时(刚好滚动了一个dom的高度),达到滚动连接临界点。此时将滚动的位置设置为起始的位置,视觉上达到了一个无缝衔接的效果。如此往复,就达到了循环滚动的效果。

滚动连接临界点,如果y轴方向滚动距离的绝对值(tranY的滚动距离)小于等于滚动的起始位置时,就将y轴滚动的位置赋值为父容器高度的一半的负值。再触发外部的ScrollEnd方法。

11d36b920f7b79f2.png
const move = ()=>{
  state.autoplayTimer = state.moved && requestAnimationFrame(() => {
    let { direction } = props;
    if (direction === "top") { // 上
      if(props.loop) {
        if(Math.abs(state.tranY) > (state.scrollHeight/2)) {
          emit('scrollStop');
          state.tranY = 0; 
        } 
        state.tranY--;
      } else {
        ... 
      }
    } else if (direction === "bottom") { // 下
      if(props.loop) {
        if(state.tranY >= 0) {
          emit('scrollStop');
          state.tranY = (state.scrollHeight/2) * -1
        } 
        state.tranY++;
      } else {
        ... 
      }
    }
    move()
  });
};

先将单步滚动的定时器清除掉。在进入判断时宽度的单步滚动模式还是高度单步滚动模式。看判断是宽度还是高度单步滚动的两个if判断中的内容,是不是一模一样。所以不要怕干就完了。

同样的先判断realSingleStopHeight或realSingleStopWidth是否存在,再通过滚动的距离对realSingleStopHeight或realSingleStopWidth取余,看是否小于步长。如果小于就进入单步定时器进行等待滚动。否则直接调用move方法进行滚动。是不是很简单。

const move = ()=>{
  if (this.singleWaitTime) clearTimeout(this.singleWaitTime)
    // 是否启动了弹幕暂停配置
    if(scrollDistance && scrollMode == 'barrage') {
      if (Math.abs(state.tranY) % scrollDistance < step) { 
        // 符合条件暂停 waitTime
        state.barrageTime = setTimeout(() => {
          move()
        }, waitTime)
      } else {
        move()
      }
    } else {
      move()
    }
  });
};

以上就是几种滚动的方式了。不过还有一些其他的需求。比如,鼠标mouseover时,需要停止滚动,离开之后又要重新启动滚动。因为需求的变化,在移动端还需要能够滑动items.ul,手指松开之后继续滚动。因此我们需要一个区别pc于移动端的函数。通过UA的不同来区分。

在移动端,可以左右滑动,滑动时停止自动滚动,松开之后继续自动滚动。移动端的滑动事件,主要通过touchstart, touchmove, touchend来实现,与pc端的mousedown, mousemove, mouseup类似。

<div :style="styles" ref="wrapper">
    <div :style="swiperStyle" 
      ref="swiperContainer"
      @mouseenter="enter"
      @mouseleave="leave"
    >
      // 留言气泡内容
    </div>
</div>
const enter=()=>{
  stop()
},
const leave=()=>{
  start()
},
const start=()=>{
  if(props.data.length == 0) return;
  state.moved && move();
}
const stop = ()=>{
  state.moved = false;
};

还有一个关于数据方面的问题,留言的数据可能是多个接口返回回来的。所以在有些数据还没有返回时,就已经将dom复制出来,而复制出来的dom中的数据不是响应式的,所有就会出现数据时有时无的情况。

所以可以通过 watch 深度监听 data 的变化来更新数据。

watch(
    () => [...props.data],
    (val) => {
      if(val){
        // 初始化数据
        start();
      }
    }
 );

在前面的循环滚动中,因为复制了一份相同的dom,发现点击事件失效了。

使用 @click="scrollClick($event)"的方式在外层父元素上添加点击事件来获取点击的子dom的方式,再获取其中的数据来进行下一步的事件处理。

在要监听点击事件的子dom中的处理

const scrollClick(e)=> {
    // 通过 e.target 获取点击的dom
    let data = e.target.dataset;
    this.$emit("supervise-detail", data.name, data.type);
}
<span
  :title="itemObj.number2"
  :data-name="itemObj.name"
  :data-type="'all'"
  >/{
  { itemObj.number2 }}
</span>
</span>

这里使用 *data-属性 *的方式来进行属性绑定,以方便父容器的点击事件中获取该属性。这样就可以通过其中的属性来处理相应的事件内容了。

11d36b920f7b79f2.png

以上就是滚动留言组件的实现思路和重点问题,欢迎大家使用 NutUI-Biz (基于 NutUI 3.0 的移动端 业务组件库 ),我们会持续迭代和优化 NutUI,最后祝大家越来越优秀,NutUI 用起来!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK