1. 程式人生 > >一款優雅的小程式拖拽排序元件實現

一款優雅的小程式拖拽排序元件實現

前言

最近po主寫小程式過程中遇到一個拖拽排序需求. 上網一頓搜尋未果, 遂自行實現.

這次就不上效果圖了, 直接掃碼感受吧.

靈感

首先由於並沒有啥現成的小程式案例給我參考. 所以有點無從下手, 那就找個h5的拖拽實現參考參考. 於是在jquery外掛網看了幾個拖拽排序實現後基本確定了思路. 大概就是用 transform 做變換. 是的, 靈感這種東西就是借鑑過來的~~

確定需求

  1. 要能拖拽, 畢竟是拖拽排序嘛, 拖拽肯定是第一位.
  2. 要能排序, 先有拖拽後有天 ~~ 跑偏了, 拖拽完了肯定是要排序的要不然和movable-view有啥區別呢.
  3. 能自定義列數以實現不同形式展現, 這裡考慮到有列表排序, 相簿排序等不同情況需要的列數不同的需求.
  4. 沒有bug, 呃呃呃, 這個我儘量.

實現思路

首先能拖拽的元素最起碼都要是一樣的大小, 至於不規則大小, 或者大小成倍數關係的均不在本次實現範圍.

然後我們對應需求找解決方案:

拖拽實現

  1. 使用 movable-view 實現拖拽, 這種方式簡單快捷, 但是由於我們的靈感是使用 transform 做變換, 而這裡 movable-view 本身也是用 transform 來實現的, 所以會有衝突, 遂棄之.

  2. 使用自定義手勢, 如 touchstart, touchmove, touchend. 對的又是這三個基佬, 雖然我們在做下拉重新整理時候採用用了 movable-view 而拋棄這三兄弟. 但是是金子總會發光的, 今天就是你們三兄弟展示自身本領的時候了(真香警告). 廢話有點多, 言歸正傳, 使用自定義手勢可以方便我們控制每一個細節, 美中不足的是他們的父親並沒有提供手動阻止冒泡的特性, 搞了個 catch 和 bind 事件, 然並不支援動態切換, 所以很坑爹, 不過這都不是問題, 就是坑了些罷了.

排序實現

排序是基於拖拽的, 通過上面 touchstart, touchmove, touchend 這三兄弟拿到觸控資訊後動態計算出當前元素的排序位置,然後根據當前啟用元素的排序位置去動態更換陣列內其他元素的位置. 大概意思就是十個兄弟做一排, 老大起來跑到老三的位置, 老三看了看往前移了移, 老二看了看也往前移了移. 當然這是正序, 還有逆序, 比如老十跑到了老大的位置, 那麼老大到老九都得順序後移一個位置.

自定義列數

自定義列數, 到是沒啥難度, 小程式元件暴露一個列屬性, 然後把計算過程中的固定的列數改成該引數就可以了

實現分析

先上 touchstart, touchmove, touchend 三兄弟

longPress

這裡為了體驗把 touchstart 換成了 longpress 長按觸發. 首先我們需要設定一個狀態 touch 表示我們在拖拽了. 然後就是獲取 pageX, pageY 注意這裡獲取 pageX, pageY 而不是 clientX, clientY 因為我們的 drag 元件有可能會有 margin 或者頂部仍有其他元素, 這時候如果獲取 clientX, clientY 就會出現偏差了. 這裡把當前 pageX, pageY 設定為初始觸控點 startX, startY.

然後需要計算下初始化的啟用元素的偏移位置 tranX 和 tranY, 這裡為了優化體驗在列數為1的時候初始化 tranX 不做位移, tranY 移動到當前啟用元素中間位置, 多列的時候把 tranX 和 tranY 全部位移到當前啟用元素中間位置.

最後設定當前啟用元素的索引 cur 以及偏移量 tranX, tranY. 然後震動一下下 wx.vibrateShort() 體驗美美噠.

/**
 * 長按觸發移動排序
 */
longPress(e) {
    this.setData({
        touch: true
    });

    this.startX = e.changedTouches[0].pageX
    this.startY = e.changedTouches[0].pageY

    let index = e.currentTarget.dataset.index;

    if(this.data.columns === 1) { // 單列時候X軸初始不做位移
        this.tranX = 0;
    } else {  // 多列的時候計算X軸初始位移, 使 item 水平中心移動到點選處
        this.tranX = this.startX - this.item.width / 2 - this.itemWrap.left;
    }

    // 計算Y軸初始位移, 使 item 垂直中心移動到點選處
    this.tranY = this.startY - this.item.height / 2 - this.itemWrap.top;

    this.setData({
        cur: index,
        tranX: this.tranX,
        tranY: this.tranY,
    });

    wx.vibrateShort();
}

