用Vue实现一个拥有“很丝滑的”首字母索引滑动定位功能的通讯录

7,649 阅读8分钟

前方预警

本文可能有超过10MB的动图。

前言

一些类似于通讯录、商品列表等组件,都会拥有一个基于每一项的首字母快速索引定位的功能,如大家手机上的通讯录、微信通讯录、某易车网的车品牌选择列表。在移动端上,对于需要快速定位的场景,我觉得这是个十分必要的功能。但最重要的不是能够通过点击去定位,因为一般索引的位置不会太大,人类胡萝卜大小的手指(并且通常是拇指)很难能够在屏幕上精准定位,单纯的点击显然不是一个好的交互。而更好的做法是能够对索引列表进行滑动选取。这样会比单纯点击来得爽快且效率更高。

image

因为自己最近在完成自己的毕业设计,做的是一个用Websocket的IM工具,也涉及到了这个需求,便了解了一下实现的机理,在此分享给各位大佬。

本人技术过菜,如有不妥,往指出,感谢。

技术栈

用了Vue.js

初始布局与位置计算

布局

一般来说,通讯录的布局都是主体好友列表+固定在屏幕右方的字母索引列表。

<template>
  <div class="address-book" ref="addressBook">
    <dl
      class="letter-list"
    >
      <dt
        v-for="item in letters"
        :key="item.letter"
        :data-offset="item.top"
      >
        {{ item.letter }}
        <span class="select-bubble">
          {{ item.letter }}
        </span>
      </dt>
    </dl>
    <div ref="letterBlocks">
      <div
        class="letter-block"
        :data-letter="item.letter"
        v-for="item in letters"
        :key="item.letter"
      >
        <div class="header">{{ item.letter }}</div>
        <div class="body">
          <div class="item" v-for="f in item.friends" :key="f.id">
            <img class="avatar" :src="f.avatar" />
            <span>{{ f.username }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

数据处理

一开始data只有两个属性:

// 记录每个字母所拥有的好友、首字母以及距顶部偏移量
// {
//   top: number,
//   friends: Array<FriendItem>,
//   letter: string
// }
letters: [],
friends: []

其中,friends代表原始数据,从数据库拿过来的一项一项不按字母排序的数据,letters代表经过数据处理后根据字母顺序排序的项,每一项中保存着当前项的自身高度height距顶部的距离top首字母letter以及对于当前首字母的好友列表friends

image

其中自身高度height距顶部的距离top用于为后续渲染提供便利。

模拟数据

这个布局相对来说比较简单,为了更好地展示效果,通过一个函数随机模拟好友列表信息,把整个页面撑开。

mockData() {
  for (let i = 0; i < 200; ++i) {
    const f = {
      id: i,
      username:
        LetterMap[parseInt(Math.random() * LetterMap.length)] +
        "开头测试名字",
      avatar: testAvatar
    };
    this.friends.push(f);
  }
}

处理数据

为了能让原始数据满足于上面所说的那样,我们需要对数据进行处理。首先遍历所有数据,拿到首字母,再把该数据放到letters中对应的字母的子数组中。对于A-Z的字母开头的名称,我们分别放到对应字母,对于其他符号、数字,这里统一放到#中。

    /**
     * 初始化数据,让通讯录中的人按首字母分离
     */
    init() {
      let letters = []; // 数据列表
      let friendsMap = {}; // 每一个索引指向一个数组,这个数组存储该字母索引的朋友项
      this.friends.forEach(friend => {
        let firstLetter = friend.username[0].toUpperCase();
        if (!AlphabetMap.includes(firstLetter)) {
          firstLetter = "#";
        }
        friendsMap[firstLetter] || (friendsMap[firstLetter] = []);
        friendsMap[firstLetter].push(friend);
      });

      for (let letter in friendsMap) {
        letters.push({
          letter: letter,
          top: 0,
          height: 0,
          friends: friendsMap[letter]
        });
      }

      // 按首字母排序
      // 字符串比较不能用减号得出大小,返回都是NaN
      // 因此这里需要通过小于号比较
      letters.sort((a, b) => {
        return a.letter <= b.letter ? -1 : 1;
      });

      this.letters = letters;
    },

当我们完成布局、模拟数据、处理数据后,目前的布局效果是这样的,但它并没有什么功能:

image

然后我们进行DOM位置的计算。

计算元素位置

通过之前的布局,我把通讯录按字母分块,每一块的高度可以通过getBoundingClientRect得出,对于距离顶部的高度top,我使用了上一块高度+上一块top的方法计算得出,这样,从第一块开始计算,便可以获得某个字母块相对于整个文档所在的位置。

image

    /**
     * 计算位置信息
     */
    calculateLocation() {
      this.$nextTick(() => {
        // 按字母排序分开的 dom 列表
        let list = [].slice.call(this.$refs.letterBlocks.children, 0);
        let len = list.length;
        // 对于每一个块计算距顶部的距离top 以及 通过getBoundingClientRect计算自身的高度
        // 顶部的距离 top = 上一块的高度 + 上一块的top值
        list.forEach((node, idx) => {
          if (idx !== -1) {
            this.letters[idx].top =
              idx > 0
                ? this.letters[idx - 1].top + this.letters[idx - 1].height
                : 0;
            this.letters[idx].height = node.getBoundingClientRect().height;
          }
        });
      });
    },

image

功能需求

功能一:点击定位

通过点击索引定位块的位置是最方便的,因为我们在计算位置的时候已经计算出每个块的高度的,直接通过scrollTop = top定位即可。

    <dl
      class="letter-list"
    >
      <dt
        v-for="item in letters"
        :key="item.letter"
        :data-offset="item.top"
        @click="go(item.top)"
      >
        {{ item.letter }}
        <span class="select-bubble">
          {{ item.letter }}
        </span>
      </dt>
    </dl>
go(offset) {
    this.$refs.addressBook.scrollTop = offset;
},

功能二:滑动定位

触屏滑动很容易想到touchmove事件,但是对于touchmove事件,target是固定的,也就是说,我们从屏幕上不管怎么滑动,单纯通过touchmove的事件对象是无法获得滑动经过的DOM元素的。所以这里要结合滑动定位的关键API:document.elementFromPoint(x, y),通过传入坐标可以获取当前坐标的DOM元素,按照习惯,接触一个之前没怎么用过的API前都要先查查兼容性,结果发现,顶上一片绿:

image

之前从布局上可以看到,我们在索引列表的每一个DOM上添加了一个自定义属性,记录该字母对应的块的top值,这样,我们通过document.elementFromPoint获取到元素后,就可快速取得top的值,最后通过go(offset),也就是scrollTop去定位高度。

    <dl
      class="letter-list"
      @touchmove="handleTouchMove"
    >
      <dt
        v-for="item in letters"
        :key="item.letter"
        :data-offset="item.top"
        @click="go(item.top)"
      >
        {{ item.letter }}
        <span class="select-bubble">
          {{ item.letter }}
        </span>
      </dt>
    </dl>
    handleTouchMove(e) {
      const x = e.touches[0].clientX;
      const y = e.touches[0].clientY;
      let target = document.elementFromPoint(x, y);
      let offset = target && target.dataset && target.dataset.offset;
      if (offset) {
        this.go(offset);
      }
    }

这样,就可以顺利进行滑动定位了:

image

但是仔细会发现,会出现一个问题,就是当我们的位置偏出了索引栏,那么滑动就会卡住,因为偏出索引栏的时候,通过document.elementFromPoint(x, y)获取到的DOM就不是索引栏中的项,而是其他DOM,因此也没有offset,所以会出现卡住的情况。

image

为了解决这种情况,我们需要在handleTouchMove初次定位的时候固定横坐标的位置,这样,当我们开始滑动索引之后,不管手指怎么偏离,滑动定位依然是有效的。所以,这里通过添加一个变量和添加touchend事件的处理解决,同时,为了减少scrollTop的触发次数,也可以添加一个prevOffset记录上次的top值,要是值相等,就不需要调用了。

所以,结合两者,整理后的代码是这样的:

    handleTouchEnd() {
      // 上次的top值
      this.prevOffset = -9999;
      // 是否正在索引栏滑动
      this.selectingLetter = false;
    },
    handleTouchMove(e) {
      if (!this.selectingLetter) {
        this.indexBarPosX = e.touches[0].clientX;
      }
      this.selectingLetter = true;
      const x = this.indexBarPosX;
      const y = e.touches[0].clientY;
      let target = document.elementFromPoint(x, y);
      let offset = target && target.dataset && target.dataset.offset;
      if (offset && offset !== this.prevOffset) {
        this.prevOffset = offset;
        this.go(offset);
      }
    }

更人性化的视觉

当我们滑动的时候,我们可能不会注意到主体部分的变动“我究竟滑到哪里了?”,在真正的移动端场景中,我们无情的大拇指可能已经把索引栏上的几个字母都遮住了,这时候,提供更抓眼的提示显然是前端的工作,所以这里我就把字母单独成一个方法的泡泡,当滑动时会有提示,并且把索引的字母放大,让整个界面有一点动画感觉,不会死死的。

image

移动端浏览器的小问题

当我准备通过微信发送给朋友装一个逼的时候,我发现微信打开网页的时候,会自带一个橡皮筋的下拉效果,这样会造成索引滑动的卡顿。然后我又试了试其他浏览器,发现,虽然没有橡皮筋效果,但是,一般浏览器都会加一个下拉刷新的操作,这样也会阻断索引的滑动。这是我之前没有遇到过的,因为之前一直在Chrome下调试,Chrome表现得一直没有问题。

image

通过万能的搜索引擎会发现,这些浏览器都会给网页添加一个touchmove事件的处理,用于判断用户的滑动操作。现在,为了解决这个问题,需要的是当滑动索引栏的时候屏蔽掉这个默认事件。一开始我是通过滑动索引栏时屏蔽document.bodytouchmove事件,但是发现并不行,浏览器添加的touchmove事件依旧没有屏蔽掉。最后我发现,即使touchmove事件触发的对象不是在body上,下拉刷新\橡皮筋等依然会触发,那也许对于DOM上的任何元素,都会有一个默认的touchmove事件处理?然后通过屏蔽掉索引栏的touchmove默认事件,解决的这个问题。

现在,在微信上已经没有橡皮筋效果,对于其他浏览器,我用夸克和Chrome测试过,都没有问题。

image

完整的代码

github.com/logcas/Addr…

完整的代码参考在这里,不知道能不能帮到你呢?如果不能,也感谢你阅读到这里。要是能点个赞,就是给本日常切图仔的最大鼓励了。

演示地址

image