Picker元件的設計與實現

京東設計中心JDC發表於2020-07-07

前言

今天的主題是 NutUI Picker 元件的設計與實現,Picker元件是 NutUI 的一個拾取器元件,它用於顯示一系列的值集合,使用者可以滾動選擇集合中一項,也可以支援多個系列的值集合供使用者分別選擇。我們通過一張效果圖,來看看元件具體實現了什麼功能。

image

說到 NutUI, 可能有些人還不太瞭解,容我們先簡單介紹一下。NutUI 是一套京東風格的移動端Vue元件庫,開發和服務於移動 Web 介面的企業級前中後臺產品。通過 NutUI,可以快速搭建出風格統一的頁面,提升開發效率。目前已有 50+ 個元件,這些元件被廣泛使用於京東的各個移動端業務中。

接下來,我們會通過以下幾個話題,展開今天的內容:

  • 為什麼要封裝元件
  • NutUI Picker元件的實現原理
  • 遇到的問題

一、為什麼要封裝元件

當業務達到一定規模後,會遇到很多相似功能介面,每次重新開發,會影響開發效律,且這些相近的程式碼可能潛伏某些問題,一旦暴露,我們需要花費很多時間去處理業務裡的相同程式碼。如果我們把這些相同的程式碼進行合理化抽離,封裝元件,多處呼叫,我們會發現,開發效律得到質的飛躍。

通過一張圖來看一下的封裝元件帶來的好處:

image

封裝元件,不僅可以讓協同開發變得高效規範,於此同時,元件化的前端開發方式也可以為後續業務擴充套件帶來更多便利。

二、 NutUI Picker元件的實現原理

這個元件在日常業務需求中還是比較常見的。它既可以承載簡單的選項卡功能,同時也可以滿足較為繁瑣的日期時間選擇,亦或是級聯地址選擇功能。基於 picker 元件的日期時間元件,我們也有封裝,有興趣的可訪問 NutUI 元件庫檢視。

從文章前言中,我們已經大致瞭解 picker 元件實現了什麼功能,它通過類似滾輪的三維旋轉來實現選中選擇集的某一項。

先來看看元件原始碼的目錄結構:

image

我們主要圍繞最後三個檔案來說。
基於就近原則,我們把相關的檔案放在同一個目錄下,基於職責單一原則,我們把元件顆粒化,以保證元件儘可能簡單和通用性比較好。把picker元件分為父元件 picker.vue 和子元件 picker-slot.vue,子元件只負責滾輪互動處理。父元件負責處理業務類邏輯。

子元件滾輪部分

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

<div class="nut-picker-list">
    <div class="nut-picker-roller" ref="roller">
        <div class="nut-picker-roller-item" 
            :class="{'nut-picker-roller-item-hidden': isHidden(index + 1)}"
            v-for="(item,index) in listData"
            :style="setRollerStyle(index + 1)"
            :key="item.label"
        >
            {{item.value}}
        </div>
    </div>
    <div class="nut-picker-content">
        <div class="nut-picker-list-panel" ref="list">
            <div class="nut-picker-item" 
                 v-for="(item,index) in listData"
                 :key="item.label "
            >
                 {{item.value }}
            </div>
        </div>
    </div>
    <div class="nut-picker-indicator"></div>
</div>
  • nut-picker-indicator: 分割線
  • nut-picker-content: 高亮選中區域
  • nut-picker-roller: 滾輪區域

不想看程式碼?“小二,上圖!”

image

2、css部分

把nut-picker-indicator設定在最高層級,以免被遮蓋

.nut-picker-indicator{
    ...
    z-index: 3;
}

nut-picker-roller滾輪區域

.nut-picker-roller{
    z-index: 1;
    transform-style: preserve-3d;
    ...
    .nut-picker-roller-item{
        backface-visibility: hidden;
        position: absolute;
        top: 0;
        ...
    }
}

要實現一些3D效果,transform-style:preserve-3d;是必不可少的,一般而言,該屬性應用在 3D 變換的父元素上,也就是舞臺元素。這樣子元素就具有 3D 屬性效果。在 CSS 的 3D 世界中,預設情況下,我們可以看到背後的元素,為了切合實際,我們常常讓後面的元素不可見,所以設定子元素 backface-visibility: hidden;
值得注意的是,設定了 transform-style:preserve-3d 該屬性,就不能防止子級元素溢位,如果設定了overflow:hidden,那麼transform-style:preserve-3d將會無效。

