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

前言

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

此次就不上效果圖了, 直接掃碼感覺吧.jquery

靈感

首先因爲並無啥現成的小程序案例給我參考. 因此有點無從下手, 那就找個h5的拖拽實現參考參考. 因而在jquery插件網看了幾個拖拽排序實現後基本肯定了思路. 大概就是用 transform 作變換. 是的, 靈感這種東西就是借鑑過來的~~git

肯定需求

  1. 要能拖拽, 畢竟是拖拽排序嘛, 拖拽確定是第一位.
  2. 要能排序, 先有拖拽後有天 ~~ 跑偏了, 拖拽完了確定是要排序的要否則和movable-view有啥區別呢.
  3. 能自定義列數以實現不一樣形式展示, 這裏考慮到有列表排序, 相冊排序等不一樣狀況須要的列數不一樣的需求.
  4. 沒有bug, 呃呃呃, 這個我儘可能.

實現思路

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

而後咱們對應需求找解決方案:小程序

拖拽實現

  1. 使用 movable-view 實現拖拽, 這種方式簡單快捷, 可是因爲咱們的靈感是使用 transform 作變換, 而這裏 movable-view 自己也是用 transform 來實現的, 因此會有衝突, 遂棄之.api

  2. 使用自定義手勢, 如 touchstart, touchmove, touchend. 對的又是這三個基佬, 雖然咱們在作下拉刷新時候採用用了 movable-view 而拋棄這三兄弟. 可是是金子總會發光的, 今天就是大家三兄弟展現自身本領的時候了(真香警告). 廢話有點多, 言歸正傳, 使用自定義手勢能夠方便咱們控制每個細節.數組

排序實現

排序是基於拖拽的, 經過上面 touchstart, touchmove, touchend 這三兄弟拿到觸摸信息後動態計算出當前元素的排序位置,而後根據當前激活元素的排序位置去動態更換數組內其餘元素的位置. 大概意思就是十個兄弟作一排, 老大起來跑到老三的位置, 老三看了看往前移了移, 老二看了看也往前移了移. 固然這是正序, 還有逆序, 好比老十跑到了老大的位置, 那麼老大到老九都得順序後移一個位置.緩存

自定義列數

自定義列數, 到是沒啥難度, 小程序組件暴露一個列屬性, 而後把計算過程當中的固定的列數改爲該參數就能夠了微信

實現分析

先上 touchstart, touchmove, touchend 三兄弟dom

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 和 curZ(該參數用於控制激活元素z軸的顯示時機, 具體參看 wxml 中代碼以及 clearData 方法中對應的代碼) 以及偏移量 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,
        curZ: 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
    });

    // 延遲清空
    setTimeout(() => {
        this.setData({
            curZ: -1,
        })
    }, 300)
}

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> 並傳入了每一項的數據, 使用抽象節點是爲了具體展現的效果和該組件自己代碼解耦. 若是要到性能問題或者以爲麻煩, 可直接在該組件下編寫樣式代碼.

最新實現中已經刪除了抽象節點, 經測試抽象節點會在某些老款機型如: iphone 6s 及如下型號機器上產生巨大性能問題, 因此這裏直接把渲染邏輯寫入 wxml 中. 須要使用該組件直接修改 .info 部分樣式和內容便可.

<view>
    <view style="overflow-x: {{overOnePage ? 'hidden' : 'initial'}}">
        <view class="item-wrap" style="height: {{ itemWrapHeight }}px;">
            <view class="item {{cur == index? 'cur':''}} {{curZ == index? 'zIndex':''}} {{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">
                <view class="info">
                    <view>
                        <image src="{{item.data.images}}"></image>
                    </view>
                </view>
            </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;
        }
        &.zIndex {
            z-index: 2;
        }
        &.cur {
            background: #c6c6c6;
            transition: initial;
        }
    }
}

.info {
    position: relative;
    padding-top: 100%;
    background: #ffffff;
    & > view {
        position: absolute;
        border: 1rpx solid $lineColor;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        overflow: hidden;
        padding: 10rpx;
        box-sizing: border-box;
        image {
            width: 100%;
            height: 100%;
        }
    }
}

.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組件地址

相關文章
相關標籤/搜索