移動(dòng)端效果之Picker

寫在前面
接著前面的移動(dòng)端效果的研究,這次來(lái)看看picker
選擇器的實(shí)現(xiàn)原理
移動(dòng)端效果之Swiper


**前端學(xué)習(xí)交流群461593224**

1. 核心解析

1.1 基本HTML結(jié)構(gòu)

<!-- 
    說明:
    1. 類 picker-3d 是為了提供3d視角,如果不需要可以去掉
    2. 類 picker-slot-absolute 在3d視角中需要加上,因?yàn)橄旅嫦鄬?duì)定位的 picker-items 是要相對(duì)父容器進(jìn)行
    transform的,如果不加,就會(huì)造成位移不正確
    3. DOM中所有的style樣式都是在初始化的時(shí)候加上的
-->
<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,因此我這里只是簡(jiǎn)單的用javascript來(lái)模擬一下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;

// 激活當(dāng)前item
var pickerItems = document.querySelectorAll('.picker-item');
pickerItems[valueIndex].classList.add('picker-selected');
1.3 初始化事件

總體上來(lái)說,picker的事件也包括滑動(dòng)開始、滑動(dòng)中、滑動(dòng)結(jié)束。因?yàn)楫吘故且苿?dòng)端,滑動(dòng)不可避免。這次,源碼中的對(duì)滑動(dòng)事件進(jìn)行了封裝,兼容了PC端以及排除了拖動(dòng)和選擇造成的影響,具體看一下分析。

 * draggable.js 
 * 只是起到一定的兼容性
 * 實(shí)質(zhì)和直接調(diào)用 el.addEventListener('touchstart', startFn); 并沒有多大差別
 */

// 滑動(dòng)開始
// touchstart 和 mousedown 可見對(duì)PC端的兼容
// onselectstart/ondragstart 直接return 可見排除了拖動(dòng)和選擇
element.addEventListener(supportTouch ? 'touchstart' : 'mousedown', function(event) {
    if (isDragging) return;
    document.onselectstart = function() { return false; };
    document.ondragstart = function() { return false; };

    // ...
});

// 滑動(dòng)結(jié)束
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跟隨自己在手機(jī)屏幕上的滑動(dòng)而滑動(dòng),方法大同小異,無(wú)非就是在開始滑動(dòng)記錄開始位置,滑動(dòng)中實(shí)時(shí)計(jì)算位移,滑動(dòng)結(jié)束之后將DOM滑動(dòng)應(yīng)該滑動(dòng)的位置。

// 滑動(dòng)開始的執(zhí)行事件方法
start: function(event) {
    dragState = {
        range: getDragRange(),
        // ...
        startTranslateTop: translateUtil.getElementTranslate(el).top
    };
}

這其中有兩個(gè)方法,第一個(gè)getDragRange
和第二個(gè)getElementTranslate(el)
.

  • 第一個(gè)函數(shù)的作用是獲取picker能夠滑動(dòng)的最小和最大的位移,這將會(huì)在滑動(dòng)結(jié)束事件中用到。關(guān)于如何計(jì)算,這里簡(jiǎn)單提一下,向下滑動(dòng),最大不能超過最中間的item的最上方,這也就是為什么itemHeight * Math.floor(visibleItemCount / 2),而向上滑動(dòng),最大不能超過中間item的最下方,-itemHeight * (valuesLength - Math.ceil(visibleItemCount / 2)),細(xì)細(xì)想一下就好了。
  • 第二個(gè)函數(shù)的作用是獲取當(dāng)前picker的transform值,作為下一次滑動(dòng)計(jì)算的依據(jù)。其實(shí)感覺這樣挺費(fèi)事,因?yàn)樵趖ouchend中最后肯定會(huì)計(jì)算translate值,我們只需要每次保存最后滑動(dòng)的移動(dòng)值就好了,而不要每次都要在DOM中取。
/**
 * translateUtil
 * 對(duì)瀏覽器對(duì)前綴支持的一些判斷
 * 檢測(cè)瀏覽器對(duì)3d屬性的支持情況
 * 獲取當(dāng)前的translate值/清空picker的translate值/移動(dòng)picker
 * 對(duì)于瀏覽器的檢測(cè)方面,這也算是一個(gè)比較好的工具類
 */
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;
}

// 講一下這個(gè)正則
// \s*(-?\d+(\.\d+?)?)px 這是一個(gè)單元,匹配這樣的 -23.15px, 剩下的應(yīng)該就好理解了
var regexp = /translate\(\s*(-?\d+(\.\d+?)?)px,\s*(-?\d+(\.\d+?)?)px\)\s*translateZ\(0px\)/ig;

接下來(lái)看看滑動(dòng)中