我們通過模擬滾輪旋轉來實現元件的互動效果,用一張側面圖來更直觀的看一下。

image

接下來我們來看一下如何實現。

首先,需要模擬一個球體,設定選擇集的每一項(以下簡稱“滾輪項”)為 position:absolute,共用同一個中心點即球心,然後依次堆疊於此。

image

我們先溫習一些基礎知識,translate3d() 函式可以使一個元素在三維空間移動。這種變形的特點是,使用三維向量的座標定義元素在每個方向移動多少。當z軸值越大時,元素也離觀看者更近,我們通過設定z軸讓滾輪項的兩端到達球體表面,z軸的大小,相當於球體的半徑,因為我們設定可視區域的高度為260,所以設定半徑為104,如果半徑過小,我們需要戴著高倍放大鏡來尋找滾輪項,如果半徑過大,那麼滾輪項就跑到我們腦後去了...,不能讓眼睛長在後腦勺這麼可怕的事情發生!所謂距離產生美,所以保持適當的距離(80%)是最美的。

setRollerStyle(index) {
    return `translate3d(0px, 0px, 104px)`;
}

image

這時候,我們發現,所有滾輪項從集體堆疊球心變為堆疊到球體某兩個點上,我們需要把它們按照周長平鋪開來。這時,我們要用到rotate3d()屬性,我們滾輪是圍繞 X 軸旋轉,所以設定 X 軸 rotate3d(1, 0, 0, a) 即可, a 是一個角度值,用來指定元素在 3D 空間旋轉的角度,值為正值,元素順時針旋轉,反之元素逆時針旋轉。那這個角度如何來設定呢,可以通過一個圓心角公式來推斷,圓心角的度數等於它所對的弧的度數,我們的半徑是104,弧長是36(我們預先設定的顯示區),從而四捨五入計算 a 角度為20。是不是有一種被說蒙圈的感覺,我們通過一張圖,更直觀的理解一下。

image

利用上面的分析,我們來動態設定滾輪項的最終位置。

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

需要注意的是滾輪項的個數可能會很多,超過一圈的可能性是大大存在的,但我們既不能一刀切只給使用者展示指定的個數,也不能全部展示造成重疊問題出現。這時候,我們需要把超出的部分隱藏掉,我們知道角度值 a 是20度,圓的一週是360度,所以最多可以顯示18個,我們以當前中心為基礎點,前面展示8個,後面展示9個。

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

3、新增事件

最後,我們來新增滑動事件,先獲取 Vue 例項關聯的 DOM 元素,設定touchstarttouchmovetouchend事件,需要注意的是,我們要記得在beforeDestroy事件中銷燬這些事件。

touchstart事件用來記錄開始點,touchmovetouchend事件用來記錄滾動結束點,計算差值,動態設定滾輪最外層元素的滾動距離和滾動角度。在滾動時候需要對滾動距離進行修正,保證滾動的最後距離為 lineSpacing (滾輪項的高度36)的倍數值。

我們還增加了增加彈性效果,允許touchmove滾動超出滾動範圍,然後在touchend事件中修正位置為首項、尾項。

來看一下具體實現。

setMove(move, type, time) {
    let updateMove = move + this.transformY;
    if (type === 'end') { // touchend 滾動處理
    
        // 超出限定滾動距離修正
        if (updateMove > 0) {
            updateMove = 0;
        }
        if (updateMove < -(this.listData.length - 1) * this.lineSpacing) {
            updateMove = -(this.listData.length - 1) * this.lineSpacing;
        }

        // 設定滾動距離為lineSpacing的倍數值
        let endMove = Math.round(updateMove / this.lineSpacing) * this.lineSpacing;
        let deg = `${(Math.abs(Math.round(endMove / this.lineSpacing)) + 1) * this.rotation}deg`;
        this.setTransform(endMove, type, time, deg);
        this.timer = setTimeout(() => {
            this.setChooseValue(endMove);
        }, time / 2); 

        this.currIndex = (Math.abs(Math.round(endMove/ this.lineSpacing)) + 1);
    } else { // touchmove 滾動處理
        let deg = '0deg';
        if (updateMove < 0) {
            deg = `${(Math.abs(updateMove / this.lineSpacing) + 1) * this.rotation}deg`;
        } else {
            deg = `${((-updateMove / this.lineSpacing) + 1) * this.rotation}deg`;
        }
        this.setTransform(updateMove, null, null, deg);
        this.currIndex = (Math.abs(Math.round(updateMove/ this.lineSpacing)) + 1);
    }
},

