查看原文
其他

【第1997期】NutUI Picker组件的设计与实现

宋成林 前端早读课 2020-09-30

前言

首先恭喜NutUI Github 2K star。今日早读文章由京东用户体验设计部@宋成林投稿分享。

京东用户体验设计部-前端开发部现有前端开发人员 50 左右,主要为京东零售集团、京东健康提供 WEB 前端开发、APP RN开发,小程序开发、小游戏开发、H5开发等能力支持。

正文从这开始~~

今天的主题是 NutUI Picker 组件的设计与实现,Picker组件是 NutUI 的一个拾取器组件,它用于显示一系列的值集合,用户可以滚动选择集合中一项,也可以支持多个系列的值集合供用户分别选择。我们通过一张效果图,来看看组件具体实现了什么功能。

说到 NutUI, 可能有些人还不太了解,容我们先简单介绍一下。NutUI 是一套京东风格的移动端Vue组件库,开发和服务于移动 Web 界面的企业级前中后台产品。通过 NutUI,可以快速搭建出风格统一的页面,提升开发效率。目前已有 50+ 个组件,这些组件被广泛使用于京东的各个移动端业务中。

接下来,我们会通过以下几个话题,展开今天的内容:

  • 为什么要封装组件

  • NutUI Picker组件的实现原理

  • 遇到的问题

一、为什么要封装组件

当业务达到一定规模后,会遇到很多相似功能界面,每次重新开发,会影响开发效律,且这些相近的代码可能潜伏某些问题,一旦暴露,我们需要花费很多时间去处理业务里的相同代码。如果我们把这些相同的代码进行合理化抽离,封装组件,多处调用,我们会发现,开发效律得到质的飞跃。

通过一张图来看一下的封装组件带来的好处:

封装组件,不仅可以让协同开发变得高效规范,于此同时,组件化的前端开发方式也可以为后续业务扩展带来更多便利。

二、 NutUI Picker组件的实现原理

这个组件在日常业务需求中还是比较常见的。它既可以承载简单的选项卡功能,同时也可以满足较为繁琐的日期时间选择,亦或是级联地址选择功能。基于 picker 组件的日期时间组件,我们也有封装,有兴趣的可访问 NutUI 组件库查看。

从文章前言中,我们已经大致了解 picker 组件实现了什么功能,它通过类似滚轮的三维旋转来实现选中选择集的某一项。

先来看看组件源码的目录结构:

我们主要围绕最后三个文件来说。
基于就近原则,我们把相关的文件放在同一个目录下,基于职责单一原则,我们把组件颗粒化,以保证组件尽可能简单和通用性比较好。把picker组件分为父组件 picker.vue 和子组件 picker-slot.vue,子组件只负责滚轮交互处理。父组件负责处理业务类逻辑。

子组件滚轮部分

1、先来看一下dom部分的分工

  1. <div class="nut-picker-list">

  2. <div class="nut-picker-roller" ref="roller">

  3. <div class="nut-picker-roller-item"

  4. :class="{'nut-picker-roller-item-hidden': isHidden(index + 1)}"

  5. v-for="(item,index) in listData"

  6. :style="setRollerStyle(index + 1)"

  7. :key="item.label"

  8. >

  9. {{item.value}}

  10. </div>

  11. </div>

  12. <div class="nut-picker-content">

  13. <div class="nut-picker-list-panel" ref="list">

  14. <div class="nut-picker-item"

  15. v-for="(item,index) in listData"

  16. :key="item.label "

  17. >

  18. {{item.value }}

  19. </div>

  20. </div>

  21. </div>

  22. <div class="nut-picker-indicator"></div>

  23. </div>

  • nut-picker-indicator: 分割线

  • nut-picker-content: 高亮选中区域

  • nut-picker-roller: 滚轮区域

不想看代码?“小二,上图!”

2、css部分

把nut-picker-indicator设置在最高层级,以免被遮盖

  1. .nut-picker-indicator{

  2. ...

  3. z-index: 3;

  4. }

nut-picker-roller滚轮区域

  1. .nut-picker-roller{

  2. z-index: 1;

  3. transform-style: preserve-3d;

  4. ...

  5. .nut-picker-roller-item{

  6. backface-visibility: hidden;

  7. position: absolute;

  8. top: 0;

  9. ...

  10. }

  11. }

要实现一些3D效果, transform-style:preserve-3d;是必不可少的,一般而言,该属性应用在 3D 变换的父元素上,也就是舞台元素。这样子元素就具有 3D 属性效果。在 CSS 的 3D 世界中,默认情况下,我们可以看到背后的元素,为了切合实际,我们常常让后面的元素不可见,所以设置子元素 backface-visibility:hidden;值得注意的是,设置了 transform-style:preserve-3d 该属性,就不能防止子级元素溢出,如果设置了 overflowhidden,那么 transform-stylepreserve-3d将会无效。

我们通过模拟滚轮旋转来实现组件的交互效果,用一张侧面图来更直观的看一下。

接下来我们来看一下如何实现。

