前方预警
本文可能有超过10MB的动图。
前言
一些类似于通讯录、商品列表等组件,都会拥有一个基于每一项的首字母快速索引定位的功能,如大家手机上的通讯录、微信通讯录、某易车网的车品牌选择列表。在移动端上,对于需要快速定位的场景,我觉得这是个十分必要的功能。但最重要的不是能够通过点击去定位,因为一般索引的位置不会太大,人类胡萝卜大小的手指(并且通常是拇指)很难能够在屏幕上精准定位,单纯的点击显然不是一个好的交互。而更好的做法是能够对索引列表进行滑动选取。这样会比单纯点击来得爽快且效率更高。
因为自己最近在完成自己的毕业设计,做的是一个用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。
其中自身高度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;
},
当我们完成布局、模拟数据、处理数据后,目前的布局效果是这样的,但它并没有什么功能:
然后我们进行DOM位置的计算。
计算元素位置
通过之前的布局,我把通讯录按字母分块,每一块的高度可以通过getBoundingClientRect得出,对于距离顶部的高度top,我使用了上一块高度+上一块top的方法计算得出,这样,从第一块开始计算,便可以获得某个字母块相对于整个文档所在的位置。
/**
* 计算位置信息
*/
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;
}
});
});
},
功能需求
功能一:点击定位
通过点击索引定位块的位置是最方便的,因为我们在计算位置的时候已经计算出每个块的高度的,直接通过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前都要先查查兼容性,结果发现,顶上一片绿:
之前从布局上可以看到,我们在索引列表的每一个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);
}
}
这样,就可以顺利进行滑动定位了:
但是仔细会发现,会出现一个问题,就是当我们的位置偏出了索引栏,那么滑动就会卡住,因为偏出索引栏的时候,通过document.elementFromPoint(x, y)获取到的DOM就不是索引栏中的项,而是其他DOM,因此也没有offset,所以会出现卡住的情况。
为了解决这种情况,我们需要在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);
}
}
更人性化的视觉
当我们滑动的时候,我们可能不会注意到主体部分的变动“我究竟滑到哪里了?”,在真正的移动端场景中,我们无情的大拇指可能已经把索引栏上的几个字母都遮住了,这时候,提供更抓眼的提示显然是前端的工作,所以这里我就把字母单独成一个方法的泡泡,当滑动时会有提示,并且把索引的字母放大,让整个界面有一点动画感觉,不会死死的。
移动端浏览器的小问题
当我准备通过微信发送给朋友装一个逼的时候,我发现微信打开网页的时候,会自带一个橡皮筋的下拉效果,这样会造成索引滑动的卡顿。然后我又试了试其他浏览器,发现,虽然没有橡皮筋效果,但是,一般浏览器都会加一个下拉刷新的操作,这样也会阻断索引的滑动。这是我之前没有遇到过的,因为之前一直在Chrome下调试,Chrome表现得一直没有问题。
通过万能的搜索引擎会发现,这些浏览器都会给网页添加一个touchmove事件的处理,用于判断用户的滑动操作。现在,为了解决这个问题,需要的是当滑动索引栏的时候屏蔽掉这个默认事件。一开始我是通过滑动索引栏时屏蔽document.body的touchmove事件,但是发现并不行,浏览器添加的touchmove事件依旧没有屏蔽掉。最后我发现,即使touchmove事件触发的对象不是在body上,下拉刷新\橡皮筋等依然会触发,那也许对于DOM上的任何元素,都会有一个默认的touchmove事件处理?然后通过屏蔽掉索引栏的touchmove默认事件,解决的这个问题。
现在,在微信上已经没有橡皮筋效果,对于其他浏览器,我用夸克和Chrome测试过,都没有问题。
完整的代码
完整的代码参考在这里,不知道能不能帮到你呢?如果不能,也感谢你阅读到这里。要是能点个赞,就是给本日常切图仔的最大鼓励了。