最近po主寫小程序過程當中遇到一個拖拽排序需求. 上網一頓搜索未果, 遂自行實現.css
此次就不上效果圖了, 直接掃碼感覺吧.jquery
首先因爲並無啥現成的小程序案例給我參考. 因此有點無從下手, 那就找個h5的拖拽實現參考參考. 因而在jquery插件網看了幾個拖拽排序實現後基本肯定了思路. 大概就是用 transform 作變換. 是的, 靈感這種東西就是借鑑過來的~~git
首先能拖拽的元素最起碼都要是同樣的大小, 至於不規則大小, 或者大小成倍數關係的均不在本次實現範圍.github
而後咱們對應需求找解決方案:小程序
使用 movable-view 實現拖拽, 這種方式簡單快捷, 可是因爲咱們的靈感是使用 transform 作變換, 而這裏 movable-view 自己也是用 transform 來實現的, 因此會有衝突, 遂棄之.api
使用自定義手勢, 如 touchstart, touchmove, touchend. 對的又是這三個基佬, 雖然咱們在作下拉刷新時候採用用了 movable-view 而拋棄這三兄弟. 可是是金子總會發光的, 今天就是大家三兄弟展現自身本領的時候了(真香警告). 廢話有點多, 言歸正傳, 使用自定義手勢能夠方便咱們控制每個細節.數組
排序是基於拖拽的, 經過上面 touchstart, touchmove, touchend 這三兄弟拿到觸摸信息後動態計算出當前元素的排序位置,而後根據當前激活元素的排序位置去動態更換數組內其餘元素的位置. 大概意思就是十個兄弟作一排, 老大起來跑到老三的位置, 老三看了看往前移了移, 老二看了看也往前移了移. 固然這是正序, 還有逆序, 好比老十跑到了老大的位置, 那麼老大到老九都得順序後移一個位置.緩存
自定義列數, 到是沒啥難度, 小程序組件暴露一個列屬性, 而後把計算過程當中的固定的列數改爲該參數就能夠了微信
先上 touchstart, touchmove, touchend 三兄弟dom
這裏爲了體驗把 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 每次都是故事的主角, 此次也不列外. 看這滿滿的代碼量就知道了. 首先進來須要判斷是否在拖拽中, 不是則須要返回.
而後判斷是否超過一屏幕. 這是啥意思呢, 由於咱們的拖拽元素可能會不少甚至超過整個屏幕, 須要滑動來處理. 可是咱們這裏使用了 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 方法, 該方法根據當前偏移量 tranX 和 tranY 來計算 目標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方法. 該方法根據 originKey(起始key) 和 endKey(目標key) 來對數組進行從新排序.
具體排序規則:
/** * 根據起始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); } }
以上 insert 方法中咱們最後調用了 getPosition 方法, 該方法用於計算每一項元素的 tranX 和 tranY 並進行渲染, 該函數在初始化渲染時候也須要調用. 因此加了一個 vibrate 變量進行不一樣的處理判斷.
該函數執行邏輯:
最後注意, 該函數並未改變 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}); }
寫了這麼久, 三兄弟就剩最後一個了, 這個兄dei貌似不怎麼努力嘛, 就兩行代碼?
是的, 就兩行... 一行判斷是否在拖拽, 另外一行清除緩存數據
touchEnd() { if (!this.data.touch) return; this.clearData(); }
由於有重複使用, 因此選擇把這些邏輯包裝了一層.
/** * 清除參數 */ clearData() { this.originKey = -1; this.setData({ touch: false, cur: -1, tranX: 0, tranY: 0 }); // 延遲清空 setTimeout(() => { this.setData({ curZ: -1, }) }, 300) }
介紹完三兄弟以及他們的表親後, 故事就剩咱們的 init 方法了.
init 方法執行邏輯:
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, 其中具體渲染部分使用了抽象節點 <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>
這裏我直接把 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方面的需求應該也是能夠知足的...