Taro 框架基于 ScrollView 组件实现索引选择器(兼容H5、微信小程序、支付宝小程序)

6,435 阅读3分钟

其实 TaroUI 框架自带索引选择器 AtIndexes,但是由于 AtIndexes 组件的列表只能接收文本传参,不能自定义内容。我们的业务需求需要自定义每个 item 的内容,每个 item 里需要添加多个标签。在看了 AtIndexes 组件相关源码和咨询完 TaroUI 的官方大佬后,AtIndexes 是基于 ScrollView, 决定自己也基于 ScrollView 实现一个索引选择器。

基础版

实现 AtIndexes 的功能,通过点击索引导航,快速定位到列表处。

实现思路:

  • 格式化重组数据,把数据按照每个字符下的数据格式化,如:
[
 {
  title: 'A',
  items: [...]
 },
 {
  title: 'B',
  items: [...]
 },
...
]
  • 格式化数据时,把字母索引单独取出并排序。之所以不用固定的 A-Z,是因为并不能保证每个字母索引下都有数据。
  • 布局,字母索引定位在屏幕视口右侧并垂直居中。左侧列表按照字母为 title,并用 li 标签循环渲染 items 内的每条数据

ScrollView 组件的参数设置:

<ScrollView
 className='scrollview'
 scrollY
 enableBackToTop
 scrollWithAnimation
 scrollIntoView={toView}
 scrollTop={scrollTop}
 style={scrollStyle}