touchMove

touchmove 每次都是故事的主角, 這次也不列外. 看這滿滿的程式碼量就知道了. 首先進來需要判斷是否在拖拽中, 不是則需要返回.

然後判斷是否超過一螢幕. 這是啥意思呢, 因為我們的拖拽元素可能會很多甚至超過整個螢幕, 需要滑動來處理. 但是我們這裡使用了 catch:touchmove 事件所以會阻塞頁面滑動. 於是我們需要在元素超過一個螢幕的時候進行處理, 這裡分兩種情況. 一種是我們拖拽元素到頁面底部時候頁面自動向下滾動一個元素高度的距離, 另一種是當拖拽元素到頁面頂部時候頁面自動向上滾動一個元素高度的距離.

接著我們設定已經重新計算好的 tranX 和 tranY, 並獲取當前元素的排序關鍵字 key 作為初始 originKey, 然後通過當前的 tranX 和 tranY 使用 calculateMoving 方法計算出 endKey.

最後我們呼叫 this.insert(originKey, endKey) 方法來對陣列進行排序

touchMove(e) {
    if (!this.data.touch) return;
    let tranX = e.touches[0].pageX - this.startX + this.tranX,
        tranY = e.touches[0].pageY - this.startY + this.tranY;

    let overOnePage = this.data.overOnePage;

    // 判斷是否超過一螢幕, 超過則需要判斷當前位置動態滾動page的位置
    if(overOnePage) {
        if(e.touches[0].clientY > this.windowHeight - this.item.height) {
            wx.pageScrollTo({
                scrollTop: e.touches[0].pageY + this.item.height - this.windowHeight,
                duration: 300
            });
        } else if(e.touches[0].clientY < this.item.height) {
            wx.pageScrollTo({
                scrollTop: e.touches[0].pageY - this.item.height,
                duration: 300
            });
        }
    }

    this.setData({tranX: tranX, tranY: tranY});

    let originKey = e.currentTarget.dataset.key;

    let endKey = this.calculateMoving(tranX, tranY);

    // 防止拖拽過程中發生亂序問題
    if (originKey == endKey || this.originKey == originKey) return;

    this.originKey = originKey;

    this.insert(originKey, endKey);
}

calculateMoving 方法

通過以上介紹我們已經基本完成了拖拽排序的主要功能, 但是還有兩個關鍵函式沒有解析. 其中一個就是 calculateMoving 方法, 該方法根據當前偏移量 tranX 和 tranY 來計算 目標key.

具體計算規則:

  1. 根據列表的長度以及列數計算出當前的拖拽元素行數 rows
  2. 根據 tranX 和 當前元素的寬度 計算出 x 軸上的偏移數 i
  3. 根據 tranY 和 當前元素的高度 計算出 y 軸上的偏移數 j
  4. 判斷 i 和 j 的最大值和最小值
  5. 根據公式 endKey = i + columns * j 計算出 目標key
  6. 判斷 目標key 的最大值
  7. 返回 目標key
/**
 * 根據當前的手指偏移量計算目標key
 */
calculateMoving(tranX, tranY) {
    let rows = Math.ceil(this.data.list.length / this.data.columns) - 1,
        i = Math.round(tranX / this.item.width),
        j = Math.round(tranY / this.item.height);

    i = i > (this.data.columns - 1) ? (this.data.columns - 1) : i;
    i = i < 0 ? 0 : i;

    j = j < 0 ? 0 : j;
    j = j > rows ? rows : j;

    let endKey = i + this.data.columns * j;

    endKey = endKey >= this.data.list.length ? this.data.list.length - 1 : endKey;

    return endKey
}

insert 方法

拖拽排序中沒有解析的另一個主要函式就是 insert方法. 該方法根據 originKey(起始key) 和 endKey(目標key) 來對陣列進行重新排序.

