微信小程式 MaterialDesign(1)---- button(漣漪)
本文用來介紹關於如何在微信小程式中實現materia風格的ui化
注意:該ui使用微信小程式原生語法,動畫均使用animate以及過渡效果實現,未使用微信的api建立動畫
1.準備
建立一個自定義元件 sc-button
在sc-button.json中指明這是一個自定義元件
{
"component":true
}
2. 封裝button
2.1 初始html格式
<button class="btn-class">
<slot></slot>
</button>
2.2 處理微信原生事件以及指令
微信小程式的button有很多內建的微信指令例如 open-type,size,plain
等以及原生的方法如getuserinfo,getphonenumber
等 所以我們封裝button的時候要把這些能力進行相應的處理。
可以分為兩類:一種是指令,一種是事件,
指令 可以從properties裡將微信原生的button的所有指令宣告,然後直接賦值到內部封裝的button裡。
事件 我們可以根據事件的捕獲冒泡以及open-type的唯一性,讓其在觸發後根據open-type選擇事件直接冒泡到外層即可,但是需要將獲取的value也傳遞出去
例如:
properties: {
openType: {
type: String
},
size: {
type: String,
value: 'default'
},
plain: {
type: Boolean,
value: false
}
},
data: {
// 事件的map表
openTypeToBindEvent: {
'getUserInfo': 'getuserinfo',
'getphonenumber' : 'getphonenumber',
'launchApp': 'error',
'contact': 'contact'
}
},
methods: {
// 繫結未冒泡的事件手動觸發到上一層
_returnEventData(e) {
this.triggerEvent(`${this.data.openTypeToBindEvent[this.properties.openType]}`);
}
}
然後直接賦值到button裡,注意,這裡需要判斷一下值是否存在
<button class="btn-class"
bind:getuserinfo="{{openType === 'getUserInfo' ? '_returnEventData' : '' }}"
bind:getphonenumber="{{openType === 'getphonenumber' ? '_returnEventData' : '' }}"
bind:error="{{openType === 'launchApp' ? '_returnEventData' : '' }}"
bind:contact="{{openType === 'contact' ? '_returnEventData' : '' }}"
open-type="{{openType || ''}}"
size="{{size || ''}}"
plain="{{plain || ''}}"
>
<slot></slot>
</button>
2.3 material 的 漣漪實現
2.3.1 重置/增加button的一些樣式
button{
display: flex;
justify-content: center;
align-items: center;
position: relative;
box-sizing: border-box;
overflow: hidden;
line-height: 66px;
min-width: 88px;
height: 36px;
padding: 0 16px;
margin: 0;
font-size: 32rpx;
border-radius: 2px;
transition: all .2s cubic-bezier(.4,0,.2,1); // 增加過渡效果
}
2.3.2 增加漣漪
注意:微信小程式不支援js操縱dom元素 即沒有appenChild一類的方法來新增元素,所以我們只能宣告一個元素來進行漣漪的展示
<button class="btn-class"
capture-bind:tap="{{ripple ? '_addRipple' : ''}}"
capture-bind:longpress="{{ripple ? '_longPress' : ''}}"
capture-bind:touchend="{{ripple ? '_touchend' : ''}}"
bind:getuserinfo="{{openType === 'getUserInfo' ? '_returnEventData' : '' }}"
bind:getphonenumber="{{openType === 'getphonenumber' ? '_returnEventData' : '' }}"
bind:error="{{openType === 'launchApp' ? '_returnEventData' : '' }}"
bind:contact="{{openType === 'contact' ? '_returnEventData' : '' }}"
open-type="{{openType || ''}}"
size="{{size || ''}}"
plain="{{plain || ''}}"
>
<slot></slot>
<!-- 漣漪view -->
<view class="ripple">
</view>
</button>
漣漪的動畫css樣式
/* 漣漪的初始樣式 */
.ripple {
border-radius: 100%;
background-color: #000000;
left: 20px;
top: 20px;
opacity: 0.3;
transform: scale(0.3);
width: 10px;
height: 10px;
position: absolute;
}
/* 漣漪的點選擴散動畫 */
.ripple-animation {
animation: ripple 0.6s ease-out;
animation-fill-mode: forwards;
}
/* 漣漪的長按擴散動畫 */
.ripple-animation-hold{
animation: ripple-hold 1s ease-out;
animation-fill-mode: forwards;
}
@keyframes ripple {
from {
transform: scale(0.1);
opacity: 0.3;
}
to {
transform: scale(2.5);
opacity: 0;
}
}
@-webkit-keyframes ripple-hold {
from {
transform: scale(0.1);
opacity: 0.3;
}
to {
transform: scale(2.5);
opacity: 0.3;
}
}
漣漪的播放控制
兩種播放控制
點選 - ripple-animation 動畫
長按 - ripple-animation-hold 動畫
然後我們在點選的時候播放 ripple-animation 長按播放ripple-animation-hold 即可
那麼如何判斷這個view的位置以及大小呢,因為每個人點選button的位置不一樣,button的大小不一樣,如果view過小就可能覆蓋不到整個button,過大就太耗費效能。
所以,大小我們定為button長邊的兩倍
然後點選button的哪個位置,ripple就在哪個位置播放,因此必須設定ripple的position為absolute,我們就可以通過控制其left,以及top來控制ripple的位置。
問題就是ripple的位置,大小該如何設定
下面,我們在html裡宣告view的位置大小屬性。
<view style="width:{{width}}px;height:{{width}}px;left:{{left}}px;top:{{top}}px"
class="ripple-class {{click?'ripple-animation':hold?'ripple-animation-hold':''}}">
</view>
新增點選事件
methods: {
// 短按(長按同理)
_tap(e) {
// 獲取button的大小,位置
this._queryMultipleNodes('btn-class').then(res => {
// 關於button的屬性 // 關於button位置的屬性
const button = res[0], viewPort = res[1];
const boxWidth = parseInt(button.width); // button的寬度
const boxHeight = parseInt(button.height); // button的長度
const rippleWidth = boxWidth > boxHeight ? boxWidth : boxHeight;
// 我們需要計算的是ripple相對於button左上角的距離
// 注意 e.detail.y(點選位置)是相當於文件的高度不是當前視窗的高度,因此需要減去滾動的距離以及button的top
const rippleX = (e.detail.x - (button.left + viewPort.scrollLeft)) - rippleWidth / 2;
const rippleY = (e.detail.y - (button.top + viewPort.scrollTop)) - rippleWidth / 2;
this.setData({
click:true,
width: rippleWidth,
left: rippleX ,
top: rippleY
});
});
},
// 該方法返回選擇元素的大小,位置
_queryMultipleNodes: function (e) {
return new Promise((resolve, reject) => {
const query = this.createSelectorQuery();
query.select(e).boundingClientRect();
query.selectViewport().scrollOffset();
query.exec(function (res) {
resolve(res);
});
})
}
}
實現效果
但是這樣出現了一個bug,即點選多次,不會出現多個漣漪效果,而是會導致一個view的動畫結束然後重複播放
解決辦法:
採用wx-for來迴圈產出ripple,這樣可以實現多個漣漪的效果,那麼我們可以定義一個ripple陣列,每次點選的時候不斷往該陣列push進新的ripple然後由瀏覽器渲染就好了,我們還需要分配 wx-key來避免渲染陣列的效能問題。
我們需要為每個rippleItem分配 短按 播放動畫的標識 startAnimate 以及 長按播放動畫標識 holdAnimate
data: {
rippleList: [],
rippleId: 0
},
methods:{
_tap(e) {
if (!this.properties.disabled) {
this._queryMultipleNodes('.' + this.data.btnClass).then(res => {
const button = res[0], viewPort = res[1];
const boxWidth = parseInt(button.width); // button的寬度
const boxHeight = parseInt(button.height); // button的長度
const rippleWidth = boxWidth > boxHeight ? boxWidth : boxHeight;
const rippleX = (e.detail.x - (button.left + viewPort.scrollLeft)) - rippleWidth / 2;
const rippleY = (e.detail.y - (button.top + viewPort.scrollTop)) - rippleWidth / 2;
this.data.rippleList.push({
rippleId: `ripple-${this.data.rippleId++}`,
width: rippleWidth ,
left: rippleX ,
top: rippleY ,
startAnimate: true,
holdAnimate: holdAnimate || false
});
this.setData({
rippleList: this.data.rippleList
});
});
}
}
}
實現效果
到這裡我們又發現了一個問題 就是 ripple在產出的時候 並未刪除,所以它會一直增加增加增加
就像這樣
所以我們可以將播放完畢的動畫從rippleList刪除,這樣可以進行一定的優化,利用小程式的animationend事件可以觸發每個ripple的動畫播放完畢事件,然後取得id並從rippleList找到這個id刪除即可。可以找到這個id的item實在是太耗費效能了。
於是我們想到,每個動畫播放完畢一定是這個list的最前面的一個item,也就是每次觸發動畫播放完畢事件我們只需要刪除list中的第一個就好了,但是小程式需要每次都執行setData方法來對陣列進行更新,這會導致我們按一百個ripple就執行一百次setData,大量耗費效能,因此需要一個防抖來控制setData的執行,
<view wx:for="{{rippleList}}"
wx:key="rippleId"
id="{{item.rippleId}}"
style="width:{{item.width}}px;height:{{item.height}}px;left:{{item.left}}px;top:{{item.top}}px"
class="ripple-class {{item.startAnimate ? item.holdAnimate ? 'ripple-animation-slow-hold' :'ripple-animation-slow' : ''}}"
bind:animationend="{{item.holdAnimate ? null : '_scbuttonrippleAnimationend'}}">
</view>
_buttonrippleAnimationend() {
// 防抖
this.data.rippleList.shift();
if (this.data.timer) {
clearTimeout(this.data.timer);
this.data.timer = setTimeout(deleteRipple.bind(this), 300);
} else {
this.data.timer = setTimeout(deleteRipple.bind(this), 300);
}
function deleteRipple() {
this.setData({
rippleList: this.data.rippleList
});
clearTimeout(this.data.timer);
this.data.timer = null;
}
}