微信小程式中懸浮窗功能的實現(主要探討和解決在原生元件上的拖動)
問題場景
所謂懸浮窗就是圖中微信圖示的按鈕,採用fixed定位,可拖動和點選。
這算是一個比較常見的實現場景了。
為什麼要用cover-view做懸浮窗?原生元件出來背鍋了~
最初我做懸浮窗用的不是cover-view,而是view。
這是簡化的程式碼結構:
index.wxml:
<view class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="setTouchMove"> <image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56"> </image> </view> <textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea> <view> 一大段test,佔個位,表示下存在感 </view>
index.js:
Page({ /** * 頁面的初始資料 */ data: { left: 20, top: 250, isIos: true }, /** * 拖拽移動 */ setTouchMove: function (e) { if (e.touches[0].clientX > 0 && e.touches[0].clientY > 0) { this.setData({ left: e.touches[0].clientX - 30, top: e.touches[0].clientY - 30 }) } else { this.setData({ left: 20, //預設顯示位置 left距離 top: 250 //預設顯示位置 top距離 }) } }, /** * 返回首頁 */ goToHome: () => { wx.reLaunch({ url: '/pages/index/index', }) } })
為什麼要用cover-view呢?
因為頁面上有個textarea元件,這個元件是原生元件,當懸浮窗移動到這個textarea元件上時,將無法繼續拖動和點選。
如果懸浮窗一開始就定位在textarea上,那麼就更慘了,一開始就不能點選和拖動了。
這個原因時因為微信小程式的原生元件層級高於非原生元件,不是你修改幾下樣式就能解決的問題。
這裡就不講什麼原生元件了,如果想進一步瞭解,可以參考我之前寫的一篇部落格:微信小程式在ios下Echarts圖表不能滑動的解決方案。
如果你的頁面上面沒有原生元件,那麼像上面的程式碼一樣用view做懸浮窗即可。
如果有,那麼就可以跟著我繼續踩坑,使用cover-view這個原生元件層級的元件來做懸浮窗。
安卓下的cover-view拖動起來,抖得不像帕金森,像是魔鬼的步伐
以下是我們修改為cover-view之後的程式碼:
<cover-view class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="setTouchMove">
<cover-image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</cover-image>
</cover-view>
<textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea>
<view>
一大段test,佔個位,表示下存在感
</view>
注意這裡,我們的image也改為了cover-image,因為cover-view只支援巢狀 cover-view、cover-image,不過可在 cover-view 中使用 button。
這樣雖然解決了可在原生元件上自由拖動點選的問題,但是在安卓上出現了一個很奇怪的現象,以至於我認為已經無法用抖動可以來形容了:
上圖是就是我滑動這個懸浮窗之後的效果,我只是很緩慢地在移動手指,但是這個懸浮窗的表現簡直就像一個受驚的兔子。
當我第一眼看見這個效果的時候一臉懵逼,我都不知道說什麼好。
雖然在ios上cover-view移動起來表現良好,但是在安卓上拖動起來的表現簡直沒法看。
勉強能看的補丁方案
安卓上這麼挫,還不如原來的呢。
所以來個補丁方案好了,在ios下用cover-view完美拖動,在安卓上用view先跑著。
<cover-view wx-if="{{isIos}}" class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="setTouchMove">
<cover-image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</cover-image>
</cover-view>
<view wx-if="{{!isIos}}" class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="setTouchMove">
<image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</image>
</view>
<textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea>
<view>
一大段test,佔個位,表示下存在感
</view>
當然少不了要在js裡面加上這句程式碼:
onLoad: function (options) {
wx.getSystemInfo({
success: (res) => {
if (res.platform == "android") {
this.setData({
isIos: false
})
}
}
})
}
不要忘記isIos預設為true哦。
反正ios環境下可以完美使用了,至於安卓下拖到textarea元件上沒法再拖的問題,調整下懸浮框的初始位置就好了。
而且只要不是刻意移動到textarea元件上,拖動著懸浮框經過textarea元件也是沒有問題的嘛。
像我這麼聰明的使用者還懂得滑動下面的頁面來使懸浮窗移動到非原生元件的地方,這樣就又可以拖動了嘛。
你又以為你的測試一定能發現這個問題?發現了又怎樣,我已經盡力了,還給你整出這麼多理論依據,足夠你把鍋牢牢地按在微信小程式官方的頭上。
使用movable-view:彷彿發現了新大陸,結果發現這個還是個弟弟
甩鍋是一定要甩鍋的,但是段位要高。
所以要遍查官方文件,探討一切可能性,以免甩鍋的時候被打臉。
我們仔細觀察小程式官方文件,發現還是有個專門用來拖動的元件叫movable-view。
這個元件和cover-view擺放在一起彷彿很厲害的樣子,緊接著我們在原生元件使用限制文件中發現了它並不是原生元件。
也就是說這個東西的層級一定還是低於咱們的textarea元件的。
雖然已經很確定這個東西沒什麼用了,但是最後還是試探一把,結果發現是個真弟弟,這裡就不給出程式碼了。
我寫這個弟弟方案放在這裡的目的主要是為了不要浪費你的驗證時間。
理論上行得通的方案:將拖動事件的捕獲放在父級
現在我們確認的最優甩鍋方案裡,已經實現了功能和甩鍋兩不誤。
那麼作為一名有追求的技術人員,還是需要去探討以下這個問題到底有沒有完美的解決方案。
因為我最開始是把這個懸浮窗做成了一個元件,那麼作為元件來講,這個東西就只能做到這個地步了。
不過如果你是像我現在的例子一樣直接做在了頁面裡,那麼實現起來也不是說沒有辦法的。
我們將拖動的事件放在父級上就可以了,請看接下來的程式碼:
index.wxml:
<view bindtouchmove="setTouchMove">
<view class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome">
<image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</image>
</view>
<textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea>
<view>
一大段test,佔個位,表示下存在感
</view>
</view>
index.js:
Page({
/**
* 頁面的初始資料
*/
data: {
left: 20,
top: 250
},
/**
* 拖拽移動
*/
setTouchMove: function (e) {
const MOVE_VIEW_RADIUS = 30 // 懸浮窗半徑
const touchPosX = e.touches[0].clientX
const touchPosY = e.touches[0].clientY
const moveViewCenterPosX = this.data.left + MOVE_VIEW_RADIUS
const moveViewCenterPosY = this.data.top + MOVE_VIEW_RADIUS
// 確保手指在懸浮窗上才可以移動
if (Math.abs(moveViewCenterPosX - touchPosX) < MOVE_VIEW_RADIUS + 60 && Math.abs(moveViewCenterPosY - touchPosY) < MOVE_VIEW_RADIUS + 60) {
if (touchPosX > 0 && touchPosY > 0) {
this.setData({
left: touchPosX - MOVE_VIEW_RADIUS,
top: touchPosY - MOVE_VIEW_RADIUS
})
} else {
this.setData({
left: 20, // 預設顯示位置 left距離
top: 250 // 預設顯示位置 top距離
})
}
}
},
/**
* 返回首頁
*/
goToHome: () => {
wx.reLaunch({
url: '/pages/index/index',
})
}
})
關鍵程式碼就是這塊了:
// 確保手指在懸浮窗上才可以移動
if (Math.abs(moveViewCenterPosX - touchPosX) < MOVE_VIEW_RADIUS + 60 && Math.abs(moveViewCenterPosY - touchPosY) < MOVE_VIEW_RADIUS + 60) {
}
只要確保手指在懸浮窗的範圍內就可以觸發移動了,這裡的60是為了確保你的手指太大,或者移動得比較快時超出了懸浮窗區域依然可以觸發拖動,這個可以自己設定數值。
這個方案在理論上很合理,並且還加上了60這個緩衝區域,但是實際在拖動的時候你仍然會面臨下面三個問題:
1.如果懸浮窗下方有滾動區域,那麼拖動的時候就會滾動頁面,效果會顯得比較奇怪。
2.實際移動沒法移動太順暢,只能拖著懸浮窗亦步亦趨,要不然很容易超過60這個緩衝區域,導致拖動不繼續觸發。
2.如果將緩衝區域設定過大,那麼又會出現一種比較奇怪的場景:明明不準備拖動懸浮窗,只是準備滑動頁面,懸浮窗卻跳到自己手指這裡了。
進階解決方案:禁止冒泡的拖動 + 理論方案
這個解決方案基於我們的最初方案,並且使用我們的理論方案作為補充。
先上程式碼:
index.wxml:
<view bindtouchmove="handleSetMoveViewPos">
<view class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="handleTouchMove">
<image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</image>
</view>
<textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea>
<view>
一大段test,佔個位,表示下存在感
</view>
</view>
index.js:
Page({
/**
* 頁面的初始資料
*/
data: {
left: 20,
top: 250
},
/**
* 拖拽移動(補丁)
*/
handleSetMoveViewPos: function (e) {
const MOVE_VIEW_RADIUS = 30 // 懸浮窗半徑
const touchPosX = e.touches[0].clientX
const touchPosY = e.touches[0].clientY
const moveViewCenterPosX = this.data.left + MOVE_VIEW_RADIUS
const moveViewCenterPosY = this.data.top + MOVE_VIEW_RADIUS
// 確保手指在懸浮窗上才可以移動
if (Math.abs(moveViewCenterPosX - touchPosX) < MOVE_VIEW_RADIUS+30 && Math.abs(moveViewCenterPosY - touchPosY) < MOVE_VIEW_RADIUS+30 ) {
if (touchPosX > 0 && touchPosY > 0) {
this.setData({
left: touchPosX - MOVE_VIEW_RADIUS,
top: touchPosY - MOVE_VIEW_RADIUS
})
} else {
this.setData({
left: 20, // 預設顯示位置 left距離
top: 250 // 預設顯示位置 top距離
})
}
}
},
/**
* 拖拽移動
*/
handleTouchMove: function (e) {
const MOVE_VIEW_RADIUS = 30 // 懸浮窗半徑
const touchPosX = e.touches[0].clientX
const touchPosY = e.touches[0].clientY
if (touchPosX > 0 && touchPosY > 0) {
this.setData({
left: touchPosX - MOVE_VIEW_RADIUS,
top: touchPosY - MOVE_VIEW_RADIUS
})
} else {
this.setData({
left: 20, //預設顯示位置 left距離
top: 250 //預設顯示位置 top距離
})
}
},
/**
* 返回首頁
*/
goToHome: () => {
wx.reLaunch({
url: '/pages/index/index',
})
}
})
這個方案的核心點在於:catchtouchmove="handleTouchMove" 。
當我們正常拖動懸浮窗時,通過catchtouchmove,我們可以捕獲在懸浮窗上的滑動事件,並且不冒泡到父元素,那麼我們綁在父層級的滑動事件就不會觸發。
而當我們拖動在原生元件之上的懸浮窗時,因為點不到這個懸浮窗,就不會觸發handleTouchMove函式,只會觸發繫結在父元素上的handleSetMoveViewPos函式。
另外如果你細心的話,就會發現在handleSetMoveViewPos函式這裡我縮小了那個60的緩衝區域為30,這樣做的目的是因為觸發這個函式只會在原生元件上,所以多番權衡距離之後,儘量避免近距離滑動操作就觸發拖動懸浮框。
通過我們的方案,我們可以在非原生元件上自由拖動,在原生元件上比較順暢地拖動。
本來我是準備將這個方案作為最終方案的,但是ios下,懸浮窗在原生元件上時,在父元素上的滑動事件竟然不觸發。
棋差一招,棋差一招啊!
最終解決方案:更多的補丁,更多的快樂
這個最終解決方案,當然是把我們之前所有的補丁方案全部結合起來。
程式碼如下:
index.wxml:
<view bindtouchmove="handleSetMoveViewPos">
<view wx-if="{{!isIos}}" class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="handleTouchMove">
<image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</image>
</view>
<cover-view wx-if="{{isIos}}" class="move-view" style=" top:{{top}}px;left:{{left}}px;" bindtap="goToHome" catchtouchmove="handleTouchMove">
<cover-image class="img" src="https://ss2.baidu.com/6ONYsjip0QIZ8tyhnq/it/u=4294841024,3545417298&fm=179&app=42&f=PNG?w=56&h=56">
</cover-image>
</cover-view>
<textarea placeholder='我是textarea元件,用來輸入一些資訊'></textarea>
<view>
一大段test,佔個位,表示下存在感
</view>
</view>
index.js:
Page({
/**
* 頁面的初始資料
*/
data: {
left: 20,
top: 250,
isIos: true
},
/**
* 生命週期函式--監聽頁面載入
*/
onLoad: function (options) {
wx.getSystemInfo({
success: (res) => {
if (res.platform == "android") {
this.setData({
isIos: false
})
}
}
})
},
/**
* 拖拽移動(補丁)
*/
handleSetMoveViewPos: function (e) {
// 在ios下永遠都不會走這個方案,以免引起無用的計算
if (!ios) {
const MOVE_VIEW_RADIUS = 30 // 懸浮窗半徑
const touchPosX = e.touches[0].clientX
const touchPosY = e.touches[0].clientY
const moveViewCenterPosX = this.data.left + MOVE_VIEW_RADIUS
const moveViewCenterPosY = this.data.top + MOVE_VIEW_RADIUS
// 確保手指在懸浮窗上才可以移動
if (Math.abs(moveViewCenterPosX - touchPosX) < MOVE_VIEW_RADIUS && Math.abs(moveViewCenterPosY - touchPosY) < MOVE_VIEW_RADIUS) {
if (touchPosX > 0 && touchPosY > 0) {
this.setData({
left: touchPosX - MOVE_VIEW_RADIUS,
top: touchPosY - MOVE_VIEW_RADIUS
})
} else {
this.setData({
left: 20, // 預設顯示位置 left距離
top: 250 // 預設顯示位置 top距離
})
}
}
}
},
/**
* 拖拽移動
*/
handleTouchMove: function (e) {
const MOVE_VIEW_RADIUS = 30 // 懸浮窗半徑
const touchPosX = e.touches[0].clientX
const touchPosY = e.touches[0].clientY
if (touchPosX > 0 && touchPosY > 0) {
this.setData({
left: touchPosX - MOVE_VIEW_RADIUS,
top: touchPosY - MOVE_VIEW_RADIUS
})
} else {
this.setData({
left: 20, //預設顯示位置 left距離
top: 250 //預設顯示位置 top距離
})
}
},
/**
* 返回首頁
*/
goToHome: () => {
wx.reLaunch({
url: '/pages/index/index',
})
}
})
這個最終解決方案在ios下直接使用cover-view來做懸浮窗,而在android的非原生元件上移動時,使用view來做懸浮窗,不冒泡滑動事件,在原生元件上移動時捕獲冒泡的滑動事件來繼續移動操作。
總結
雖然問題解決了,但是這仍然只是一個補丁方案。
最好的方式依然是微信小程式官方能修復cover-view在安卓移動時的BUG,但是我發現最早有人反饋這個問題是在2018年11月,到了現在2019年8月都沒有結果。
如果不是微信小程式的官方態度有問題,那麼只能說明這個問題的解決確實有難度或者優先順序並不高,無論是哪一種,暫時都還是得用補丁方案。
這個方案並沒有那麼完美,他在一些邊界的銜接上面可能還是會存在一些小問題,但它至少可用,並且應該是大多數使用者可以接受的