首先,需要模拟一个球体,设置选择集的每一项(以下简称“滚轮项”)为 position:absolute,共用同一个中心点即球心,然后依次堆叠于此。

我们先温习一些基础知识, translate3d() 函数可以使一个元素在三维空间移动。这种变形的特点是,使用三维向量的坐标定义元素在每个方向移动多少。当z轴值越大时,元素也离观看者更近,我们通过设置z轴让滚轮项的两端到达球体表面,z轴的大小,相当于球体的半径,因为我们设定可视区域的高度为260,所以设置半径为104,如果半径过小,我们需要戴着高倍放大镜来寻找滚轮项,如果半径过大,那么滚轮项就跑到我们脑后去了...,不能让眼睛长在后脑勺这么可怕的事情发生!所谓距离产生美,所以保持适当的距离(80%)是最美的。

  1. setRollerStyle(index) {

  2. return `translate3d(0px, 0px, 104px)`;

  3. }

这时候,我们发现,所有滚轮项从集体堆叠球心变为堆叠到球体某两个点上,我们需要把它们按照周长平铺开来。这时,我们要用到 rotate3d()属性,我们滚轮是围绕 X 轴旋转,所以设定 X 轴 rotate3d(1,0,0,a) 即可, a 是一个角度值,用来指定元素在 3D 空间旋转的角度,值为正值,元素顺时针旋转,反之元素逆时针旋转。那这个角度如何来设定呢,可以通过一个圆心角公式来推断,圆心角的度数等于它所对的弧的度数,我们的半径是104,弧长是36(我们预先设定的显示区),从而四舍五入计算 a 角度为20。是不是有一种被说蒙圈的感觉,我们通过一张图,更直观的理解一下。

利用上面的分析,我们来动态设置滚轮项的最终位置。

  1. setRollerStyle(index) {

  2. return `transform: rotate3d(1, 0, 0, ${-this.rotation * index}deg) translate3d(0px, 0px, 104px)`;

  3. }

需要注意的是滚轮项的个数可能会很多,超过一圈的可能性是大大存在的,但我们既不能一刀切只给用户展示指定的个数,也不能全部展示造成重叠问题出现。这时候,我们需要把超出的部分隐藏掉,我们知道角度值 a 是20度,圆的一周是360度,所以最多可以显示18个,我们以当前中心为基础点,前面展示8个,后面展示9个。

  1. isHidden(index) {

  2. return (index >= this.currIndex + 9 || index <= this.currIndex - 8) ? true : false;

  3. }

3、添加事件

最后,我们来添加滑动事件,先获取 Vue 实例关联的 DOM 元素,设置 touchstarttouchmovetouchend事件,需要注意的是,我们要记得在 beforeDestroy事件中销毁这些事件。

touchstart事件用来记录开始点, touchmovetouchend事件用来记录滚动结束点,计算差值,动态设置滚轮最外层元素的滚动距离和滚动角度。在滚动时候需要对滚动距离进行修正,保证滚动的最后距离为 lineSpacing (滚轮项的高度36)的倍数值。

我们还增加了增加弹性效果,允许 touchmove滚动超出滚动范围,然后在 touchend事件中修正位置为首项、尾项。

来看一下具体实现。

  1. setMove(move, type, time) {

  2. let updateMove = move + this.transformY;

  3. if (type === 'end') { // touchend 滚动处理


  4. // 超出限定滚动距离修正

  5. if (updateMove > 0) {

  6. updateMove = 0;

  7. }

  8. if (updateMove < -(this.listData.length - 1) * this.lineSpacing) {

  9. updateMove = -(this.listData.length - 1) * this.lineSpacing;

  10. }


  11. // 设置滚动距离为lineSpacing的倍数值

  12. let endMove = Math.round(updateMove / this.lineSpacing) * this.lineSpacing;

  13. let deg = `${(Math.abs(Math.round(endMove / this.lineSpacing)) + 1) * this.rotation}deg`;

  14. this.setTransform(endMove, type, time, deg);

  15. this.timer = setTimeout(() => {

  16. this.setChooseValue(endMove);

  17. }, time / 2);


  18. this.currIndex = (Math.abs(Math.round(endMove/ this.lineSpacing)) + 1);

  19. } else { // touchmove 滚动处理

  20. let deg = '0deg';

  21. if (updateMove < 0) {

  22. deg = `${(Math.abs(updateMove / this.lineSpacing) + 1) * this.rotation}deg`;

  23. } else {

  24. deg = `${((-updateMove / this.lineSpacing) + 1) * this.rotation}deg`;

  25. }

  26. this.setTransform(updateMove, null, null, deg);

  27. this.currIndex = (Math.abs(Math.round(updateMove/ this.lineSpacing)) + 1);

  28. }

  29. },

touchend中,为滚轮父元素增加了过渡的“缓动函数”, 模拟惯性滚动效果。

  1. setTransform(translateY = 0, type, time = 1000, deg) {

  2. this.$refs.roller.style.transition = type === 'end' ? `transform ${time}ms cubic-bezier(0.19, 1, 0.22, 1)` : '';

  3. this.$refs.roller.style.transform = `rotate3d(1, 0, 0, ${deg})`;

  4. }