drag: function(event) {
    // 加上 dragging 類是為了清除過渡效果,在swiper中也有同樣的應(yīng)用
    el.classList.add('dragging');

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

    var deltaY = dragState.top - dragState.startTop;
  
    // 計(jì)算當(dāng)前的滑動(dòng)位移
    var translate = dragState.startTranslateTop + deltaY;

    // 滑動(dòng)元素
    translateUtil.translateElement(el, null, translate);
    velocityTranslate = translate - prevTranslate || translate;

    prevTranslate = translate;

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

看到以上的代碼中有一個(gè)velocityTranslate,這個(gè)值有神馬作用,最開始我也不清楚,后面發(fā)現(xiàn)在滑動(dòng)結(jié)束之后用到了,才明白了它代表了一個(gè)速率的位移值。什么是速率?就好比你快速滑動(dòng)的時(shí)候,總希望它能夠慣性滑動(dòng)一下,這個(gè)值乘以一個(gè)慣性值就可以得出一個(gè)慣性位移。看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的倍數(shù)
        // 在范圍的最大值和最小值中取
        translate = Math.max(Math.min(translate, dragRange[1]), dragRange[0]);
        translateUtil.translateElement(el, null, translate);

        // 計(jì)算得出當(dāng)前位移下應(yīng)該對(duì)應(yīng)的實(shí)際值
        currentValue = translate2Value(translate);

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

    dragState = {};
}

這就是整個(gè)picker的實(shí)現(xiàn)流程,撇開3d效果就可以使用了。下面看一下如何實(shí)現(xiàn)的3D效果。在doOnValuesChange中有一個(gè)最開始的初始化。

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

給每一個(gè)item設(shè)置了根據(jù)索引來(lái)的位移值,此時(shí)的每一個(gè)item的定位都必須是absolute的,這樣位移下來(lái)才是緊挨著的,不然可能中間都會(huì)有一個(gè)itemHeight的空格。3D效果中最關(guān)鍵的一點(diǎn)就是如何進(jìn)行翻轉(zhuǎn)角度的計(jì)算。在源碼中定義了一個(gè)常量對(duì)象:

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

可以看到,當(dāng)只有3個(gè)可見元素的時(shí)候,高亮部分相對(duì)于X軸平行,而上一個(gè)item就必須繞X軸順時(shí)針旋轉(zhuǎn)45度,反之下一個(gè)item繞X軸逆時(shí)針旋轉(zhuǎn)45度。另外在其中有一段代碼特別繞,根據(jù)我的理解是這樣的:

// 當(dāng)前item相對(duì)于頂部原本應(yīng)該有的位移值
var itemOffsetTop = index * itemHeight; 

// 滑動(dòng)過程中,相對(duì)于最開始的位置滑動(dòng)的位移值
var translateOffset = dragRange[1] - currentTranslate;

// 當(dāng)應(yīng)該有的位移值和滑動(dòng)的位移值相等的時(shí)候,也就說明了當(dāng)前的`item`被選中
// 也就是說此時(shí)當(dāng)前的角度為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);

如果覺得太繞,其實(shí)也沒有必要按照他的這種做法來(lái),我們只要想辦法確定每一個(gè)item相對(duì)于當(dāng)前選中的item是處于上一個(gè)還是下一個(gè),就可以根據(jù)此來(lái)計(jì)算角度。

2. 總結(jié)

關(guān)于餓了么中的picker組件就看了這么多,整體來(lái)說跟swiper中的滑動(dòng)十分相似,其中的關(guān)鍵點(diǎn)在于最后的計(jì)算位移值來(lái)根據(jù)位移值滑動(dòng)到正確的位置,至于怎么計(jì)算值,其實(shí)每個(gè)人的實(shí)現(xiàn)方式可能都是大同小異的,也沒要必要一定要按照源碼來(lái),可以適當(dāng)加入自己的理解,這樣或許寫起代碼來(lái)更加得心應(yīng)手。這里只是個(gè)人的一點(diǎn)理解,希望能夠給自己也給大家提供一點(diǎn)幫助。

學(xué)習(xí)前端的同學(xué)注意了!!!
學(xué)習(xí)過程中遇到什么問題或者想獲取學(xué)習(xí)資源的話,歡迎加入前端學(xué)習(xí)交流群461593224,我們一起學(xué)前端!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評(píng)論 6 532
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,530評(píng)論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評(píng)論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評(píng)論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,759評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,204評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評(píng)論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,415評(píng)論 0 288
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,955評(píng)論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,782評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,983評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評(píng)論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,222評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評(píng)論 1 286
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,675評(píng)論 3 392
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,967評(píng)論 2 374

推薦閱讀更多精彩內(nèi)容