touchend中,為滾輪父元素增加了過渡的“緩動函式”, 模擬慣性滾動效果。

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

通過以上的內容,我們的滾輪效果已經基本成型。但是我們還想要類似 ios 上時間選擇器高亮當前區域的效果,該如何實現呢?

我們嘗試瞭如下三種方法。

第一種,考慮當滾輪項停留在高亮選中區域的時候,字型進行變化,但實踐發現,只能在滾動結束的時候讓字型變化,無法在滾動過程中設定,體驗並不友好。

第二種,是否可以巧用 CSS,利用背景漸變和 background-size 配合完成漸變,利用蒙層來實現呢!

.nut-picker-mask{
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    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));
    background-position: top, bottom;
    background-size: 100% 108px;
    background-repeat: no-repeat;
    z-index: 3;
}

image

這裡把背景設定成黃色,便於我們看效果。

感覺還可以,這樣就搞定了嗎?

我們在pc端模擬一切正常,在真機上卻出現了詭異的畫面,上滑彈出的時候,蒙層會延遲展示,影響體驗效果。只有禁止上滑過渡效果,才可以正常展示。去除上滑效果是不可能的,我們只能考慮一下其他辦法。

第三種,是否可以設定一個附屬滾動,也就是上面說的高亮顯示區,將其蓋在滾輪上面,裡面每個元素高度等於可視區高度,當滾輪滑動的時候,高亮顯示區內部列表元素跟隨一起滑動。

image

實踐證明,這種方法可以避免上述兩個方法的弊端,完美解決我們的需求。來看一下具體實現方法。

.nut-picker-content {
    position: absolute;
    height: 36px;
    ...
    .nut-picker-roller-item{
        height: 36px;
        ...
    }
}

然後在上面的 setTransform 函式中,增加高亮展示區滾動效果。

setTransform(translateY = 0, type, time = 1000, deg) {
    ...
    this.$refs.list.style.transition =  type === 'end' ? `transform ${time}ms cubic-bezier(0.19, 1, 0.22, 1)` : '';
    this.$refs.list.style.transform = `translate3d(0, ${translateY}px, 0)`;
}

父元件部分

除了滾動效果,我們還有一些灰色蒙層、上滑彈出、工作欄等業務內容,我們交由父元件去處理。我們業務中也會涉及到多列情況,所以父元件可以把 props 資料拆分傳給子元件,讓每個子元件相互獨立,監聽子元件event事件,傳遞給外層。

三、遇到的問題

我們的元件是基於px來實現的,在 issues 中,收集到部分使用者遇到一些問題,這裡提供瞭解決方案。

1、使用px2rem,滾輪旋轉出現偏差

因為 px 轉 rem 有時候轉出來的值會有偏差,並且出現多個小數位,導致滾動的高度和實際轉化的高度出現偏差,我們可體通過以下配置解決

第一種:在.postcssrc.js配置檔案中,把nutui開頭的過濾掉

module.exports = ({ file }) => {
  return {
    plugins: [
        ...
        pxtorem({
            rootValue: rootValue,
            propList: ['*'],
            minPixelValue: 2,
            selectorBlackList: ['.nut'] // 設定
        })
   }
}

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

npm uninstall postcss-px2rem
npm i postcss-px2rem-exclude -D
// 在.postcssrc.js配置
module.exports = ({ file }) => {
    return {
        plugins: [
            ...
            pxtorem({
                remUnit: rootValue,
                exclude: '/node_modules/@nutui/nutui/packages/picker'
            })
        ]
   }
}

2、使用lib-flexible,元件被縮小問題

我們的 css 是基於 data-dpr 為1的時候編寫的,如果使用了 lib-flexible, 頁面要設定

<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元件庫,更多元件等你發現。

相關文章