Vue實現浮動按鈕元件 - 頁面滾動時自動隱藏 - 可拖拽
效果圖
說明
本文可能有點囉嗦了...
元件難點
- 如何監聽滾動完成事件
- 移動端如何監聽拖拽事件
前置條件
為了充分發揮vue
的特性,我們不應該通過ref
來直接操作dom
,而是應該通過修改資料項從而讓vue
自動更新dom
。因此,我們這樣編寫template
。
<template>
<div class="ys-float-btn" :style="{'left':left+'px','top':top+'px'}">
<slot name="icon"></slot>
<p>{{text}}</p >
</div>
</template>
複製程式碼
當然.ys-float-btn
肯定是position:fixed
的,其他的樣式很簡單,大家自由發揮。
初始化位置
首次進入頁面時,按鈕應該處於一個初始位置。我們在created鉤子中進行初始化。
created(){
this.left = document.documentElement.clientWidth - 50;
this.top = document.documentElement.clientHeight*0.8;
},
複製程式碼
監聽滾動
為了能夠讓這個浮動按鈕能夠在頁面滾動時隱藏,第一步要做的就是監聽頁面滾動事件。
mounted(){
window.addEventListener('scroll', this.handleScrollStart);
},
methods:{
handleScrollStart(){
this.left = document.documentElement.clientWidth - 25;
}
}
複製程式碼
嗯,別忘了取消註冊。
beforeDestroy(){
window.removeEventListener('scroll', this.handleScrollStart);
},
複製程式碼
這樣就能夠讓元件在頁面滾動時往右再移動25畫素的距離。 but!我還沒有寫動畫誒...
過渡動畫
嗯,我當然不會使用js寫動畫了,我們在css
類.ys-float-btn
中加上transition: all 0.3s;
過渡動畫就搞定了。
滾動什麼時候完成呢?
監聽到scroll
事件只是第一步,那麼什麼時候scroll事件才會停止呢?瀏覽器並沒有為我們準備這樣一個事件,我們需要手動去實現它。思路其實也很簡單,當一個時間週期內頁面的scrollTop
不變就說明頁面滾動停止了。 所以我們需要在data
函式裡返回一個timer
物件,用來儲存我們的定時器。像這樣:
data(){
return{
timer:null,
currentTop:0
}
}
複製程式碼
改造一下handleScrollStart
方法。 觸發scroll
的時候清掉當前的計時器(如果存在),並重新計時
handleScrollStart(){
this.timer&&clearTimeout(this.timer);
this.timer = setTimeout(()=>{
this.handleScrollEnd();
},300);
this.currentTop = document.documentElement.scrollTop || document.body.scrollTop;
this.left = document.documentElement.clientWidth - 25;
},
複製程式碼
現在增加了一個回撥handleScrollEnd
方法
handleScrollEnd(){
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
if(scrollTop === this.currentTop){
this.left = document.documentElement.clientWidth - 50;
clearTimeout(this.timer);
}
}
複製程式碼
如果現在的滾動高度等於之前的滾動高度,說明頁面沒有繼續滾動了。將left
調整為初始位置。
關於拖拽我踩過的坑
為了實現元件的拖拽功能,我最先想到的就是html5
為我們提供的drag
方法。因此像這樣,為我們的template
增加這樣的程式碼。
<div class="ys-float-btn" :style="{'width':itemWidth+'px','height':itemHeight+'px','left':left+'px','top':top+'px'}"
:draggable ='true' @dragstart="onDragStart" @dragover.prevent = "onDragOver" @dragenter="onDragEnter" @dragend="onDragEnd">
<slot name="icon"></slot>
<p>{{text}}</p>
</div>
複製程式碼
結果在測試的時候就是沒有效果,設定的四個監聽方法一個都沒有執行。迷茫了好久,後來在自己找bug
期間無意將chrome
取消了移動端模式,然後發現拖拽監聽方法執行了。
這真是,無力吐槽。 記筆記了:移動端無法使用drag來進行元件的拖拽操作。
移動端拖拽
那麼移動端如何實現拖拽效果呢?瞭解到移動端有touch
事件。touch
與click
事件觸發的先後順序如下所示:
touchstart => touchmove => touchend => click。
這裡我們需要為元件註冊監聽以上touch
事件,怎麼拿到具體的dom
呢? vue
為我們提供了ref
屬性。
template
最外層的
div
加上
ref
<div class="ys-float-btn" :style="{'left':left+'px','top':top+'px'}"
ref="div">
<slot name="icon"></slot>
<p>{{text}}</p>
</div>
複製程式碼
為了確保元件已經成功掛載,我們在nextTick
中進行事件註冊。現在mounted鉤子方法長這樣:
mounted(){
window.addEventListener('scroll', this.handleScrollStart);
this.$nextTick(()=>{
const div = this.$refs.div;
div.addEventListener("touchstart",()=>{
});
div.addEventListener("touchmove",(e)=>{
});
div.addEventListener("touchend",()=>{
});
});
},
複製程式碼
在對元件進行拖拽的過程中,應當不需要元件的過度動畫的,所以我們在touchstart中取消過度動畫。
div.addEventListener("touchstart",()=>{
div.style.transition = 'none';
});
複製程式碼
在拖拽的過程中,元件應該跟隨手指的移動而移動。
div.addEventListener("touchmove",(e)=>{
if (e.targetTouches.length === 1) {//一根手指
let touch = event.targetTouches[0];
this.left = touch.clientX;
this.top = touch.clientY;
}
});
複製程式碼
可能有同學看了上面的程式碼之後已經看出來所疏漏的地方了,上述程式碼似乎能夠讓元件跟隨手指移動了,但是還差了點。因為並不是元件中心跟隨手指在移動。我們微調一下:
div.addEventListener("touchmove",(e)=>{
if (e.targetTouches.length === 1) {
let touch = event.targetTouches[0];
this.left = touch.clientX - 25;//元件的寬度是50
this.top = touch.clientY - 25;
}
});
複製程式碼
拖拽結束以後,判斷在頁面的稍左還是稍右,重新調整元件的位置並重新設定過度動畫。
div.addEventListener("touchend",()=>{
div.style.transition = 'all 0.3s';
if(this.left>document.documentElement.clientWidth/2){
this.left = document.documentElement.clientWidth - 50;
}else{
this.left = 0;
}
});
複製程式碼
寫到這裡是不是就完了呢? 我們好像漏了點什麼。 對了,頁面滾動時沒有判斷元件在左邊還是在右邊,當時統一當成右邊在處理了。 現在修改handleScrollStart和handleScrollEnd方法。
handleScrollStart(){
this.timer&&clearTimeout(this.timer);
this.timer = setTimeout(()=>{
this.handleScrollEnd();
},300);
this.currentTop = document.documentElement.scrollTop || document.body.scrollTop;
if(this.left>document.documentElement.clientWidth/2){
this.left = document.documentElement.clientWidth - 25;
}else{
this.left = -25;
}
},
handleScrollEnd(){
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
if(scrollTop === this.currentTop){
if(this.left>document.documentElement.clientWidth/2){
this.left = document.documentElement.clientWidth - 50;
}else{
this.left = 0;
}
clearTimeout(this.timer);
}
}
複製程式碼
重構
剛剛噼裡啪啦一頓敲鍵盤終於把這個元件寫完啦,這樣是不是就完事大吉了呢?不,當然不。我們為什麼要寫元件呢?不就是為了重用嗎,現在這個元件裡充斥著各種沒有標明意義的數字和重複程式碼,是時候重構一下了。 開發元件通常是資料先行,現在我們回過頭來看一下哪些資料需要預定義。
props:{
text:{
type:String,
default:"預設文字"
},
itemWidth:{
type:Number,
default:60
},
itemHeight:{
type:Number,
default:60
},
gapWidth:{
type:Number,
default:10
},
coefficientHeight:{
type:Number,
default:0.8
}
}
複製程式碼
我們需要元件的寬高和間隔(與頁面邊界的間隔),額對了,還有那個視口的寬度!我們在前文中多次使用document.documentElement.clientWidth
不知道你們有沒有看煩,我反正是寫煩了.... 元件內部用的資料我們用data定義:
data(){
return{
timer:null,
currentTop:0,
clientWidth:0,
clientHeight:0,
left:0,
top:0,
}
}
複製程式碼
因此,在元件建立的時候我們需要為這些資料做預處理! 現在created
長這樣:
created(){
this.clientWidth = document.documentElement.clientWidth;
this.clientHeight = document.documentElement.clientHeight;
this.left = this.clientWidth - this.itemWidth - this.gapWidth;
this.top = this.clientHeight*this.coefficientHeight;
},
複製程式碼
... 就到這裡吧,後面的都差不多了....
完整原始碼
<template>
<div class="ys-float-btn" :style="{'width':itemWidth+'px','height':itemHeight+'px','left':left+'px','top':top+'px'}"
ref="div"
@click ="onBtnClicked">
<slot name="icon"></slot>
<p>{{text}}</p>
</div>
</template>
<script>
export default {
name: "FloatImgBtn",
props:{
text:{
type:String,
default:"預設文字"
},
itemWidth:{
type:Number,
default:60
},
itemHeight:{
type:Number,
default:60
},
gapWidth:{
type:Number,
default:10
},
coefficientHeight:{
type:Number,
default:0.8
}
},
created(){
this.clientWidth = document.documentElement.clientWidth;
this.clientHeight = document.documentElement.clientHeight;
this.left = this.clientWidth - this.itemWidth - this.gapWidth;
this.top = this.clientHeight*this.coefficientHeight;
},
mounted(){
window.addEventListener('scroll', this.handleScrollStart);
this.$nextTick(()=>{
const div = this.$refs.div;
div.addEventListener("touchstart",()=>{
div.style.transition = 'none';
});
div.addEventListener("touchmove",(e)=>{
if (e.targetTouches.length === 1) {
let touch = event.targetTouches[0];
this.left = touch.clientX - this.itemWidth/2;
this.top = touch.clientY - this.itemHeight/2;
}
});
div.addEventListener("touchend",()=>{
div.style.transition = 'all 0.3s';
if(this.left>this.clientWidth/2){
this.left = this.clientWidth - this.itemWidth - this.gapWidth;
}else{
this.left = this.gapWidth;
}
});
});
},
beforeDestroy(){
window.removeEventListener('scroll', this.handleScrollStart);
},
methods:{
onBtnClicked(){
this.$emit("onFloatBtnClicked");
},
handleScrollStart(){
this.timer&&clearTimeout(this.timer);
this.timer = setTimeout(()=>{
this.handleScrollEnd();
},300);
this.currentTop = document.documentElement.scrollTop || document.body.scrollTop;
if(this.left>this.clientWidth/2){
this.left = this.clientWidth - this.itemWidth/2;
}else{
this.left = -this.itemWidth/2;
}
},
handleScrollEnd(){
let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
if(scrollTop === this.currentTop){
if(this.left>this.clientWidth/2){
this.left = this.clientWidth - this.itemWidth - this.gapWidth;
}else{
this.left = this.gapWidth;
}
clearTimeout(this.timer);
}
}
},
data(){
return{
timer:null,
currentTop:0,
clientWidth:0,
clientHeight:0,
left:0,
top:0,
}
}
}
</script>
<style lang="less" scoped>
.ys-float-btn{
background:rgb(255,255,255);
box-shadow:0 2px 10px 0 rgba(0,0,0,0.1);
border-radius:50%;
color: #666666;
z-index: 20;
transition: all 0.3s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: fixed;
bottom: 20vw;
img{
width: 50%;
height: 50%;
object-fit: contain;
margin-bottom: 3px;
}
p{
font-size:7px;
}
}
</style>
複製程式碼