天天看點

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

前言

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

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

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

靈感

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

确定需求

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

實作思路

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

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

拖拽實作

  1. 使用 movable-view 實作拖拽, 這種方式簡單快捷, 但是由于我們的靈感是使用 transform 做變換, 而這裡 movable-view 本身也是用 transform 來實作的, 是以會有沖突, 遂棄之.
  2. 使用自定義手勢, 如 touchstart, touchmove, touchend. 對的又是這三個基佬, 雖然我們在做下拉重新整理時候采用用了 movable-view 而抛棄這三兄弟. 但是是金子總會發光的, 今天就是你們三兄弟展示自身本領的時候了(真香警告). 廢話有點多, 言歸正傳, 使用自定義手勢可以友善我們控制每一個細節.

排序實作

排序是基于拖拽的, 通過上面 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 和 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元件位址