具體排序規則:

  1. 首先判斷 origin 和 end 的大小進行不同的邏輯處理
  2. 迴圈列表 list 進行邏輯處理
  3. 如果是 origin 小於 end 則把 origin 到 end 之間(不包含 origin 包含 end) 所有元素的 key 減去 1, 並把 origin 的key值設定為 end
  4. 如果是 origin 大於 end 則把 end 到 origin 之間(不包含 origin 包含 end) 所有元素的 key 加上 1, 並把 origin 的key值設定為 end
  5. 呼叫 getPosition 方法進行渲染
/**
 * 根據起始key和目標key去重新計算每一項的新的key
 */
insert(origin, end) {
    let list;

    if (origin < end) {
        list = this.data.list.map((item) => {
            if (item.key > origin && item.key <= end) {
                item.key = item.key - 1;
            } else if (item.key == origin) {
                item.key = end;
            }
            return item
        });
        this.getPosition(list);

    } else if (origin > end) {
        list = this.data.list.map((item) => {
            if (item.key >= end && item.key < origin) {
                item.key = item.key + 1;
            } else if (item.key == origin) {
                item.key = end;
            }
            return item
        });
        this.getPosition(list);
    }
}

getPosition 方法

以上 insert 方法中我們最後呼叫了 getPosition 方法, 該方法用於計算每一項元素的 tranX 和 tranY 並進行渲染, 該函式在初始化渲染時候也需要呼叫. 所以加了一個 vibrate 變數進行不同的處理判斷.

該函式執行邏輯:

  1. 首先對傳入的 data 資料進行迴圈處理, 根據以下公式計算出每個元素的 tranX 和 tranY (this.item.width, this.item.height 分別是元素的寬和高, this.data.columns 是列數, item.key 是當前元素的排序key值)
    item.tranX = this.item.width * (item.key % this.data.columns);
    item.tranY = Math.floor(item.key / this.data.columns) * this.item.height;
  2. 設定處理後的列表資料 list
  3. 判斷是否需要執行抖動以及觸發事件邏輯, 該判斷用於區分初始化呼叫和insert方法中呼叫, 初始化時候不需要後面邏輯
  4. 首先設定 itemTransition 為 true 讓 item 變換時候加有動畫效果
  5. 然後抖一下, wx.vibrateShort(), 嗯~, 這是個好東西
  6. 最後copy一份 listData 然後出發 change 事件把排序後的資料丟擲去

最後注意, 該函式並未改變 list 中真正的排序, 而是根據 key 來進行偽排序, 因為如果改變 list 中每一個項的順序 dom結構會發生變化, 這樣就達不到我們要的絲滑效果了. 但是最後 this.triggerEvent('change', {listData: listData}) 時候是真正排序後的資料, 並且已經去掉了 key, tranX, tranY 的原始資料資訊(這裡每一項資料有key, tranX, tranY 是因為初始化時候做了處理, 所以使用時無需考慮)

/**
 * 根據排序後 list 資料進行位移計算
 */
getPosition(data, vibrate = true) {
    let list = data.map((item, index) => {
        item.tranX = this.item.width * (item.key % this.data.columns);
        item.tranY = Math.floor(item.key / this.data.columns) * this.item.height;
        return item
    });

    this.setData({
        list: list
    });

    if(!vibrate) return;

    this.setData({
        itemTransition: true
    })

    wx.vibrateShort();

    let listData= [];

    list.forEach((item) => {
        listData[item.key] = item.data
    });

    this.triggerEvent('change', {listData: listData});
}

touchEnd

寫了這麼久, 三兄弟就剩最後一個了, 這個兄dei貌似不怎麼努力嘛, 就兩行程式碼?

是的, 就兩行... 一行判斷是否在拖拽, 另一行清除快取資料

touchEnd() {
    if (!this.data.touch) return;

    this.clearData();
}

clearData 方法

因為有重複使用, 所以選擇把這些邏輯包裝了一層.

/**
 * 清除引數
 */
clearData() {
    this.originKey = -1;

    this.setData({
        touch: false,
        cur: -1,
        tranX: 0,
        tranY: 0
    });
}

init 方法

介紹完三兄弟以及他們的表親後, 故事就剩我們的 init 方法了.

init 方法執行邏輯:

  1. 首先就是對傳入的 listData 做處理加上 key, tranX, tranY 等資訊
  2. 然後設定處理後的 list 以及 itemTransition 為 false(這樣初始化就不會看見動畫了)
  3. 獲取 windowHeight
  4. 獲取每一項 item 的寬高等屬性 並設定為 this.item 留做後用
  5. 初始化執行 this.getPosition(this.data.list, false)
  6. 設定動態計算出來的父級元素高度 itemWrapHeight, 因為這裡使用了絕對定位和transform所以父級元素無法獲得高度, 故手動計算並賦值
  7. 最後就是獲取父級元素 item-wrap 的節點資訊並計算是否超過一屏, 並設定 overOnePage 值
