移动端效果之Picker

写在前面

接着前面的移动端效果的研究,这次来看看picker选择器的实现原理

移动端效果之Swiper

移动端效果之CellSwiper

移动端效果之IndexList

移动端效果之scrollList

代码看这里:github

1. 核心解析

1.1 基本HTML结构

<!-- 
    说明:
    1. 类 picker-3d 是为了提供3d视角,如果不需要可以去掉
    2. 类 picker-slot-absolute 在3d视角中需要加上,因为下面相对定位的 picker-items 是要相对父容器进行
    transform的,如果不加,就会造成位移不正确
    3. DOM中所有的style样式都是在初始化的时候加上的
-->
<div class="picker picker-3d">
    <div class="picker-items">
        <div class="picker-slot picker-slot-absolute" style="flex:1;">
            <div class="picker-slot-wrapper" id="wrapper" style="height: 108px;">
                <div class="picker-item picker-selected" style="height:36px;line-height: 36px">1981</div>
                <!-- ... -->
                <div class="picker-item" style="height:36px;line-height: 36px">1999</div>
            </div>
        </div>
    </div>
    <div class="picker-center-highlight" style="height:36px;margin-top:-18px;"></div>
</div>

1.2 初始化DOM

由于饿了么源码中的picker是采用v-for指令生成的DOM,因此我这里只是简单的用javascript来模拟一下DOM的生成。

var el = document.querySelector('#wrapper');
var animationFrameId = null;
var currentValue;
var itemHeight = 36;
var visibleItemCount = 3;
var valueIndex = 0;
var rotateEffect = true;

var datas = ['1981', '1982', '1983', '...', '1999'];

// 如果支持3d视角,则给<div class="picker"></div>加上类"picker-3d"
// <div class="picker-slot" style="flex:1;">加上类"picker-slot-absolute"
if (rotateEffect) {
    var picker = document.querySelector('.picker');
    var pickerSlot = document.querySelector('.picker-slot');
    picker.classList.add('picker-3d');
    pickerSlot.classList.add('picker-slot-absolute');
}

// 限定容器高度
el.style.height = `${visibleItemCount * itemHeight}px`;

// 生成DOM
var html = '';
datas.forEach(function(data, index) {
    html += `<div class="picker-item" style="height:36px;line-height:36px;">${data}</div>`;
});
el.innerHTML = html;

// 激活当前item
var pickerItems = document.querySelectorAll('.picker-item');
pickerItems[valueIndex].classList.add('picker-selected');

1.3 初始化事件