>
···
</ScrollView>
  • scrollY:允许纵向滚动。
  • enableBackToTopiOS 点击顶部状态栏、安卓双击标题栏时,滚动条返回顶部,只支持竖向。
  • `scrollWithAnimation``:在设置滚动条位置时使用动画过渡。
  • scrollIntoView: 值应为某子元素 id,设置哪个方向可滚动,则在哪个方向滚动到该元素。
  • scrollTop:设置竖向滚动条位置。
  • style:使用竖向滚动时,需要给 ScrollView 一个固定高度。

左侧每个索引列表区块的 idclassName 设置:

 id={`data-${item.title}`}
className={`list-items-${item.title}`}

主要事件:

  • 右侧索引导航的点击事件,获取点击的导航内容,设置列表区块对应的 id
clickLetter(key, index) {
    const keyText = `data-${key}`
    this.setState({
      toView: keyText
    })
}
  • 左侧 ScrollView 组件通过设置 scrollIntoView={toView},列表滚动到该 id 区块元素,达到快速定位的效果。

进阶版

基础版只是实现了简单的点击索引导航,快速定位的效果,显得有些生硬,在项目 1.5 版本迭代的时候,我增加了一些效果。

1、通过 onScroll 事件监听 ScrollView 的纵向滚动位置,使右侧对应索引高亮。 2、手指在屏幕上点击右侧索引导航并移动时(touchMove),左侧实现滚动到对应导航的列表区块。

在原有功能上的实现思路:

  • 获取左侧每个列表区块的高度(获取 DOM 元素高度的兼容方法:H5、微信小程序 Taro.createSelectorQuery(),支付宝小程序 my.createSelectorQuery()):
// 通过 class 或者 id 获取 DOM 元素的 height
querySelect(nodeSelect) {
    const query =
      process.env.TARO_ENV === 'alipay'
        ? // eslint-disable-next-line no-undef
          my.createSelectorQuery()
        : Taro.createSelectorQuery().in(this.$scope)
    return new Promise(function(resolve) {
      query
        .select(nodeSelect)
        .boundingClientRect()
        .exec(rect => {
          if (rect && rect[0]) {
            resolve(rect[0].height)
          }
        })
    })
}
  • 计算每个列表区块的高度区间:
  // stationListToJS 是格式化重组后的数据
  _getListItemsHeight(stationListToJS) {
    const listItemsPromise = new Promise(resolve => {
      if (stationListToJS.length > 0) {
        let arr = []
        let listHeights = []
        // 通过 querySelect 方法获取所有 class 为 .list-items-${item.title} 的 DOM 高度
        stationListToJS.forEach(item => {
          arr.push(this.querySelect(`.list-items-${item.title}`))
        })
        Promise.all([...arr]).then(res => {
          // 高度区间从 0 开始计算
          let height = 0
          listHeights.push(height)
          for (let i = 0; i < res.length; i++) {
            // 高度累加,根据 纵向滚动的值
            height += res[i]
            listHeights.push(height)
          }
          resolve(listHeights)
        })
      }
    })
    return listItemsPromise
  }
  • componentDidMount 生命周期中计算右侧每个索引导航的高度:
if (toJS(letter).length > 0) {
  this.querySelect('.letter').then(res => {
    this.setState({
      letterDOMHeight: res
    })
  })
}
  • componentDidMount 生命周期中调用计算列表区块的高度区间的方法:
// 在保证有数据的情况下,渲染 DOM 元素后获取列表区块的高度区间
const stationListToJS = toJS(stationList)
if (stationListToJS.length > 0) {
  this._getListItemsHeight(stationListToJS).then(res => {
      this.setState({
        DOMHeight: [...res]
      })
  })
}
  • onScroll 监听左侧 ScrollView 组件纵向滚动的值 e.detail.scrollTop ,计算 e.detail.scrollTop 落在 DOMHeight 数据的哪个区间,获取索引值,设置右侧索引导航的高亮:
// 滚动列表,高亮对应的字母
onScroll(e) {
  const { DOMHeight } = this.state
  for (let i = 0; i < DOMHeight.length - 1; i++) {
    let height1 = DOMHeight[i]
    let height2 = DOMHeight[i + 1]
    if (e.detail.scrollTop >= height1 && e.detail.scrollTop < height2) {
      // currentLetterIndex 右侧索引导航的高亮
      this.setState({
        currentLetterIndex: i
      })
      return
    }
  }
}
  • 右侧索引导航添加 touchStarttouchMove 事件,监听并计算出在索引导航上移动的距离和移动了几个索引值。
  • touchStart 事件:
// 监听开始滑动的位置
touchStartLetter(e) {
  // 由对应的 id 获取到该位置的字母和索引值
  // 索引导航设置的 id   id={`letter-${item}-${index}`}
  const targetArr = e.target.id.split('-')
  const letterText = targetArr[1]
  const index = targetArr[2]
  const keyText = `data-${letterText}`

  // e.touches[0].pageY 记录开始滑动的位置
  // startIndex touchStart 事件接触到的索引值
  // currentLetterIndex 当前索引导航的索引值
  this.setState({
    toView: keyText,
    currentLetterIndex: index,
    startIndex: index,
    touchStartPageY: e.touches[0].pageY
  })
}
  • touchMove 事件:
// 监听滑动并高亮对应的索引和滑动到对应的列表
touchMoveLetter(e) {
  e.stopPropagation()
  e.preventDefault()
  const { touchStartPageY, letterDOMHeight, startIndex, DOMHeight } = this.state
  const letter = toJS(this.props.letter)

  // 由 滑动的距离 / 每个索引的额高度,计算滑动了多少个索引
  let diff = ((e.touches[0].pageY - touchStartPageY) / letterDOMHeight) | 0

  // 处理滑动到最上和最下的边界值
  // eslint-disable-next-line radix
  let curIndex = parseInt(startIndex) + diff
  if (curIndex < 0) {
    curIndex = 0
  } else if (curIndex > DOMHeight.length - 2) {
    curIndex = DOMHeight.length - 2
  }

  // 根据计算出的索引值从 letter 字母数组中找到对应的 字母
  let keyText = `data-`
  letter.forEach((item, index) => {
    if (curIndex === index) {
      keyText = keyText + item
    }
  })
  this.setState({
    toView: keyText,
    currentLetterIndex: curIndex
  })
}

DOM 元素设置 idclassName

  • 右侧索引导航设置的属性:
<View className='letter-wrapper'>
  {letter.length > 0 &&
    letter.map((item, index) => {
      return (
        <View
          className={
            index === Number(currentLetterIndex) ? 'letter active' : 'letter'
          }
          key={item}
          id={`letter-${item}-${index}`}
          onClick={this.clickLetter.bind(this, item, index)}
          onTouchStart={this.touchStartLetter}
          onTouchMove={this.touchMoveLetter}
        >
          {item}
        </View>
      )
    })}
</View>
  • 左侧列表区块 DOM 设置的属性:
<View
  key={item.title + index}
  className={`list-items list-items-${item.title}`}
  id={`data-${item.title}`}
>
···
</View>

END

记录一下实现索引选择器的解题和一些值的计算思路。