概述
本篇筆者來(lái)講解一下 index-bar 和 index-anchor 的實(shí)現(xiàn)原理和細(xì)節(jié)處理,以及結(jié)合實(shí)際場(chǎng)景會(huì)對(duì)其進(jìn)行拓展,來(lái)實(shí)現(xiàn)Wechat通訊錄相似的功能,保證讓index-bar變得更加生動(dòng)有趣,滿足更多的業(yè)務(wù)場(chǎng)景。當(dāng)然筆者會(huì)結(jié)合自身的理解,已經(jīng)為每個(gè)核心的方法增加了必要的注釋,會(huì)盡最大努力將其中的原理講清楚,若有不妥之處,還望不吝賜教,歡迎批評(píng)指正。
預(yù)覽

層級(jí)結(jié)構(gòu)
index-bar :主要由 內(nèi)容 和 van-index-bar__sidebar組成,van-index-bar__sidebar 主要就是用來(lái) 點(diǎn)擊或者觸摸滑動(dòng) 來(lái)滾動(dòng)到指定的錨點(diǎn)(index-anchor).
index-anchor :主要由一個(gè) div 包裹著一個(gè) van-index-anchor,其中 van-index-anchor 如果 吸頂 了會(huì)變成 fixed 定位,以及包裹他的父元素( div )會(huì)設(shè)置高度,用于彌補(bǔ)其脫離文檔流后的高度。
實(shí)現(xiàn)原理
筆者覺(jué)得 index-bar 中最核心的地方,在于滾動(dòng)過(guò)程中,錨點(diǎn)的吸頂?shù)奶幚?。其中主要包括:獲取哪個(gè)活躍的錨點(diǎn)將要吸頂,以及上一個(gè)活躍的錨點(diǎn)如何退場(chǎng)等。所以我們把核心點(diǎn)關(guān)注在:index-bar 所處的滾動(dòng)容器 scroller 的滾動(dòng)事件上。
mixins: [
TouchMixin,
ParentMixin('vanIndexBar'),
BindEventMixin(function (bind) {
// bind: on/off 函數(shù)
if (!this.scroller) {
this.scroller = getScroller(this.$el);
}
bind(this.scroller, 'scroll', this.onScroll);
}),
],
onScroll() {
if (isHidden(this.$el)) {
return;
}
// 獲取滾動(dòng)容器的scrollTop
const scrollTop = getScrollTop(this.scroller);
// 返回滾動(dòng)容器元素的大小及其相對(duì)于視口的位置 因?yàn)闈L動(dòng)容器可能不是 window/body,而且也有可能距離視口頂部有一段距離
const scrollerRect = this.getScrollerRect();
// 計(jì)算每一個(gè)錨點(diǎn)在滾動(dòng)容器中的具體位置 top/height
const rects = this.children.map((item) =>
item.getRect(this.scroller, scrollerRect)
);
// 獲取當(dāng)前活躍的錨點(diǎn)
const active = this.getActiveAnchorIndex(scrollTop, rects);
this.activeAnchorIndex = this.indexList[active];
if (this.sticky) {
this.children.forEach((item, index) => {
// 由于要設(shè)置 active 和 active-1 錨點(diǎn)的 fixed 屬性,所以要把其,父容器的寬高 繼承過(guò)來(lái)
if (index === active || index === active - 1) {
const rect = item.$el.getBoundingClientRect();
item.left = rect.left;
item.width = rect.width;
} else {
item.left = null;
item.width = null;
}
// 核心代碼
if (index === active) {
// 這里錨點(diǎn)已經(jīng)是 fixed 定位
item.active = true;
// 計(jì)算top: 由于錨點(diǎn) fixed 定位的 top為0,這里設(shè)置的top 是用于設(shè)置自身錨點(diǎn)的transform.y
// rects[index].top 是相對(duì)于滾動(dòng)容器的位置,是固定值
// scrollTop: 是變量,向上滾動(dòng) 增大, 向下滾動(dòng) 減小
item.top =
Math.max(this.stickyOffsetTop, rects[index].top - scrollTop) +
scrollerRect.top;
} else if (index === active - 1) {
// 由于涉及到上一個(gè)活躍錨點(diǎn) 會(huì)被新的活躍錨點(diǎn) 隨著滾動(dòng)而頂?shù)? const activeItemTop = rects[active].top - scrollTop;
// 是否活躍:當(dāng)活躍的錨點(diǎn)的頂部正好和滾動(dòng)容器的頂部重合
item.active = activeItemTop > 0;
// 設(shè)置其top
item.top = activeItemTop + scrollerRect.top - rects[index].height;
} else {
item.active = false;
}
});
}
},
// 獲取有效的錨點(diǎn)索引
getActiveAnchorIndex(scrollTop, rects) {
// 細(xì)節(jié):從后往前遍歷 找到第一個(gè)滿足條件的錨點(diǎn)退出即可
for (let i = this.children.length - 1; i >= 0; i--) {
// 取出上一個(gè)活躍(吸頂)錨點(diǎn)的高度
const prevHeight = i > 0 ? rects[i - 1].height : 0;
const reachTop = this.sticky ? prevHeight + this.stickyOffsetTop : 0;
// 判斷某個(gè)錨點(diǎn)第一次進(jìn)入臨界值 這里計(jì)算的都是相對(duì) 滾動(dòng)容器 來(lái)計(jì)算的 所以是統(tǒng)一坐標(biāo)系
if (scrollTop + reachTop >= rects[i].top) {
return i;
}
}
return -1;
},
Web Api
筆者在看源碼的時(shí)候,發(fā)下了比較好用的API,很好的減輕了許多復(fù)雜邏輯處理,特此分享一下,希望大家多去 MDN Web Docs 翻翻好用的API。筆者列出的API,會(huì)的你就當(dāng)做復(fù)習(xí),不會(huì)的API,你就權(quán)當(dāng)學(xué)習(xí)啦。
拓展
美中不足的是 Vant 大大提供的 index-bar 和 index-anchor 只能滿足一些基本所需,一些定制化的需求,比如微信通訊錄、手機(jī)通訊錄等樣式,還不能提供友好的支撐,筆者這里站在巨人的肩膀上,手把手教大家實(shí)現(xiàn)Wechat通訊錄相似的功能。以及為index-bar增加更多的特性和拓展性。
而且,本次涉及的拓展,只是UI層面的東西,不會(huì)更改vant提供的核心原理(onScoll)的內(nèi)容,所以,咋們只關(guān)注UI相關(guān)的東西即可。Let's get it...
微信通訊錄
特性
- 微信通訊錄的
index-bar增加了點(diǎn)擊或者觸摸tag,會(huì)在tag左側(cè)彈出一個(gè)hint,且松手后,會(huì)回到index-bar最大能吸頂?shù)?code>tag和anchor。 - 可以設(shè)置
tag觸摸或點(diǎn)擊,不彈出hint,比如搜索??``tag。 -
tag以及hint能支持用戶自定義,即提供插槽。
實(shí)現(xiàn)
針對(duì)特性一,我們需要監(jiān)聽(tīng)用戶的touchstart、touchmove、touchend、touchcancel觸摸事件,并且要知道當(dāng)前是觸摸index-bar的狀態(tài),還是滾動(dòng)內(nèi)容的狀態(tài),因?yàn)樯婕暗侥膫€(gè)index-bar上哪個(gè)tag高亮。具體代碼如下:
// 開(kāi)始觸摸
onTouchStart(event) {
// 正在觸摸
this.isTouching = true
// 調(diào)用touch start方法
this.touchStart(event)
// 處理事件
this.handleTouchEvent(event)
},
// 正在觸摸
onTouchMove(event) {
this.touchMove(event);
if (this.direction === 'vertical') {
// 阻止默認(rèn)事件
preventDefault(event);
// 處理touch事件
this.handleTouchEvent(event)
}
},
// 結(jié)束或取消touch
onTouchEnd() {
this.active = null;
// 結(jié)束觸摸
this.isTouching = false
},
// 觸摸事件處理
handleTouchEvent(event){
const { clientX, clientY } = event.touches[0];
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/elementFromPoint
// 獲取點(diǎn)擊的元素
const target = document.elementFromPoint(clientX, clientY);
if (target) {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
// const { index } = target.dataset;
const index = this.findDatasetIndex(target)
/* istanbul ignore else */
if (index && this.touchActiveIndex !== index) {
this.touchActiveIndex = index;
// 記錄手指觸摸下的索引
this.touchActiveAnchorIndex = index
this.scrollToElement(target);
}
}
},
// 渲染索引
renderIndexes(){
return this.indexList.map((index) => {
// const active = index === this.activeAnchorIndex;
// 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);
const ignore = this.ignoreTags.some((value) => {
return value === index
})
return (
<span
class={bem('index', { active })}
style={active ? this.highlightStyle : null}
data-index={index}
>
{this.renderIndexTag(index, active, ignore)}
{this.renderIndexHint(index, active, ignore)}
</span>
);
});
},
這里涉及到 isTouching 的設(shè)置,以及touchActiveAnchorIndex的記錄,這里會(huì)后面渲染索引列表中哪個(gè)tag高亮做準(zhǔn)備。
// 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);
在tag左側(cè)彈出一個(gè)hint,利用子絕父相布局,這個(gè)功能比較好實(shí)現(xiàn)。即:一個(gè)父元素tag,就會(huì)對(duì)應(yīng)一個(gè)子元素hint。然后哪個(gè)tag是active并且isTouching = true時(shí),其子元素hint就會(huì)彈出。
針對(duì)特性二,點(diǎn)擊某個(gè)tag,不彈出hint,這個(gè)功能也比較簡(jiǎn)單,在index-bar的props新增一個(gè)屬性,類型為string[] | number[],ignoreTags:忽略的Tags,這些忽略Tag, 不會(huì)高亮顯示,點(diǎn)擊或長(zhǎng)按 不會(huì)彈出 tagHint。
// 這里區(qū)分一下 按下和松手 這兩個(gè)狀態(tài)的 活躍索引
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);
// 去ignoreTags中查找,這個(gè)tag是否被忽略
const ignore = this.ignoreTags.some((value) => {
return value === index
})
針對(duì)特性三,我們只需要為tag和hint提供一個(gè)具名插槽,并且拋出一個(gè)帶index,active,ignore三個(gè)參數(shù)的對(duì)象即可。這樣就可以滿足用戶的自定義了。具體代碼如下
// 渲染索引tag
renderIndexTag(index, active, ignore) {
// 有插槽
const slot = this.slots('tag', { index, active, ignore });
if (slot) {
return slot
}
// 默認(rèn)狀態(tài)下的樣式
const style = {}
// 活躍狀態(tài)且不忽略的場(chǎng)景下
if (active&&!ignore) {
if (this.highlightColor) {
style.color = this.highlightColor;
}
if (this.highlightBackgroundColor) {
style.backgroundColor = this.highlightBackgroundColor;
}
}
return <span style={style} data-index={index}>{index}</span>
},
// 渲染索引Hint
renderIndexLeftHint(index, active, ignore) {
// 顯示hint的場(chǎng)景
const show = active && this.isTouching && !ignore
// 獲取插槽內(nèi)容
const slot = this.slots('hint', { index, active, ignore });
if (slot) {
return show ? slot : ''
}
// 默認(rèn)場(chǎng)景
return (
<div vShow={show} class={bem('hint','pop')}>
<span>{index}</span>
</div>
)
}
如果用戶使用tag插槽的場(chǎng)景下,這里有個(gè)比較細(xì)節(jié)的地方,對(duì)于renderIndexTag,默認(rèn)不使用插槽時(shí),其內(nèi)容如下:<span style={style} data-index={index}>{index}</span> 這里我們可以看到這里有個(gè)data-index={index},因?yàn)?code>tag點(diǎn)擊事件或者sidbar觸摸事件,獲取對(duì)應(yīng)的索引都是通過(guò)const { index } = element.dataset;去獲取索引的,但是如果用戶自定義tag時(shí),用戶不會(huì)知道還要傳個(gè)data-index={index},導(dǎo)致傳統(tǒng)的方法const { index } = element.dataset;獲取的index為空。導(dǎo)致點(diǎn)擊無(wú)效。
解決辦法就是在tag的父元素身上也添加一個(gè)data-index={index},如果用戶在自定義tag傳了data-index={index},則使用用戶傳的index;反之,則使用其父元素提供的index。具體方法如下:
// 查詢dataset index
findDatasetIndex(target) {
if (target) {
const { index } = target.dataset;
if (index) {
return index
}
return this.findDatasetIndex(target.parentElement)
}
return undefined
},
手機(jī)通訊錄
手機(jī)通訊錄跟微信通信錄,可謂是如出一轍,唯一不同的就是,tagHint彈出的位置不同罷了,前者居中彈出,而后者是tag左側(cè)彈出。大家可能第一時(shí)間想到的就是依葫蘆畫(huà)瓢,把微信通訊錄的hint的position: absolute;改成position: fixed;不就可以了么?理想很豐滿,現(xiàn)實(shí)很骨感 我只能這么說(shuō)!
由于van-index-bar__sidebar的css設(shè)置了transform: translateY(-50%);導(dǎo)致其子元素設(shè)置的position: fixed;都會(huì)失效。所以我們采用的是將hint放在van-index-bar中去即可。關(guān)鍵代碼如下:
// 渲染索引中間Hint
renderIndexCenterHint() {
if (this.hintType !== 'center') {
return null
}
const index = this.touchActiveAnchorIndex
const active = index !== null
const ignore = this.ignoreTags.some((value) => {
return value === index
})
// 顯示hint的場(chǎng)景
const show = active && this.isTouching && !ignore
// 獲取插槽內(nèi)容
const slot = this.slots('hint', { index, active, ignore });
if (slot) {
return show ? slot : ''
}
// 默認(rèn)場(chǎng)景
return (
<div vShow={show} class={bem('hint','pop-center')}>
<span>{index}</span>
</div>
)
}
// UI層
render() {
const Indexes = this.renderIndexes()
const centerHint = this.renderIndexCenterHint()
return (
<div class={bem()}>
<div
class={bem('sidebar')}
style={this.sidebarStyle}
onClick={this.onClick}
onTouchstart={this.onTouchStart}
onTouchmove={this.onTouchMove}
onTouchend={this.onTouchEnd}
onTouchcancel={this.onTouchEnd}
>
{Indexes}
</div>
{this.slots('default')}
{centerHint}
</div>
);
}
期待
- 文章若對(duì)您有些許幫助,請(qǐng)給個(gè)喜歡??,畢竟碼字不易;若對(duì)您沒(méi)啥幫助,請(qǐng)給點(diǎn)建議??,切記學(xué)無(wú)止境。
- 針對(duì)文章所述內(nèi)容,閱讀期間任何疑問(wèn);請(qǐng)?jiān)谖恼碌撞吭u(píng)論指出,我會(huì)火速解決和修正問(wèn)題。
- GitHub地址:https://github.com/CoderMikeHe
- 源碼地址:vant-learn