Vant 源码解析——IndexBar

概述

本篇笔者来讲解一下 index-barindex-anchor 的实现原理和细节处理,以及结合实际场景会对其进行拓展,来实现Wechat通讯录相似的功能,保证让index-bar变得更加生动有趣,满足更多的业务场景。当然笔者会结合自身的理解,已经为每个核心的方法增加了必要的注释,会尽最大努力将其中的原理讲清楚,若有不妥之处,还望不吝赐教,欢迎批评指正。

预览

index-bar.gif

层级结构

index-bar :主要由 内容van-index-bar__sidebar组成,van-index-bar__sidebar 主要就是用来 点击或者触摸滑动 来滚动到指定的锚点(index-anchor).

index-anchor :主要由一个 div 包裹着一个 van-index-anchor,其中 van-index-anchor 如果 吸顶 了会变成 fixed 定位,以及包裹他的父元素( div )会设置高度,用于弥补其脱离文档流后的高度。

实现原理

笔者觉得 index-bar 中最核心的地方,在于滚动过程中,锚点的吸顶的处理。其中主要包括:获取哪个活跃的锚点将要吸顶,以及上一个活跃的锚点如何退场等。所以我们把核心点关注在:index-bar 所处的滚动容器 scroller 的滚动事件上。

mixins: [
  TouchMixin,
  ParentMixin('vanIndexBar'),
  BindEventMixin(function (bind) {
    // bind: on/off 函数
    if (!this.scroller) {
      this.scroller = getScroller(this.$el);
    }
    bind(this.scroller, 'scroll', this.onScroll);
  }),
],

onScroll() {
  if (isHidden(this.$el)) {
    return;
  }
  // 获取滚动容器的scrollTop
  const scrollTop = getScrollTop(this.scroller);
  // 返回滚动容器元素的大小及其相对于视口的位置 因为滚动容器可能不是 window/body,而且也有可能距离视口顶部有一段距离
  const scrollerRect = this.getScrollerRect();
  // 计算每一个锚点在滚动容器中的具体位置 top/height
  const rects = this.children.map((item) =>
    item.getRect(this.scroller, scrollerRect)
  );
  // 获取当前活跃的锚点
  const active = this.getActiveAnchorIndex(scrollTop, rects);

  this.activeAnchorIndex = this.indexList[active];

  if (this.sticky) {
    this.children.forEach((item, index) => {
      // 由于要设置 active 和 active-1 锚点的 fixed 属性,所以要把其,父容器的宽高 继承过来
      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) {
        // 这里锚点已经是 fixed 定位
        item.active = true;
        
        // 计算top: 由于锚点 fixed 定位的 top为0,这里设置的top 是用于设置自身锚点的transform.y
        // rects[index].top 是相对于滚动容器的位置,是固定值
        // scrollTop: 是变量,向上滚动 增大, 向下滚动 减小
        item.top =
          Math.max(this.stickyOffsetTop, rects[index].top - scrollTop) +
          scrollerRect.top;
      } else if (index === active - 1) {
        // 由于涉及到上一个活跃锚点 会被新的活跃锚点 随着滚动而顶掉
        const activeItemTop = rects[active].top - scrollTop;
        // 是否活跃:当活跃的锚点的顶部正好和滚动容器的顶部重合
        item.active = activeItemTop > 0;
        // 设置其top
        item.top = activeItemTop + scrollerRect.top - rects[index].height;
      } else {
        item.active = false;
      }
    });
  }
},
// 获取有效的锚点索引
getActiveAnchorIndex(scrollTop, rects) {
  // 细节:从后往前遍历 找到第一个满足条件的锚点退出即可
  for (let i = this.children.length - 1; i >= 0; i--) {
    // 取出上一个活跃(吸顶)锚点的高度
    const prevHeight = i > 0 ? rects[i - 1].height : 0;
    const reachTop = this.sticky ? prevHeight + this.stickyOffsetTop : 0;
    // 判断某个锚点第一次进入临界值 这里计算的都是相对 滚动容器 来计算的 所以是统一坐标系
    if (scrollTop + reachTop >= rects[i].top) {
      return i;
    }
  }
  return -1;
},

Web Api

笔者在看源码的时候,发下了比较好用的API,很好的减轻了许多复杂逻辑处理,特此分享一下,希望大家多去 MDN Web Docs 翻翻好用的API。笔者列出的API,会的你就当做复习,不会的API,你就权当学习啦。

拓展

美中不足的是 Vant 大大提供的 index-barindex-anchor 只能满足一些基本所需,一些定制化的需求,比如微信通讯录手机通讯录等样式,还不能提供友好的支撑,笔者这里站在巨人的肩膀上,手把手教大家实现Wechat通讯录相似的功能。以及为index-bar增加更多的特性和拓展性。