init() {
    // 遍歷資料來源增加擴充套件項, 以用作排序使用
    let list = this.data.listData.map((item, index) => {
        let data = {
            key: index,
            tranX: 0,
            tranY: 0,
            data: item
        }
        return data
    });

    this.setData({
        list: list,
        itemTransition: false
    });

    this.windowHeight = wx.getSystemInfoSync().windowHeight;

    // 獲取每一項的寬高等屬性
    this.createSelectorQuery().select(".item").boundingClientRect((res) => {

        let rows = Math.ceil(this.data.list.length / this.data.columns);

        this.item = res;

        this.getPosition(this.data.list, false);

        let itemWrapHeight = rows * res.height;

        this.setData({
            itemWrapHeight: itemWrapHeight
        });

        this.createSelectorQuery().select(".item-wrap").boundingClientRect((res) => {
            this.itemWrap = res;

            let overOnePage = itemWrapHeight + res.top > this.windowHeight;

            this.setData({
                overOnePage: overOnePage
            });

        }).exec();
    }).exec();
}

wxml

以下是整個元件的 wxml, 其中具體渲染部分使用了抽象節點 <item item="{{item.data}}"></item> 並傳入了每一項的資料, 使用抽象節點是為了具體展示的效果和該元件本身程式碼解耦. 如果要到效能問題或者覺得麻煩, 可直接在該元件下編寫樣式程式碼.

<view>
    <view style="overflow-x: {{overOnePage ? 'hidden' : 'initial'}}">
        <view class="item-wrap" style="height: {{ itemWrapHeight }}px;">
            <view class="item {{cur == index? 'cur':''}} {{itemTransition ? 'itemTransition':''}}"
                  wx:for="{{list}}"
                  wx:key="{{index}}"
                  id="item{{index}}"
                  data-key="{{item.key}}"
                  data-index="{{index}}"
                  style="transform: translate3d({{index === cur ? tranX : item.tranX}}px, {{index === cur ? tranY: item.tranY}}px, 0px);width: {{100 / columns}}%"
                  bind:longpress="longPress"
                  catch:touchmove="touchMove"
                  catch:touchend="touchEnd">
                <item item="{{item.data}}"></item>
            </view>
        </view>

    </view>
    <view wx:if="{{overOnePage}}" class="indicator">
        <view>滑動此區域滾動頁面</view>
    </view>
</view>

wxss

這裡我直接把 scss 程式碼拉出來了, 這樣看的更清楚, 具體完整程式碼文末會給出地址

@import "../../assets/css/variables";

.item-wrap {
    position: relative;
    .item {
        position: absolute;
        width: 100%;
        z-index: 1;
        &.itemTransition {
            transition: transform 0.3s;
        }
        &.cur {
            z-index: 2;
            background: $mainColorActive;
            transition: initial;
        }
    }
}

.indicator {
    position: fixed;
    z-index: 99999;
    right: 0rpx;
    top: 50%;
    margin-top: -250rpx;
    padding: 20rpx;
    & > view {
        width: 36rpx;
        height: 500rpx;
        background: #ffffff;
        border-radius: 30rpx;
        box-shadow: 0 0 10rpx -4rpx rgba(0, 0, 0, 0.5);
        color: $mainColor;
        padding-top: 90rpx;
        box-sizing: border-box;
        font-size: 24rpx;
        text-align: center;
        opacity: 0.8;
    }
}

寫在結尾

該拖拽元件來來回回花了我好幾周時間, 算的上是該元件庫中最有質量的一個元件了. 所以如果您看了覺得還不錯歡迎star. 當然遇到問題在 issues 提給我就行了, 我回復還是蠻快的~~

還有就是該元件受限制於微信本身的 api 以及一些特性, 在超出一屏時候會無法滑動. 這裡我做了個判斷超出一屏時候加了個指示器輔助滑動, 使用時可對樣式稍做修改(因為感覺有點醜...)

其他的好像沒啥了...

補充一句, 該元件基本上沒怎麼使用太多小程式相關的特性, 所以按照這個思慮用h5實現應該也是可以的, 如果有h5方面的需求應該也是可以滿足的...

drag元件地