通过以上的内容,我们的滚轮效果已经基本成型。但是我们还想要类似 ios 上时间选择器高亮当前区域的效果,该如何实现呢?

我们尝试了如下三种方法。

第一种,考虑当滚轮项停留在高亮选中区域的时候,字体进行变化,但实践发现,只能在滚动结束的时候让字体变化,无法在滚动过程中设置,体验并不友好。

第二种,是否可以巧用 CSS,利用背景渐变和 background-size 配合完成渐变,利用蒙层来实现呢!

  1. .nut-picker-mask{

  2. position: absolute;

  3. top: 0;

  4. left: 0;

  5. right: 0;

  6. bottom: 0;

  7. background-image: linear-gradient(180deg,hsla(0,0%,100%,.9),hsla(0,0%,100%,.6)),linear-gradient(0deg,hsla(0,0%,100%,.9),hsla(0,0%,100%,.6));

  8. background-position: top, bottom;

  9. background-size: 100% 108px;

  10. background-repeat: no-repeat;

  11. z-index: 3;

  12. }

这里把背景设置成黄色,便于我们看效果。

感觉还可以,这样就搞定了吗?

我们在pc端模拟一切正常,在真机上却出现了诡异的画面,上滑弹出的时候,蒙层会延迟展示,影响体验效果。只有禁止上滑过渡效果,才可以正常展示。去除上滑效果是不可能的,我们只能考虑一下其他办法。

第三种,是否可以设置一个附属滚动,也就是上面说的高亮显示区,将其盖在滚轮上面,里面每个元素高度等于可视区高度,当滚轮滑动的时候,高亮显示区内部列表元素跟随一起滑动。

实践证明,这种方法可以避免上述两个方法的弊端,完美解决我们的需求。来看一下具体实现方法。

  1. .nut-picker-content {

  2. position: absolute;

  3. height: 36px;

  4. ...

  5. .nut-picker-roller-item{

  6. height: 36px;

  7. ...

  8. }

  9. }

然后在上面的 setTransform 函数中,增加高亮展示区滚动效果。

  1. setTransform(translateY = 0, type, time = 1000, deg) {

  2. ...

  3. this.$refs.list.style.transition = type === 'end' ? `transform ${time}ms cubic-bezier(0.19, 1, 0.22, 1)` : '';

  4. this.$refs.list.style.transform = `translate3d(0, ${translateY}px, 0)`;

  5. }

父组件部分

除了滚动效果,我们还有一些灰色蒙层、上滑弹出、工作栏等业务内容,我们交由父组件去处理。我们业务中也会涉及到多列情况,所以父组件可以把 props 数据拆分传给子组件,让每个子组件相互独立,监听子组件event事件,传递给外层。

三、遇到的问题

我们的组件是基于px来实现的,在 issues 中,收集到部分用户遇到一些问题,这里提供了解决方案。

1、使用px2rem,滚轮旋转出现偏差

因为 px 转 rem 有时候转出来的值会有偏差,并且出现多个小数位,导致滚动的高度和实际转化的高度出现偏差,我们可体通过以下配置解决

第一种:在.postcssrc.js配置文件中,把nutui开头的过滤掉

  1. module.exports = ({ file }) => {

  2. return {

  3. plugins: [

  4. ...

  5. pxtorem({

  6. rootValue: rootValue,

  7. propList: ['*'],

  8. minPixelValue: 2,

  9. selectorBlackList: ['.nut'] // 设置

  10. })

  11. }

  12. }

第二种: postcss-px2rem-exclude代替postcss-px2rem

  1. npm uninstall postcss-px2rem

  2. npm i postcss-px2rem-exclude -D

  1. // 在.postcssrc.js配置

  2. module.exports = ({ file }) => {

  3. return {

  4. plugins: [

  5. ...

  6. pxtorem({

  7.         remUnit: rootValue,

  8. exclude: '/node_modules/@nutui/nutui/packages/picker'

  9.     })

  10. ]

  11. }

  12. }

2、使用lib-flexible,组件被缩小问题

我们的 css 是基于 data-dpr 为1的时候编写的,如果使用了 lib-flexible, 页面要设置

  1. <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">

后续我们也会考虑从代码层面上去解决上述问题。

总结

以上就是本文的全部内容,主要介绍了 Picker 组件的一些设计思想与实现原理,如果您对这个组件感兴趣,不妨查看和试用一下,使用上有任何问题,可在 issues 上进行提问,我们会尽快解答和修复,后续我们也会对组件进行持续优化迭代,访问 NutUI组件库,更多组件等你发现。

京东用户体验设计部曾分享的文章


【第1992期】为京东PLUS会员保驾护航的日子


【第1930期】运用 NutUI - 快捷开发京东企业业务之酷兜


【第1988期】NutUI 组件 popup 源码分析


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存