总体上来说,picker的事件也包括滑动开始、滑动中、滑动结束。因为毕竟是移动端,滑动不可避免。这次,源码中的对滑动事件进行了封装,兼容了PC端以及排除了拖动和选择造成的影响,具体看一下分析。`

/** 
 * draggable.js 
 * 只是起到一定的兼容性
 * 实质和直接调用 el.addEventListener('touchstart', startFn); 并没有多大差别
 */

// 滑动开始
// touchstart 和 mousedown 可见对PC端的兼容
// onselectstart/ondragstart 直接return 可见排除了拖动和选择
element.addEventListener(supportTouch ? 'touchstart' : 'mousedown', function(event) {
    if (isDragging) return;
    document.onselectstart = function() { return false; };
    document.ondragstart = function() { return false; };

    // ...
});

// 滑动结束
var endFn = function(event) {
    // 注销事件
    if (!supportTouch) {
        document.removeEventListener('mousemove', moveFn);
        document.removeEventListener('mouseup', endFn);
    }
    document.onselectstart = null;
    document.ondragstart = null;

    isDragging = false;

    if (options.end) {
        options.end(supportTouch ? event.changedTouches[0] || event.touches[0] : event);
    }
}

要是DOM跟随自己在手机屏幕上的滑动而滑动,方法大同小异,无非就是在开始滑动记录开始位置,滑动中实时计算位移,滑动结束之后将DOM滑动应该滑动的位置。这点可以参看前面一篇文章移动端效果之Swiper,这篇文章中有着相同的方法。这里重点讲一下其中的区别

// 滑动开始的执行事件方法
start: function(event) {
    dragState = {
        range: getDragRange(),
        // ...
        startTranslateTop: translateUtil.getElementTranslate(el).top
    };
}

这其中有两个方法,第一个getDragRange和第二个getElementTranslate(el).

  • 第一个函数的作用是获取picker能够滑动的最小和最大的位移,这将会在滑动结束事件中用到。关于如何计算,这里简单提一下,向下滑动,最大不能超过最中间的item的最上方,这也就是为什么itemHeight * Math.floor(visibleItemCount / 2),而向上滑动,最大不能超过中间item的最下方,-itemHeight * (valuesLength - Math.ceil(visibleItemCount / 2)),细细想一下就好了。
  • 第二个函数的作用是获取当前pickertransform值,作为下一次滑动计算的依据。其实感觉这样挺费事,因为在touchend中最后肯定会计算translate值,我们只需要每次保存最后滑动的移动值就好了,而不要每次都要在DOM中取。
/**
 * translateUtil
 * 对浏览器对前缀支持的一些判断
 * 检测浏览器对3d属性的支持情况
 * 获取当前的translate值/清空picker的translate值/移动picker
 * 对于浏览器的检测方面,这也算是一个比较好的工具类
 */
var docStyle = document.documentElement.style;
var engine;
var translate3d = false;

// 浏览器判断
if (window.opera && Object.prototype.toString.call(opera) === '[object Opera]') {
    engine = 'presto';
} else if ('MozAppearance' in docStyle) {
    engine = 'gecko';
} else if ('WebkitAppearance' in docStyle) {
    engine = 'webkit';
} else if (typeof navigator.cpuClass === 'string') {
    engine = 'trident';
}

// css前缀
var cssPrefix = {
    trident: '-ms-',        // IE
    gecko: '-moz-',         // FireFox
    webkit: '-webkit-',     // Chrome/Safari/etc...
    presto: '-o-'           // Opera
}[engine];

// style前缀
var vendorPrefix = {
    trident: 'ms',
    gecko: 'Moz',
    webkit: 'Webkit',
    presto: 'O'
}[engine];

var helpElem = document.createElement('div');
var perspectiveProperty = vendorPrefix + 'Perspective';
var transformProperty = vendorPrefix + 'Transform';
var transformStyleName = cssPrefix + 'transform';
var transitionProperty = vendorPrefix + 'Transition';
var transitionStyleName = cssPrefix + 'transition';
var transitionEndProperty = vendorPrefix.toLowerCase() + 'TransitionEnd';

if (helpElem.style[perspectiveProperty] !== undefined) {
    translate3d = true;
}

// 讲一下这个正则
// \s*(-?\d+(\.\d+?)?)px 这是一个单元,匹配这样的 -23.15px, 剩下的应该就好理解了
var regexp = /translate\(\s*(-?\d+(\.\d+?)?)px,\s*(-?\d+(\.\d+?)?)px\)\s*translateZ\(0px\)/ig;

接下来看看滑动中

drag: function(event) {
    // 加上 dragging 类是为了清除过渡效果,在swiper中也有同样的应用
    el.classList.add('dragging');

    dragState.left = event.pageX;
    dragState.top = event.pageY;

    var deltaY = dragState.top - dragState.startTop;
  
    // 计算当前的滑动位移
    var translate = dragState.startTranslateTop + deltaY;

    // 滑动元素
    translateUtil.translateElement(el, null, translate);
    velocityTranslate = translate - prevTranslate || translate;

    prevTranslate = translate;

    if (rotateEffect) {
        updateRotate(prevTranslate, pickerItems);
    }
}

看到以上的代码中有一个velocityTranslate,这个值有神马作用,最开始我也不清楚,后面发现在滑动结束之后用到了,才明白了它代表了一个速率的位移值。什么是速率?就好比你快速滑动的时候,总希望它能够惯性滑动一下,这个值乘以一个惯性值就可以得出一个惯性位移。看end中的代码。

end: function() {
    // 添加过渡
    el.classList.remove('dragging');
    // 惯性值
    var momentumRatio = 7;
    var currentTranslate = translateUtil.getElementTranslate(el).top;
    var duration = new Date() - dragState.start;

    var momentumTranslate;
    if (duration < 300) {
        momentumTranslate = currentTranslate + velocityTranslate * momentumRatio;
    }

    // 加上惯性速率之后的位移值
    console.log('momentumTranslate', momentumTranslate);

    dragRange = dragState.range;

    setTimeout(function() {
        var translate;
        if (momentumTranslate) {
            translate = Math.round(momentumTranslate / itemHeight) * itemHeight;
        } else {
            translate = Math.round(currentTranslate / itemHeight) * itemHeight;
        }

        // 取得最终的位移值,
        // 必须为itemHeight的倍数
        // 在范围的最大值和最小值中取
        translate = Math.max(Math.min(translate, dragRange[1]), dragRange[0]);
        translateUtil.translateElement(el, null, translate);

        // 计算得出当前位移下应该对应的实际值
        currentValue = translate2Value(translate);

        // 3d效果
        if (rotateEffect) {
            planUpdateRotate();
        }
    }, 10);

    dragState = {};
}

这就是整个picker的实现流程,撇开3d效果就可以使用了。下面看一下如何实现的3D效果。在doOnValuesChange中有一个最开始的初始化。

[].forEach.call(items, function(item, index) {
    translateUtil.translateElement(item, null, itemHeight * index);
});

给每一个item设置了根据索引来的位移值,此时的每一个item的定位都必须是absolute的,这样位移下来才是紧挨着的,不然可能中间都会有一个itemHeight的空格。

3D效果中最关键的一点就是如何进行翻转角度的计算。在源码中定义了一个常量对象:

var VISIBEL_ITEMS_ANGLE_MAP = {
    3: -45,
    5: -20,
    7: -15
};

可以看到,当只有3个可见元素的时候,高亮部分相对于X轴平行,而上一个item就必须绕X轴顺时针旋转45度,反之下一个itemX轴逆时针旋转45度。另外在其中有一段代码特别绕,根据我的理解是这样的:

// 当前item相对于顶部原本应该有的位移值
var itemOffsetTop = index * itemHeight; 

// 滑动过程中,相对于最开始的位置滑动的位移值
var translateOffset = dragRange[1] - currentTranslate;

// 当应该有的位移值和滑动的位移值相等的时候,也就说明了当前的`item`被选中
// 也就是说此时当前的角度为0
var itemOffset = itemOffsetTop - translateOffset;
var percentage = itemOffset / itemHeight;

var angle = angleUnit * percentage;
if (angle > 180) angle = 180;
if (angle < -180) angle = -180;

rotateElement(item, angle);

如果觉得太绕,其实也没有必要按照他的这种做法来,我们只要想办法确定每一个item相对于当前选中的item是处于上一个还是下一个,就可以根据此来计算角度。

2. 总结

关于饿了么中的picker组件就看了这么多,整体来说跟swiper中的滑动十分相似,其中的关键点在于最后的计算位移值来根据位移值滑动到正确的位置,至于怎么计算值,其实每个人的实现方式可能都是大同小异的,也没要必要一定要按照源码来,可以适当加入自己的理解,这样或许写起代码来更加得心应手。这里只是个人的一点理解,希望能够给自己也给大家提供一点帮助。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏hightopo

基于 HTML5 的 WebGL 3D 版俄罗斯方块

20430
来自专栏小红豆的数据分析

小蛇学python(10)tkinter和pandas的补充

本文主要是想对写界面以及操作表格遇到的常见问题做个总结。前两篇文章想想对tkinter和pandas这两个库的概述还不够全面。

21430
来自专栏一“技”之长

设计iOS中随系统键盘弹收和内容文字长度自适应高度的文本框

    文本输入框是多数与社交相关的app中不可或缺的一个控件,这些文本输入框应该具备如下的功能:

11220
来自专栏IMWeb前端团队

浏览器渲染原理

浏览器工作大致流程 废话少说,先来看个图: ? 从上面这个图中,我们可以看到那么几个事: 1)浏览器会解析三个东西: 一个是HTML/SVG/XHTML,事实上...

34700
来自专栏快乐八哥

Cmd Markdown编辑器简明语法手册

标签: Cmd-Markdown 1. 斜体和粗体 使用 * 和 ** 表示斜体和粗体。 示例: 这是 斜体,这是 粗体。 2. 分级标题 使用 === 表示一...

21970
来自专栏一“技”之长

iOS动画开发之一——UIViewAnimation动画的使用

      一款APP的成功与否,除了完善的功能外,用户体验也占有极大的比重,动画的合理运用,可以很好的增强用户体验。iOS开发中,常用的动画处理有UIView...

12830
来自专栏应用案例

D3.js 满足你对数据可视化的一切幻想

D3.js D3的全称是Data-Driven Documents(数据驱动的文档),是一个用来做数据可视化的JavaScript函数库,而JavaScript...

627100
来自专栏前端新视界

Vue.js 系列教程 5:动画

原文:intro-to-vue-5-animations 译者:nzbin 译者的话:经过两周的努力,终于完成了这个系列的翻译,由于时间因素及个人水平有限,并没...

61970
来自专栏進无尽的文章

绘图-CAShapeLayer、CABasicAnimation以及核心动画

QeartzCore是iOS中的图层框架,Quartz Core 的渲染能力可以像三维一样对二维图像进行任意操纵,在这个框架中我们可以对试图的图层进行定制,以实...

17920
来自专栏Java后端生活

Markdown的十个基本标签

51370

扫码关注云+社区

领取腾讯云代金券