而且,本次涉及的拓展,只是UI层面的东西,不会更改vant提供的核心原理(onScoll)的内容,所以,咋们只关注UI相关的东西即可。Let's get it...

微信通讯录

特性

  • 微信通讯录的index-bar增加了点击或者触摸tag,会在tag左侧弹出一个hint,且松手后,会回到index-bar最大能吸顶的taganchor
  • 可以设置tag触摸或点击,不弹出hint,比如搜索🔍``tag
  • tag以及hint能支持用户自定义,即提供插槽。

实现

针对特性一,我们需要监听用户的touchstarttouchmovetouchendtouchcancel触摸事件,并且要知道当前是触摸index-bar的状态,还是滚动内容的状态,因为涉及到哪个index-bar上哪个tag高亮。具体代码如下:

// 开始触摸
onTouchStart(event) {
  // 正在触摸
  this.isTouching = true
  // 调用touch start方法
  this.touchStart(event)

  // 处理事件
  this.handleTouchEvent(event)
},

// 正在触摸
onTouchMove(event) {
  this.touchMove(event);

  if (this.direction === 'vertical') {
    // 阻止默认事件
    preventDefault(event);

    // 处理touch事件
    this.handleTouchEvent(event)
  }
},

// 结束或取消touch
onTouchEnd() {
  this.active = null;

  // 结束触摸
  this.isTouching = false
},


// 触摸事件处理
handleTouchEvent(event){
  const { clientX, clientY } = event.touches[0];
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/elementFromPoint
  // 获取点击的元素
  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;
    // 这里区分一下 按下和松手 这两个状态的 活跃索引 
    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 的设置,以及touchActiveAnchorIndex的记录,这里会后面渲染索引列表中哪个tag高亮做准备。

// 这里区分一下 按下和松手 这两个状态的 活跃索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

tag左侧弹出一个hint,利用子绝父相布局,这个功能比较好实现。即:一个父元素tag,就会对应一个子元素hint。然后哪个tagactive并且isTouching = true时,其子元素hint就会弹出。

针对特性二,点击某个tag,不弹出hint,这个功能也比较简单,在index-barprops新增一个属性,类型为string[] | number[]ignoreTags:忽略的Tags,这些忽略Tag, 不会高亮显示,点击或长按 不会弹出 tagHint

// 这里区分一下 按下和松手 这两个状态的 活跃索引 
const active = this.isTouching ? (index === this.touchActiveAnchorIndex) : (index === this.activeAnchorIndex);

// 去ignoreTags中查找,这个tag是否被忽略
const ignore = this.ignoreTags.some((value) => {
  return value === index
})

针对特性三,我们只需要为taghint提供一个具名插槽,并且抛出一个带indexactiveignore三个参数的对象即可。这样就可以满足用户的自定义了。具体代码如下

// 渲染索引tag
renderIndexTag(index, active, ignore) {
  // 有插槽
  const slot = this.slots('tag', { index, active, ignore });
  if (slot) {
    return slot
  }

  // 默认状态下的样式
  const style = {}
  // 活跃状态且不忽略的场景下
  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的场景
  const show = active && this.isTouching && !ignore
  // 获取插槽内容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默认场景
  return (
    <div vShow={show} class={bem('hint','pop')}>
      <span>{index}</span>
    </div>
  )
}

如果用户使用tag插槽的场景下,这里有个比较细节的地方,对于renderIndexTag,默认不使用插槽时,其内容如下:<span style={style} data-index={index}>{index}</span> 这里我们可以看到这里有个data-index={index},因为tag点击事件或者sidbar触摸事件,获取对应的索引都是通过const { index } = element.dataset;去获取索引的,但是如果用户自定义tag时,用户不会知道还要传个data-index={index},导致传统的方法const { index } = element.dataset;获取的index为空。导致点击无效。

解决办法就是在tag的父元素身上也添加一个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
},

手机通讯录

手机通讯录微信通信录,可谓是如出一辙,唯一不同的就是,tagHint弹出的位置不同罢了,前者居中弹出,而后者是tag左侧弹出。大家可能第一时间想到的就是依葫芦画瓢,把微信通讯录hintposition: absolute;改成position: fixed;不就可以了么?理想很丰满,现实很骨感 我只能这么说!

由于van-index-bar__sidebarcss设置了transform: translateY(-50%);导致其子元素设置的position: fixed;都会失效。所以我们采用的是将hint放在van-index-bar中去即可。关键代码如下:

// 渲染索引中间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的场景
  const show = active && this.isTouching && !ignore
  // 获取插槽内容
  const slot = this.slots('hint', { index, active, ignore });
  
  if (slot) {
    return show ? slot : ''
  }

  // 默认场景
  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>
  );
}

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:vant-learn
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335