实现目标
1. 商品卡片点击右上角的时候,弹出遮罩层以及对话框
2. 当点击遮罩层/滑动窗口时的时候,对话框隐藏
实现效果
具体实现思路和需要注意的点
1. 气泡对话框的实现主要分为三角形元素和矩形元素,通过y轴偏移拼接在一起形成对话框
2. 由于气泡对话框可能为上方弹出,或者下方弹出,因此三角形会存在上下两个。
3.左右两侧的对话框,由于右侧靠近屏幕边缘,因此矩形的X轴偏移量不一样。
// 三角形元素样式
.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布局。
组件完整代码
页面的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组件自带的遮罩层,提供更优化的动画。完善点击取消后的动画。欢迎交流学习。创作不易,点个赞吧。