實現目標
1. 商品卡片點擊右上角的時候,彈出遮罩層以及對話框
2. 當點擊遮罩層/滑動窗口時的時候,對話框隱藏
實現目標.jpg
實現效果
實現效果.jpg
具體實現思路和需要注意的點
1. 氣泡對話框的實現主要分為三角形元素和矩形元素,通過y軸偏移拼接在一起形成對話框
2. 由于氣泡對話框可能為上方彈出,或者下方彈出,因此三角形會存在上下兩個。
對話框原理.png
3.左右兩側的對話框,由于右側靠近屏幕邊緣,因此矩形的X軸偏移量不一樣。
右列.png
左列.jpg
// 三角形元素樣式
.triangle {
width: 0rpx;
height: 0rpx;
border: 25rpx solid transparent;
border-bottom: 25rpx solid #fff;
}
// 對話框矩形樣式
.dialgo-div {
height: 350rpx;
width: 200rpx;
background: #fff;
transform: translate(var(--translateX),-2rpx); // 左右兩列矩形的X軸偏移量不同,因此需要通過計算動態傳入
border-radius: 10rpx;
overflow: hidden;
}
.bottom-triangle {
width: 0rpx;
height: 0rpx;
border: 25rpx solid transparent;
border-top: 25rpx solid #fff;
transform: translate(-15rpx, -5rpx);//偏移Y軸重合對話框,x偏移量,使三角形對準 "x" 圖標
}
- 通過點擊事件獲取點擊右上角x后獲得event.detail內的x,以及y變量是相對頁面元素整體的偏移量,并不是相對于屏幕的偏移量(頁面元素高度可能會大于屏幕高度,因此獲得的y可能會大于屏幕高度)。由于我事先彈出對話框時基于fixed布局,top和left變量時基于屏幕坐標,因此需要通過計算得出基于屏幕的top偏移量(x軸不溢出屏幕,因此可以直接應用x軸偏移量)。
計算點擊時相對于屏幕的偏移量,可以通過監聽頁面滾動方法onPageScroll獲取當前頁面滾動頂部部的y軸量,使用點擊處的y軸偏移量 - 當前頁面頂部的y軸偏移量就可以得出當前點擊元素相對于屏幕的y軸偏移量,在Page下填入
// debounce為防抖函數
onPageScroll:function(event) {
const that = this;
debounce(function(){
that.setData({
scrollTop: Math.ceil(event.scrollTop)
});
}, 100)();
},
debounce為防抖函數,由于onPageScroll會被頻繁觸發,為了避免拖動時頻繁觸發setData函數更新造成頁面卡頓,而我們實際只需要拖動結束后獲取這個值就行,所以引入了防抖函數,具體實現為
/**
* 防抖函數
* @param {*} fun 需要進行防抖的函數
*/
export function debounce(fun, delay = 500, immediate= false) {
let timer = null; // 保存定時器
return function(args) {
let that = this;
let _args = args;
if(timer) clearTimeout(timer);
if(immediate) {
if(!timer) fun.apply(that,_args); // 定時器為空表示可以執行
timer = setTimeout(function() {
timer = null;// 到時間后設置定時器為空
},delay);
}
else {
// 如非立即執行,則重設定時器
timer = setTimeout(function() {
fun.call(that,_args);
},delay);
}
}
}
在Page頁面獲取到scrollTop后通過prop傳入到組件中在點擊事件中進行計算。
計算對話框從上彈出還是下彈出, 通過第四點,我們已經計算出了當前的對話框需要偏移的left和top值。如果top值加上對話框的高度大于整個屏幕的高度時,表示對話框溢出屏幕,此時就需要在上方彈出。
獲取屏幕信息的接口是wx.getSystemInfoSync()底部的導航欄如果為系統原生,屬于是最頂層元素,手寫的遮罩層無法遮蓋住導航欄,因此需要引入wx.hideTabBar() 接口,當點擊時隱藏底部導航欄,遮罩層消失時,重新顯示,接口為wx.showTabBar。(有遮罩層置頂的處理方法歡迎提出)
當頁面進行滾動時,對話框及遮罩層也隱藏,因此在Page的滾動事件中再引入,當滾動時設置isScrolling為true,傳入組件,在組件中監聽obeserver,當變量改變為true的時候隱藏遮罩層以及對話框。
遮罩層使用簡單的fixed布局,設置z-index來進行遮蓋。點擊對話框選項后,顯示減少推薦,使用簡單absolute布局。
減少推薦.jpg
組件完整代碼
頁面的onPageScroll函數
onPageScroll:function(event) {
// 滾動時隱藏對話框和遮罩層
if(!this.data.isScrolling) {
this.setData({
isSrolling: true
});
}
const that = this;
debounce(function(){
that.setData({
scrollTop: Math.ceil(event.scrollTop),
isSrolling: false
});
}, 100)();
}
組件wxml
<!--pages/index/components/suggestCard/suggestCard.wxml-->
<view style="top: {{top}}; left: {{left}};height: {{realHeght}};" class="{{ index % 2 ? 'odd-card' : 'even-card' }}">
<van-image src="{{ itemData.imageUrl }}" fit="widthFix" width="325rpx">
</van-image>
<view class="info-panel">
<view class="info-title">
<van-tag class="new-tag" custom-class="new-tag" color="#95d475">上新</van-tag>
時尚百搭雙肩奶爸包 多功能兩用媽咪包防水休閑學生包 可定制
</view>
<view class="price-tag">
<view class="price-signal-container">
<text class="price-signal">¥</text>
<text>999.86</text>
</view>
</view>
</view>
<view class="close-icon-container">
<view bindtap="closeTap" class="close-icon-inside-container">
<van-icon name="cross" color="#C0C4CC" />
<!--van-transition name="fade" show="{{show}}" -->
<view catchtap="wrapperTap" class="{{ show ? 'popover-wrapper-active' : 'popover-wrapper'}}">
</view>
<view style="top: {{dialogTop}}; left: {{dialogleft}};--translateX: {{translateX}}" class="{{ show ? 'buble-dialog-open' :'buble-dialog' }}">
<view class="{{dialogDirection == 'bottom' ? 'triangle' : 'hidden-triangle'}}">
</view>
<view class="dialgo-div">
<view catchtap="menueTap" wx:for="{{menuItems}}" wx:key="unique" wx:for-item="item" class="dialog-menu-item">
<view>
{{ item.text }}
</view>
</view>
</view>
<view class="{{dialogDirection == 'up' ? 'bottom-triangle':'hidden-triangle'}}">
</view>
</view>
<!--/van-transition-->
</view>
</view>
<view class="{{ isCanceled ? 'cancel-cover ' : 'cancel-cover-deactive'}}">
<view class="cancel-text-container">
<text>您的反饋已收到</text>
<view>會減少此類內容的推薦</view>
</view>
</view>
</view>
組件wxss
.odd-card {
width: 350rpx;
height: 0rpx;
background: #fff;
border: 1px solid #E4E7ED;
border-radius: 4px;
position: absolute;
left: 375rpx;
top: 0rpx;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: 2s all ease-in-out;
overflow: hidden;
}
.even-card {
width: 350rpx;
height: 0rpx;
background: #fff;
border: 1px solid #E4E7ED;
border-radius: 4px;
position: absolute;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: 2s all ease-in-out;
overflow: hidden;
}
.info-panel {
font-size: 28rpx;
display: flex;
flex-direction: column;
height: 100rpx;
}
.info-title {
padding-left: 2.5%;
padding-right: 2.5%;
width: 95%;
font-size: 25rpx;
overflow: hidden;
text-overflow:clip;
display: -webkit-box;
-webkit-line-clamp: 2; /*限制文本行數*/
-webkit-box-orient: vertical;
word-break: break-all;
}
.new-tag {
font-size: 20rpx !important;
}
.price-tag {
height: 0rpx;
flex-grow: 1;
font-size: 25rpx;
display: flex;
align-items: center;
}
.price-signal-container {
width: 50%;
color: red;
text-align: center;
}
.price-signal {
font-size: 20rpx;
}
.close-icon-container {
position: absolute;
top: 15rpx;
left: 310rpx;
font-size: 15rpx;
text-align: center;
}
.close-icon-inside-container {
height: 25rpx;
width: 25rpx;
}
.popover-wrapper {
position: fixed;
width: 750rpx;
height: 100vh;
top: 0px;
left: 0px;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
display: none;
opacity: 0;
animation-name: hide;
animation-duration: .3s;
}
.popover-wrapper-active {
position: fixed;
width: 750rpx;
height: 100vh;
top: 0px;
left: 0px;
opacity: 1;
animation-name: show;
animation-duration: .3s;
animation-fill-mode: forwards;
z-index: 1999;
background: rgba(0, 0, 0, 0.5);
}
.cancel-cover {
width: 100%;
height: 100%;
position: absolute;
top: 0px;
left: 0px;
background-color: rgba(256, 256, 256, 0.8);
display: flex;
justify-content: center;
align-items: center;
font-size: 25rpx;
}
.cancel-cover-deactive {
display: none;
}
.cancel-text-container {
width: 80%;
}
@keyframes show {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.buble-dialog {
display: none;
animation: bubleShow .3s ease-in-out;
animation-fill-mode: forwards;
animation-direction: reverse;
}
.buble-dialog-open {
--translateX: '0rpx';
position: fixed;
z-index: 2000;
display: block;
animation: bubleShow .3s ease-in-out;
animation-fill-mode: forwards;
}
@keyframes bubleShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dialgo-div {
height: 350rpx;
width: 200rpx;
background: #fff;
transform: translate(var(--translateX),-2rpx);
border-radius: 10rpx;
overflow: hidden;
}
.triangle {
width: 0rpx;
height: 0rpx;
border: 25rpx solid transparent;
border-bottom: 25rpx solid #fff;
transform: translateX(-15rpx);
}
.bottom-triangle {
width: 0rpx;
height: 0rpx;
border: 25rpx solid transparent;
border-top: 25rpx solid #fff;
transform: translate(-15rpx, -5rpx);
}
.hidden-triangle {
display: none;
}
.dialog-menu-item {
color: #323233;
height: 69rpx;
font-size: 20rpx;
width: 100%;
border-bottom: 1px solid #ebedf0;
display: flex;
justify-content: center;
align-items: center;
transition: .2s background ease-in-out;
}
.dialog-menu-item:hover {
background: #e1f3d8;
transition: .2s background ease-in-out;
}
.dialog-menu-item:last-child {
border-bottom: none;
}
組件js
Component({
/**
* 組件的屬性列表
*/
lifetimes: {
attached() {
let val = '-80rpx';
if(this.properties.index % 2) {
val = '-148rpx'
}
this.setData({
translateX: val
})
}
},
properties: {
index: {
type: Number,
value: 0,
/* observer: function(newVal, oldVal) {
let top = `${(newVal / 2 ) * 460}rpx`;
let left = `25rpx`;
if(newVal % 2) {
top = `${(Math.floor(newVal / 2))* 410}rpx`;
if(newVal == 1) {
// console.log(top);
}
left = `375rpx`;
}
this.setData({
top: top,
left: left
});
}*/
observer: function (newValue, oldValue) {
if ((newValue % 2)) {
this.setData({
left: "375rpx"
})
}
}
},
itemData: {
type: Object,
value: {
imageUrl: '',
top: '0rpx',
left: '0rpx',
realHeght: '450rpx'
},
observer: function (newValue, oldValue) {
this.setData({
top: newValue.top,
left: newValue.left,
realHeght: newValue.realHeight
})
}
},
scrollTop: {
type: Number,
value: 0,
observer: function (newValue) {
// console.log(newValue);
}
},
isSrolling: {
type: Boolean,
value: false,
observer: function (newValue) {
if (newValue && this.data.show) {
wx.showTabBar({
animation: true,
})
this.setData({
show: false
})
}
}
}
},
/**
* 組件的初始數據
*/
data: {
left: `25rpx`, // 組件left值
top: '0rpx', //組件top值
realHeght: `450rpx`, // 組件真實高度
show: false, // 用于控制點擊組件右上角x后,遮罩層和對話框是否顯示
dialogTop: '300rpx', // 對話框的top坐標
dialogLeft: '350rpx', // 對話框的left坐標
dialogDirection: 'bottom', // 對話框顯示的位置
translateX: '-80rpx', // 對話框的x偏移值,右邊列顯示對話框時需要向左偏移更多(-148rpx)
isCanceled: false,
menuItems: [
{
text: '不感興趣'
},
{
text: '品類不喜歡'
},
{
text: '已經買了'
},
{
text: '圖片引起不適'
},
{
text: '涉及隱私'
}
]
},
/**
* 組件的方法列表
*/
methods: {
// 組件右上角 x ,關閉點擊事件
closeTap: function (event) {
// 重復點擊也關閉遮罩層
if(this.data.show) {
wx.showTabBar({
animation: true,
});
this.setData({
show: false,
});
return ;
}
let result = wx.getSystemInfoSync(); // 獲取系統信息
let windowHeight = Math.floor(result.windowHeight); // 獲取系統窗戶高度
let windowWidth = Math.floor(result.windowWidth); // 獲取系統窗戶寬度
let x = Math.floor(event.detail.x); // 獲取點擊的x坐標
let y = Math.floor(event.detail.y); // 獲取點擊的y坐標
// 計算當前元素(y坐標 - scrollTop) 獲取當前元素在屏幕處的Y坐標,再加上 彈出對話框的高度
// 如果所得數值大于當前屏幕的高度(或再減去81(底部導航欄高度)),證明數值溢出,則顯示為頂部對話框,否則則顯示為底部對話框。
let dialogDirection = (y - this.properties.scrollTop + windowWidth / 750 * 400) >= windowHeight - 81 ? 'up' : 'bottom';
//console.log({windowHeight})
//console.log({y:y - this.properties.scrollTop})
//console.log({event})
wx.hideTabBar(); // 隱藏底部
// 根據頂部對話框或底部對話框計算top的值
// 底部對話框的top值為當前窗口的絕對y坐標,計算方式為(點擊坐標y值 - 當前頁面的scrollTop)
// 頂部對話框top值為底部對話框top值的基礎上 - 對話框的高度;
let tempTop = dialogDirection == 'up' ? `calc(${y - this.properties.scrollTop}px - 400rpx)` : `${y - this.properties.scrollTop}px`;
this.setData({
dialogTop: tempTop,
dialogLeft: `${x}px`,
show: true,
dialogDirection: dialogDirection
})
},
// 遮罩層點擊事件
wrapperTap: function (event) {
this.setData({
show: false
});
wx.showTabBar({
animation: true,
})
},
// 菜單點擊事件
menueTap: function() {
/*this.triggerEvent('deleteItem',this.properties.index);
this.setData({
show: false
})*/
this.setData({
show: false,
isCanceled: true
})
}
}
})
可優化
遮罩層使用Vant組件自帶的遮罩層,提供更優化的動畫。完善點擊取消后的動畫。歡迎交流學習。創作不易,點個贊吧。