移动端的程序由于屏幕大小的局限性,一些手指滑动交互的布局在移动开发里面就比较实用了,但是官方提供的组件却往往不能满足项目需求,那掌握扎实的自定义组件的知识就至关重要了,别到时UI妹纸设计一个版本UI你就说实现不了,再改一版你又说实现不了,人家该嫌弃你了!好了废话就说这么多,先上效果图~
怎么样?是你们想要的效果吗?我为什么贴两个图,因为它不仅仅是个左滑删除组件,它的主内容区域和副内容区域(右侧隐藏区域下面称作副内容区域)的布局都是可以自定义的!相对来说自由度比一般的左滑删除组件自由度还是高一些的。好了先来说一下它的使用方法吧!
demo地址: https://github.com/954469291/leftSlideViewTest.git
目录
(1) removeEffects 类型:String; 子组件移除特效
(2)radius 类型:String,组件圆角,也就是css中border-radius的值
(2)当组件联动属性isLinked设为false 的时候删除特效异常。
一、组件使用
组件有两个,leftSlideGroup和leftSlideItem,其中leftSlideGroup是leftSlideItem的父组件,这两个组件需要一起使用!先将项目中的leftSlideGroup和leftSlideItem复制到你的项目components文件夹下!
然后在你需要使用的界面引入这两个组件
index.json
{
"navigationBarTitleText": "自定义左滑删除组件",
"usingComponents": {
"leftSlideGroup":"../../components/leftSlideGroup/leftSlideGroup",
"leftSlideItem":"../../components/leftSlideItem/leftSlideItem"
}
}
做一个简单的示例:
<!--index.wxml-->
<view class="contentView">
<leftSlideGroup id="leftSlideGroup">
<block wx:for="{{10}}" wx:key="key">
<leftSlideItem id="leftSlideItem">
<view class="leftView" slot="leftView">
主内容区域
</view>
<view class="rightView" slot="rightView">
右边的布局
</view>
</leftSlideItem>
</block>
</leftSlideGroup>
</view>
/*index.wxss*/
page {
background: #EFF2F5;
}
.contentView {
padding-bottom: 50rpx;
}
#leftSlideItem{
width: 92%;
height: 200rpx;
margin: 10rpx 4% 0 4%;
}
#leftSlideItem .leftView{
width: 100%;
height: 100%;
background: rgb(221, 64, 64);
display: flex;
align-items: center;
justify-content: center;
}
#leftSlideItem .rightView{
width: 300rpx;
height: 100%;
background: rgb(28, 223, 54);
display: flex;
align-items: center;
justify-content: center;
}
代码很简单,leftSlideGroup组件下面放了10个leftSlideItem组件,leftSlideItem里面有两个内容区域view,分别是slot="leftView"和slot="rightView"表示主内容区域和右侧隐藏区域。组件内容随便加了几个文字,这里说一下注意事项:
1.leftSlideItem一定要放在leftSlideGroup组件里面
2.leftSlideItem组件下面只允许放两个view,slot="leftView"的是主内容view,slot="rightView"的是副内容区域的view,两个view的内容可以根据自己需求随意布局
3.css布局的时候一定要给leftSlideItem组件一个高度不然会显示不出来
4.for循环中的key值一定设置唯一的标识符,否则删除动画执行就会有问题,假如你的列表数据有类似id的属性,你就设置id为key值,否则你得自己造一个key,实在造不出来那就最好不要用删除动画了。具体不知道怎么设置key的可以去官网看看。
好了看下代码的效果:
这是最基本的效果,那有的同学就发现了这没有删除功能啊!是的,确实没有,因为我确实没有放删除按钮在上面,这需要开发者自己定义,无论你想放在主内容区域,还是右边的隐藏区域都随你,你自己在leftView和rightView的两个区域加上删除按钮添加删除事件就好了!
二、组件属性
1.leftSlideGroup组件的属性
(1) removeEffects 类型:String; 子组件移除特效
这是一个子组件移除特效,也就是组件删除特效,默认是together(同时移动),可选值sequence(依次触发)\together(同时触发)
在使用这个组件的时候要配合组件方法deleteChild(indexItem, callBack)去使用,不然不会执行移除动画。deleteChild有两个参数,第一个indexItem参数是即将要移除的条目下标,第二个参数是动画执行完成后的回调函数,可以在回调函数中执行数据删除更新。代码示例:
this.leftSlideGroup = this.selectComponent("#leftSlideGroup") //获取leftSlideGroup组件实例
//执行子组件移出动画
this.leftSlideGroup.deleteChild(index, function () {
//删除指定元素
list.splice(index, 1)
that.setData({
list:list
})
})
接下来我们看看sequence和together两个值的区别
(2)isLinked 类型:Boolean, 是否联动
这个属性可以设置子组件动作是否联动,就是子组件间的动作是否会互相产生影响。true就是互相影响,false就是互不影响。但这里列表的wx:key一定要设置唯一的标识符,不然这个属性就不起作用。接下来看下示例
区别还是很明显,相信大家也一眼就明白了这个属性的意思,就不多做解释了。
2.leftSlideItem组件的属性
(2)radius 类型:String,组件圆角,也就是css中border-radius的值
这个属性很好理解,就是增加组件圆角属性,和css中的border-radius一样,填你需要的border-radius的值就好,这里就不演示了。
好了,组件的使用方法就说完了,使用起来应该也不算复杂。如果只想知道怎么用的,读到这里就结束了。后面的内容都是讲的我写这个组件的实现思路和一步一步的演变过程,篇幅会比较长,感兴趣的可以看一下。
三、组件实现思路及演变过程
想要做好自定义组件,最重要的还是想法及大致实现思路,当你想要做一个自定义组件的时候,你起码得要知道你为什么做这个组件,做这个组件想要实现什么功能以及要达到一个什么效果,当然光有想法还不行,你想的时候脑海里得有大致的实现思路,你不能天马行空的去想,到头来技术上实现不了也是白搭,所以要做好一个自定义组件需要想法和自身技术底蕴相结合。当然一般来说一个复杂的自定义组件都不是一步到位的就能完成的,都是由简到繁演变过来的。好了言归正传,这里说一说我这个组件的思路及演变过程,在读的过程中你们也许会发现很问题,不过没关系,编程就是不断发现问题解决问题的过程,中途出现的一些明显问题我在后面都会一步一步解决。
1.实现基本的左滑组件
假如在某天我们在项目中需要写一个左滑删除功能,不要害怕,不要盲目去百度,先想一想这个东西以自己的技术能不能实现,首先我们思考一下一个左滑组件有哪些东西组成,很简单,主要就是两个部分,一个主内容区域,和一个需要滑动才显示的区域(后面我们称作副内容区域),并且组件初始状是主内容区域覆盖在副内容区域上面或者副内容区域在主内容区域右边隐藏。如下图:
当然肯定不止这两方式,不过我觉着这两个算是比较常见的思路了,无论哪种方式都是通过滑动主内容区域将隐藏部分显示出来。这里我选择第二种方式去实现,将副内容区域放在主内容右边。先在index页面中写一个简单的代码。
<!--index.wxml-->
<view class="contentView">
<view class="leftSlideView">
<view class="leftView" style="left: 0;" >主内容余区域</view>
<view class="rightView" style="left: 100%;" >副内容区域</view>
</view>
</view>
/*index.wxss*/
.leftSlideView{
width: 92%;
height: 200rpx;
margin: 10rpx 4% 0 4%;
display: flex;
position: relative;
overflow: hidden;
}
.leftView{
width: 100%;
height: 100%;
background: rgb(48, 187, 241);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
}
.rightView{
width: 40%;
height: 100%;
background: rgb(241, 34, 34);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
}
上面的代码很简单,就是在leftSlideView里面放了两个view,分别作为主内容区域和副内容区域,我这里通过绝对定位将rightView放在了leftView右边,并将leftView宽度设为100%以横向占满父组件。这里我在组件标签中加上css样式left写在在wxml中是为了一会通过改变left的值可以使leftView和rightView两个区域随手指移动,当然现在还是无法移动的,我们还得给加上手指滑动事件,与手指滑动相关的事件有下面几个
在触摸滑动的事件有如上四种,那么我们就得想象如何利用上面的事件使组件滑动动起来,我们首先要知道 这几个事件触发后会给我们提供一个touches数组,这个东西是当前停留在屏幕中的触摸点信息的数组,也就是你几根手指在屏幕上滑动他就返回多少个触摸点的信息,我们再来看看touches数组中包含的Touch对象有哪些信息吧
我们发现touch中包含了触摸点在文档的坐标和页面可显示区域的的坐标,有了这些信息就好办,当我们手指滑动的时候只要计算当前手指X轴的坐标与刚按下去的时候手指在X轴坐标的距离我们就知道了组件横向滑动的距离,那有的同学就说touches不是数组吗?我们取哪一个的坐标信息呢,这里呢我说明一下当touches存才多个元素时,说明此时是多个手指触摸,这通常是用来做多点触控的,我们这个组件就没多大必要去做多点触控的,只需要根据第一个触摸到屏幕的触摸点来进行交互就行了,当然有兴趣做多点触控的可以自己去尝试。所以,这里我们只要在刚开始触摸的时候在touchstart事件里记录下最开始的X轴坐标,然后在触摸过程中在touchmove事件中实时去计算与起始点的横向距离并更新界面就好了,根据这个思路我们改一下代码。
<!--index.wxml-->
<view class="contentView">
<view class="leftSlideView" bindtouchmove="touchmove" bindtouchstart="touchstart" >
<view class="leftView" style="left: {{offsetX}}px;" >主内容余区域</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px);" >隐藏内容余区域</view>
</view>
</view>
//index.js
Page({
data: {
startX:0,//X轴起始坐标
offsetX:0,//横向的偏移量
},
/**
* 开始触摸
*/
touchstart(e){
this.setData({
startX: e.touches[0].pageX //记录手指X轴的其实坐标
})
},
/**
* 正在触摸
*/
touchmove(e){
var currX = e.touches[0].pageX //手指当前的X轴坐标
var offsetX = currX - this.data.startX //计算手指在X轴上移动的距离
this.setData({
offsetX:offsetX //更新偏移量
})
},
onLoad() {
},
})
代码并不复杂就是在刚开始触摸的时候记录下手指起始的X轴坐标,在手指移动的时候计算当前手指X坐标与起始坐标的距离就是组件内容横向偏移的距离 。我们看下效果:
怎么样让组件随着手指移动并不难吧!但是这才刚开始,接下来麻烦就来了,我们先来看看目前最明显的问题。
问题很明显,1.手指画滑动的时候组件移动没有上限导致出现了空白区域就不美观;2当手指移动后抬起再触摸移动,组件会闪一下改变位置再移动。
现在问题是发现了,但是怎么解决呢,首先我们要分析问题产生的原因,第一个问题产生的原因很简单,因为我们并没有设置组件移动的上限,所以手指移动多长距离,组件就跟着移动多长距离,那么我们就得设置一个组件偏移量offsetX的上限,即设置它的最大值和最小值。那么我们想一下offsetX的最大值和最小值应该怎么计算,我们看看我们之前在wxml中给leftView主内容区域设置的left的值就等于offsetX,当我们左滑的时候,手指在X轴的坐标实际上的越来越小的,所以在左滑的时候组件的偏移量offsetX一定是负数,这个大家一定要理解,屏幕的坐标系如下图,X轴向右是正方向,Y轴向下是正方向。
我们的偏移量offsetX = 当前手指X轴坐标 - 手指刚触摸屏幕时的X轴坐标,所以我们手指向左左滑的时候offsetX一定是负数,而我们向左移动的最小偏移量应该是rightView的宽度的负数才会保证右边不会出现空白区域,而向右的的最大偏移量是0才会保证左边不会出现白边,这个大家仔细想一想为什么向右最大偏移量是0,实际上当前偏移量是0的时候就是正常的未移动的状态,你想如果向右的偏移量可以大于0,那左边就会出现空白区域了,这个一定要理解。接下来知道了offsetX上限从哪来,我们就要开始计算了。
首先offsetX的最小值就是rightView的宽度的相反数,那rightView的宽度怎么来呢,如果简单粗暴一点你就可以直接将css中rightView的宽度设置成一个固定数值的宽度,这样就简单了,但是这样缺陷也很明显,因为有的左滑删除他不一定就只有一个按钮,有可能一个两个,有的宽有的窄,所以直接设置固定值的做法就不太可取,那么我就就要用到小程序提供的api获取rightView的宽度。接下来上代码:
//index.js
/**
* 测量节点信息
*/
measure() {
var that = this
this.createSelectorQuery().select('.rightView').boundingClientRect(function (rect) {
that.setData({
rightWidth: rect.width
})
}).exec()
},
onLoad() {
this.measure()
},
我们可一个通过SelectorQuery.select来获取rightView对象的实例,从而获取rightView的宽度,不明白的可以去小程序官网翻一下这个api。这样我们的偏移量上线就得到了,我们再来改造一下touchmove的内容给offsetX限制上线:
data: {
startX: 0, //X轴起始坐标
offsetX: 0, //横向的偏移量
rightWidth:0//右边隐藏区域的宽度
},
/**
* 正在触摸
*/
touchmove(e) {
var currX = e.touches[0].pageX //手指当前的X轴坐标
var offsetX = currX - this.data.startX //计算手指在X轴上移动的距离
var rightWidth = this.data.rightWidth
if(offsetX < -rightWidth) offsetX = -rightWidth //当手指向左移动的的距离超过rightWidth时那么给offsetX赋值-rightWidth
if(offsetX > 0) offsetX = 0 //当右边的区域已经完成隐藏的时候就不允许向右滑动了
this.setData({
offsetX: offsetX //更新偏移量
})
},
代码比较简单就在data里面加了rightWidth,并且在touchmove方法里面加了两个if判断来限制offsetX的大小,来看看效果
可还行?这样刚刚的第一个问题也就解决了!
接下来是第二个问题,手指触摸后抬起再触摸会闪一下的问题,首先我们要搞清楚,为什么手指再次触摸的时候会闪一下改变位置后再移动,假设我们的手指第一次触摸到屏幕的时候,向左移动了20px,那么这个时候offsetX就等于-20,这个时候我们抬起了手指,再次触摸到屏幕向左滑动了1px,这个时候offsetX就会被刷新变成-1,但是我们上一次抬起的时候offsetX = -20 ,我们第二次触摸到屏幕的时候offsetX又立马变成了-1,这个时候组件就会从偏移量-20px的位置瞬间变到偏移量-1的位置。看起来就像瞬移了一下。那么问题产生了原因知道了,怎么解决呢?也很好解决,你们想想,既然我们的目的是要后面一次的滑动参照前一次的偏移量继续移动,那只要我们在每次手指抬起的时候记录下当前offsetX的值,然后在第二次触摸移动的时候加上旧的偏移量就好了。继续改造代码:
首先我们在wxml给组件给触摸事件结束以及在被打断的时候添加监听方法bindtouchend="touchend" 和bindtouchcancel="touchend",这里我为什么在两个监听事件都是调用同一个方法touchend,是因为无论是滑动事件正常结束还是非正常的被打断,对于我们现在来说,处理逻辑都是一样的,所以调用同一个方法就好了。
<!--index.wxml-->
<view class="contentView">
<view class="leftSlideView" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend" bindtouchcancel="touchend" >
<view class="leftView" style="left: {{offsetX}}px;" >主内容区域</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px);" >隐藏内容区域</view>
</view>
</view>
然后在js中添加touchend方法添加oldOffsetX数据用以记录触摸结束时的offsetX,然后在touchmove方法中计算offsetX加上oldOffsetX就好了。看代码
//index.js
Page({
data: {
startX: 0, //X轴起始坐标
offsetX: 0, //横向的偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
rightWidth: 0 //右边隐藏区域的宽度
},
/**
* 开始触摸
*/
touchstart(e) {
this.setData({
startX: e.touches[0].pageX //记录手指X轴的起始坐标
})
},
/**
* 正在触摸
*/
touchmove(e) {
var currX = e.touches[0].pageX //手指当前的X轴坐标
var oldOffsetX = this.data.oldOffsetX//上一次触摸结束时的偏移量
var offsetX = currX - this.data.startX + oldOffsetX //计算手指在X轴上移动的距离
var rightWidth = this.data.rightWidth //rightView的宽度
if (offsetX < -rightWidth) offsetX = -rightWidth //当手指向左移动的的距离超过rightWidth时那么给offsetX赋值-rightWidth
if (offsetX > 0) offsetX = 0 //当右边的区域已经完成隐藏的时候就不允许向右滑动了
this.setData({
offsetX: offsetX //更新偏移量
})
},
/**
* 触摸结束
*/
touchend(e) {
this.setData({
oldOffsetX: this.data.offsetX
})
},
/**
* 测量节点信息
*/
measure() {
var that = this
wx.createSelectorQuery().select('.rightView').boundingClientRect(function (rect) {
console.log(rect)
that.setData({
rightWidth: rect.width
})
}).exec()
},
onLoad() {
this.measure()
},
})
代码并不复杂,我们看下改造后的效果:
这样第二次触摸瞬移的问题也就解决了,写到这里你发现UI给的图中会有好多地方都要用到左滑显示功能按钮的地方,所以你干脆就想着把它做成一个组件吧!
首先我们在 components文件夹中创建一个leftSlideItem组件:
然后我们要想一下,哪些东西要封装的组件里面,哪些东西不用,如果你只是想简单的做一个左滑删除组件,你可以把删除按钮都封装进去,但我们在项目中用到左滑的地方也许还有修改还有详情等其他按钮,甚至没有按钮就是需要滑动才能查看的内容,所以我们定义这个组件的时候,他的主内容区域和副内容区域的内容肯定不能写死,这两块地方内容可以让用户自己去按需添加,所以我们只需要把滑动的功能封装起来就好了,先看下leftSlideItem.wxml代码
<!--leftSlideItem.wxml-->
<view class="leftSlideItem" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);">
<slot name="rightView"></slot>
</view>
</view>
这里我们添加了两个<slot>,可以给用户自助编辑主内容区域和隐藏区域内容,其他都是复制之前的代码。不清楚slot标签是干嘛的可以去官网看看,
https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/wxml-wxss.html
leftSlideItem.js的内容基本上也是复制之前的js内容:
// components/leftSlideItem/leftSlideItem.js
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
/**
* 组件的属性列表
*/
properties: {
},
/**
* 组件的初始数据
*/
data: {
startX: 0, //X轴起始坐标
offsetX: 0, //横向的偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
rightWidth: 0 //右边隐藏区域的宽度
},
/**
* 组件的方法列表
*/
methods: {
/**
* 开始触摸
*/
touchstart(e) {
this.setData({
startX: e.touches[0].pageX //记录手指X轴的起始坐标
})
},
/**
* 正在触摸
*/
touchmove(e) {
var currX = e.touches[0].pageX //手指当前的X轴坐标
var oldOffsetX = this.data.oldOffsetX //上一次触摸结束时的偏移量
var offsetX = currX - this.data.startX + oldOffsetX //计算手指在X轴上移动的距离
var rightWidth = this.data.rightWidth //rightView的宽度
if (offsetX < -rightWidth) offsetX = -rightWidth //当手指向左移动的的距离超过rightWidth时那么给offsetX赋值-rightWidth
if (offsetX > 0) offsetX = 0 //当右边的区域已经完成隐藏的时候就不允许向右滑动了
this.setData({
offsetX: offsetX //更新偏移量
})
},
/**
* 触摸结束
*/
touchend(e) {
this.setData({
oldOffsetX: this.data.offsetX
})
},
/**
* 测量节点信息
*/
measure() {
var that = this
this.createSelectorQuery().select('.rightView').boundingClientRect(function (rect) {
console.log(rect)
that.setData({
rightWidth: rect.width
})
}).exec()
},
},
/**
* 组件生命周期
*/
lifetimes: {
/**
* 在组件在视图层布局完成后执行
*/
ready: function () {
this.measure() //测量节点信息
}
}
})
这里有两个地方需要注意,因为我们使用了两个<slot>标签,所以要在js中加上下面的代码启用多slot支持
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
还有一点就measure方法里面wx.createSelectorQuery()在组件中需要改成this.createSelectorQuery():
wx.createSelectorQuery()
改成
this.createSelectorQuery()
接下来是leftSlideItem.json,没啥好说的
{
"component": true,
"navigationBarTitleText": "左滑列表组件",
"usingComponents": {}
}
最后是leftSlideItem.wxss
.leftSlideItem{
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
position: relative;
border-radius: 16rpx;
}
.leftSlideItem .leftView{
width: calc(100% + 5rpx);
height: 100%;
position: absolute;
left: 0;
overflow: hidden;
}
.leftSlideItem .rightView{
height: 100%;
position: absolute;
}
这里我将leftView的宽度和wxml文件中rightView标签的偏移量增加5rpx,是因为有同事反应在ios手机上会有细微的偏差,导致右边的隐藏区域会显示出来一丢丢,具体为什么在ios上会这样我也还不清楚(主要是没ios测试机给我调试,我就直接暴力点硬生生加了5rpx解决问题,不过不影响整体美观 ,如果你们知道什么原因欢迎留言。)
好了一个粗糙的组件就写好了,接下来我们引用组件,画一个稍微好看点的布局,我们清除index页面之前的内容,添加上新的布局。
首先是index.js:
Page({
data: {
list:[1,2,3,4,5,6,7,8,9,10]
},
onLoad() {
},
})
index.json
{
"navigationBarTitleText": "自定义左滑删除组件",
"usingComponents": {
"leftSlideItem":"../../components/leftSlideItem/leftSlideItem"
}
}
index.wxml
<view class="contentView">
<block wx:for="{{list}}" wx:for-item="item" wx:key="*this" >
<leftSlideItem id="leftSlideItem">
<view class="leftView" slot="leftView">
<view class="goodsName">
旺仔牛奶
</view>
<view class="barCode">
商品条码:122343434
</view>
<view class="bottomRow">
<text class="qty">实盘数量:12</text>
<text class="unit">货架号:105</text>
</view>
<text class="num">{{item}}</text>
</view>
<view class="rightView" slot="rightView">
<view class="deleteView">删除</view>
<view class="updateView">修改</view>
</view>
</leftSlideItem>
</block>
</view>
index.wxss
page {
background: #EFF2F5;
}
.contentView {
width: 100%;
display: flex;
flex-direction: column;
}
#leftSlideItem {
width: 92%;
height: 260rpx;
margin: 32rpx 4% 0 4%;
}
#leftSlideItem .leftView {
width: 76.54%;
height: 164rpx;
padding: 48rpx 11.73%;
display: flex;
flex-direction: column;
justify-content: space-between;
background: #ffffff;
position: relative;
}
#leftSlideItem .rightView {
width: 296rpx;
height: 100%;
display: flex;
flex-direction: row;
}
#leftSlideItem .leftView .goodsName {
font-size: 32rpx;
color: #242933;
font-weight: bold;
}
#leftSlideItem .leftView .barCode {
font-size: 28rpx;
color: #8A8F99;
}
#leftSlideItem .leftView .bottomRow {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 28rpx;
color: #8A8F99;
}
#leftSlideItem .leftView .bottomRow .unit {
position: relative;
right: 5%;
}
#leftSlideItem .leftView .num {
width: 48rpx;
height: 35rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
color: #ffffff;
background: #2B72E8;
border-radius: 18rpx;
position: absolute;
top: 52rpx;
left: 20rpx;
}
#leftSlideItem .rightView .deleteView{
width: 50%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28rpx;
background: #FF4C4C;
}
#leftSlideItem .rightView .updateView{
width: 50%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 28rpx;
background: #FED13C;
}
看看效果:
这么个样子滑是可以滑了但是总感觉还是有点生硬,一点都不丝滑,想要丝滑还得加上动画以及一些细节上的控制,首先当我们手指松开的时候组件得自动滑开或者自动关闭,通常情况是当手指滑动的距离超过了副内容区域宽度的一半的时候松开,组件应该自动完全展开,当受手指滑动距离小于副内容区域宽度一半的时候松开,组件应该自动完全关闭。这个过程得加上动画才会显得不那么僵硬,不然手指松开瞬间就展开或者瞬间就关闭也不好看。好了思路有了我们再来改造一下组件让它变得丝滑一些。
那么一步一步来,我们先在手指松开的的时候加上判断,让组件自动展开和关闭,改动很简单,只需要在组件的touchend加上判断就好了:
/**
* 触摸结束
*/
touchend(e) {
var offsetX = this.data.offsetX //当前偏移量
var rightWidth = this.data.rightWidth
//当前手指松开的时候偏移量大于rightWidth / 2则自动完全展开,否则自动关闭
if (Math.abs(offsetX) > rightWidth / 2) {
offsetX = -rightWidth
} else {
offsetX = 0
}
this.setData({
offsetX:offsetX,
oldOffsetX: offsetX
})
},
改动不复杂,我也加了注释,大家应该看得懂,我们看下效果:
嗯,自动归位的功能算是完成了,不过还是很生硬,我们得在手指松开的时候让归位的动作有一个过度动画就不会那么生硬了,至于怎么写动画,你们可以选择用小程序提供的api做动画,或者直接使用css的transition写过渡动画,我这里选择使用css的transition写过渡动画,好了接下来加上过渡动画。
首先是leftSlideItem.wxml
<view class="leftSlideItem" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; {{isTouch ? '' : 'transition: left 300ms'}} ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);{{isTouch ? '' : 'transition: left 300ms' }}">
<slot name="rightView"></slot>
</view>
</view>
这里添加了变量isTouch来判断当前是否正处于手指滑动状态,因为我们只需要在手指松开的时候有过渡动画,手指在屏幕上移动的时候是不需要动画的,只需要实时改变组件的位置就好了。
所以在leftView和rightView的style中都添加了过渡动画样式
{{isTouch ? '' : 'transition: left 300ms' }}
然后在leftSlideItem.js的data中添加isTouch
/**
* 组件的初始数据
*/
data: {
startX: 0, //X轴起始坐标
offsetX: 0, //横向的偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
rightWidth: 0, //右边隐藏区域的宽度
isTouch:false,//是否正在滑动
}
接着在手指正在滑动时,也就是touchmove方法中设置isTouch为true表示手指正在屏幕上滑动
touchmove(e) {
......
this.setData({
isTouch: true,手指滑动的时候改变isTouch状态为正在滑动
offsetX: offsetX //更新偏移量
})
},
最后在滑动结束的时候,也就是touchend方法中将isTouch改为false,表示当前手指已经离开屏幕了。
/**
* 触摸结束
*/
touchend(e) {
......
this.setData({
isTouch: false, //手指离开时将状态改为false
offsetX: offsetX,
oldOffsetX: offsetX
})
},
改动并不复杂,我们看下效果:
怎么样这样看起来是不是舒服多了,再提一下我们这个组件只管实现滑动效果,不管布局怎么样,布局和点击事件需要开发者自己在组件的使用界面slot="leftView"和slot="rightView"里自定义。这里还要注意一点,在使用目前这个组价的时候,得给它的父组件的css加上让子组件垂直排列的样式,否则会显示异常,示例:
.contentView {
width: 100%;
display: flex;
flex-direction: column;
}
其实写到这里这个左滑组件基本上就写完了。但如果你追求更完美点,你还可一加上一些特性让它使用起来更加舒服,其实也就是锦上添花的事情,接下来我们再来加一些特性,有兴趣的继续往下看吧!
2.实现快划功能
什么是快划功能,在我们这个组件里面的解释就是,当手指在屏幕上快速朝左边滑动时,就不需要去判断偏移量是否大于或小于副区域的一半的宽度的情况,这种向左快速滑动的情况我们就认定用户是有划开组件的意图的,直接将组件打开就可!同理,快速朝右滑动就直接将组件关闭。
那这个功能是怎么实现的呢?其实思路很简单,咱们只要在手指按下的时候记录一下时间点,然后在手指抬起的时候,就用手指偏移量除以手指滑动的时间就可以算出这段时间手指在屏幕上滑动的平均速度,当这个平均速度大于我们指定的某个边界速度就可以判断他是快划,从而给出对应的向响应。当然这里我们没有考虑那种什么前面滑的很慢后面突然滑的很快,或者来回快速滑动等极端情况,咱就直接计算平均速度就行了,简单粗暴。好了,思路有了,那继续改造我们组件吧!
首先肯定要在手指刚触摸屏幕的时候记录一下触摸开始的时间,我们就记录当前时间戳就好了。
//leftSlideItem.js
data: {
startX: 0, //X轴起始坐标
offsetX: 0, //横向的偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
rightWidth: 0, //右边隐藏区域的宽度
isTouch:false,//是否正在滑动
touchStartTime: 0, //触摸开始时间戳
},
/**
* 开始触摸
*/
touchstart(e) {
this.setData({
touchStartTime: new Date().getTime(), //记录手指刚触摸屏幕时的时间戳
startX: e.touches[0].pageX //记录手指X轴的起始坐标
})
},
这里我们先给组件加上一个touchStartTime变量用于记录触摸动作开始时的时间戳,并在触摸开始时也就是touchstart方法中获取当前时间戳,接下来就要在触摸结束时也就是touchend中计算手指平均速度。但这里要注意我们获取手指滑动偏移量单位是px,我们的设备分辨率有大有小,如果用px计算速度,在不同设备上滑动相同的物理距离计算出的速度可能就会不一样,所以我们得将px转换成rpx来计算速度,我们知道小程序规定了任何屏幕的宽度都为750rpx,这样我们通过手指滑动了多少rpx来计算速度相对来说靠谱一点。
//leftSlideItem.js
/**
* 组件生命周期
*/
lifetimes: {
/**
* 在组件在视图层布局完成后执行
*/
ready: function () {
this.measure() //测量节点信息
var sysInfo = wx.getSystemInfoSync()
this.data.systemInfo = sysInfo
this.data.factorPx = sysInfo.windowWidth / 750 //屏幕像素比,屏幕像素大小 : 750
}
}
我们在组件生命周期ready中计算了一下屏幕像素大小与屏幕rpx大小的一个比率,我为什么要计算这个,因为我们一会要将手指滑动的像素距离换算成rpx距离,根据这个公式:
屏幕像素/750 = 手指滑动的像素距离/手指滑动的rpx距离
这公式不是我编的啊,稍微有点数学基础的应该都懂的。有了这个公式。接下来我们就可以在滑动结束的时候计算出手指滑动的rpx速度。
//leftSlideItem.js
/**
* 触摸结束
*/
touchend(e) {
var offsetX = this.data.offsetX //当前偏移量
var oldOffsetX = this.data.oldOffsetX //滑动之前的原始偏移量
var rightWidth = this.data.rightWidth //右边区域的宽度
var touchStartTime = this.data.touchStartTime // 刚开始触摸的时间戳
var touchEndTime = new Date().getTime() //结束触摸时的时间戳
var factorPx = this.data.factorPx //屏幕像素大小 : 750
var touchDist = offsetX - oldOffsetX //计算出本次手指在屏幕上滑动的距离
var offsetRpx = Math.abs(touchDist) / factorPx //移动的距离,单位rpx
var touchTime = touchEndTime - touchStartTime //手指在屏幕上滑动的时间
//当手指滑动速度大于800rpx/s时,则判定为快划
if (offsetRpx / touchTime * 1000 > 800) {
//当本次手指在屏幕上滑动的距离小于0则代表是左滑,否则是右滑
if (touchDist < 0) {
offsetX = -this.data.rightWidth //如果是向左快划则将偏移量设为右边区域的宽度
} else {
offsetX = 0 //如果是想右滑动,则将偏移量设为0
}
} else { //非快划
//当前手指松开的时候偏移量大于rightWidth / 2则自动完全展开,否则自动关闭
if (Math.abs(offsetX) > rightWidth / 2) {
offsetX = -rightWidth
} else {
offsetX = 0
}
}
this.setData({
isTouch: false, //手指离开时将状态改为false
offsetX: offsetX,
oldOffsetX: offsetX
})
},
我觉得我这里注释写的蛮清楚的,大多数人应该都看的懂,不过这里我还是说一下大致逻辑,首先我们触摸结束的时候,就获取到了手指在屏幕上移动的时间以及本次触摸距离(这里要注意组件偏移量和手指移动距离是不同的两个概念,这两个数值不一定相等,所以不要混淆,这个之前有说,当前组件偏移量 = 本次手指滑动距离 + 上次滑动结束时组件的偏移量),然后通过factorPx 就可以算出手指移动的rpx单位距离,从而计算出rpx单位的速度,这里我们判断当速度大于800rpx/s的时候,我们就认定为快滑,否则就是正常滑动按正常逻辑处理,而快滑又分为向左快划和向右快划,我们之前记录过上一次滑动的偏移量,所以这里可以通过offsetX - oldOffsetX计算出手指本次移动的距离,如果手指移动的距离小于0就是向左快滑,反之就是向右快滑,这里大家要想清楚这个结论,可以动手自己画一画。好了我们看一下效果
怎么样?为了方便看效果我把副内容区域加宽了,当我们手指用正常速度滑动的时候,它就会按照正常判断逻辑处理,当我们手指快速向左滑动的时候它就直接展开,向右快速滑动就直接关闭。这样我们这个组件快划功能就算写完了。
3.实现组件联动
什么是组件联动呢?在我们这个组件的主要体现在当我划开编号为1的组件之后,我再划开编号为2的组件的时候,其他已经划开的组件需不需要自动关闭?如果需要其他已经划开的组件自动关闭,我们这里就说明它们之间是有联动的,反之互不影响就是没有联动。我们组件写到这里其实是还没有联动关系的。
显然我们的组件之间,滑动的时候是互不影响的。在某些情况这其实是不太友好的交互方式,现在我们要让他们产生联动,当划开一个组件的时候,其他已经划开的组件得自动关闭。
那么首先我们得有个思路,怎样才能让他们互相联动?当划开某个组件的时候得有人通知其他组件关闭,这时候就需要有个统管全局的人去做这件事, 谁来当这个统管全局的人,这统管的人必须能拿到所有组件的实例对象才行。在使用组件的页面统管行吗?这不是不行,使用组件的页面确实可以拿到所有组件的实例对象,但是缺点也很明显,那需要在每个使用组件的页面都要加上管理的代码,这显然使用起来太麻烦了!这时候我们就得想到给我们这个滑动组件添加一个父组件,这样父组件也可以拿到它所有子组件的实例的对象,就可以在父组件中做一个统管全局的操作,不用在每个页面都去写管理代码,开发者使用起来就更方便。好了大致的思路有了我们先创建一个父组件。
首先我们创建一个leftSlideGroup组件当做leftSlideItem的父组件。
接下来,我们需要将两个组件相互关联起来,很简单,先在leftSlideGroup关联leftSlideItem
//leftSlideGroup.js
Component({
relations: {
'../leftSlideItem/leftSlideItem': {
type: 'child', // 关联的目标节点应为子节点
linked: function (target) {
// 每次有子组件被插入时执行,target是该节点实例对象,触发在该节点attached生命周期之后
},
linkChanged: function (target) {
// 每次有子组件被移动后执行,target是该节点实例对象,触发在该节点moved生命周期之后
},
unlinked: function (target) {
// 每次有子组件被移除时执行,target是该节点实例对象,触发在该节点detached生命周期之后
}
}
},
/**
* 组件的属性列表
*/
properties: {
},
/**
* 组件的初始数据
*/
data: {
},
/**
* 组件的方法列表
*/
methods: {
}
})
我们在leftSlideGroup.js中加上relations用来关联leftSlideItem,同理我们还需要让leftSlideItem去关联leftSlideGroup
// components/leftSlideItem/leftSlideItem.js
Component({
......
relations: {
'../leftSlideGroup/leftSlideGroup': {
type: 'parent', // 关联的目标节点应为父节点
linked: function (target) {
// 每次被插入到父组件时执行,target是父组件节点实例对象,触发在attached生命周期之后
},
linkChanged: function (target) {
// 每次被移动后执行,target是父组件节点实例对象,触发在moved生命周期之后
},
unlinked: function (target) {
// 每次被移除时执行
}
}
},
......
})
好了,这样父子组件就关联完成了,这里不明白的可以去官网看看组件间的关系
https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/relations.html
我们还要给父组件加上布局。
<!-- leftSlideGroup.wxml -->
<view class="leftSlideGroup" >
<slot></slot>
</view>
/* leftSlideGroup.wxss */
.leftSlideGroup {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
在我们这个组件中父组件不需要太复杂的布局,只需要加上<slot>标签就好了,然后在根view中加上垂直排列居中的布局就好了,我们先把父组件加载页面上。
//index.json
{
"navigationBarTitleText": "自定义左滑删除组件",
"usingComponents": {
"leftSlideItem":"../../components/leftSlideItem/leftSlideItem",
"leftSlideGroup":"../../components/leftSlideGroup/leftSlideGroup"
}
}
<!--index.wxml-->
<view class="contentView">
<leftSlideGroup>
<block wx:for="{{list}}" wx:for-item="item" wx:key="*this">
<leftSlideItem id="leftSlideItem">
<view class="leftView" slot="leftView">
<view class="goodsName">
旺仔牛奶
</view>
<view class="barCode">
商品条码:122343434
</view>
<view class="bottomRow">
<text class="qty">实盘数量:12</text>
<text class="unit">货架号:105</text>
</view>
<text class="num">{{item}}</text>
</view>
<view class="rightView" slot="rightView">
<view class="deleteView">删除</view>
<view class="updateView">修改</view>
</view>
</leftSlideItem>
</block>
</leftSlideGroup>
</view>
很简单,现在json文件引用父组件,再到wxml文件在<leftSlideItem>标签上套上<leftSlideGroup>就行了,完成后的界面和之前一样,视觉上没有区别,我就不再贴效果图了。好了,这样父组件就创建好了,那么接下来就需要写父组件通知子组件的代码,但是在这之前呢,我们得让父组件拿到所有的子组件实例对象,并区分子组件谁是谁,毕竟是多胞胎长得都一样得有东西区分他们。我们先看看关联子组件的时候relations中的三个函数用啥用。
其实在我的注释里面也已经写的很清楚了,这里我们看到linked函数,这个函数是每次有子组件被插入时执行,并且它的参数target就是被插入子组件的实例对象,这正是我们想要的。我们可以在这里获取被插入的所有子组件实例对象,当然还有一种方法,通过官方提供的api获取子节点实例对象:
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') //获取所有已关联的子节点,且是有序的
这里我选择用api的方式去获取子节点信息,那又有个问题,父组件如何知道他的哪个子组件滑动了呢?首先我想的是父组件有没有可以监听子组件滑动的监听器,这里我立马就想到了刚才relations中的linkChanged函数,官方说它是每次有节点被移动时会执行,但是我经过多次验证,发现组件上下左右滑动它都不会被调用,只有当列表的顺序被改变时它才会被调用,比如列表的两个条目被交换位置,就会调linkChanged。没办法,没有监听器那只能另寻他路。既然父组件没办法去监听子组件是否滑动了,那子组件自己总是知道吧,我们就可以在子组件滑动的时候,自己主动去通知它的父组件,告诉它我滑动了,你可以关闭其他子组件了。这样父组件就需要有一个关闭所有子组件的方法,并且还要想办法让子组件获得父组件的实例对象,这样子组件才能调用父组件方法。
我们想让父组件关闭子组件,子组件最好提供一个方法去关闭自己,给父组件调用,那么我们在leftSlideItem组件中添加一个关闭自己的方法
//leftSlideItem.js
/**
* 还原
*/
restore() {
this.setData({
offsetX: 0, //偏移量
oldOffsetX:0//上一次触摸结束时的偏移量
})
},
我们在子组件中添加了一个restore方法用以还原子组件自己,很简单,将偏移量offsetX设为0自然就还原了。那接下就要在父组件中调用了,同样我们在父组件创建一个方法。
//leftSlideGroup.js
/**
* 还原指定id之外的所有item组件
*
* @param {string} id 当前触摸的组件id
*/
restoreItems(id) {
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') 获取所有已关联的子节点,且是有序的
nodes.map(function (item) {
//不是指定组件id就执行还原效果
if (id != item.__wxExparserNodeId__) {
item.restore()//还原子组件
}
})
}
这里我们在父组件中添加了restoreItems方法用以还原子组件,那么首先我们要知道,手指正在触摸的组件是不用还原的,只需要还原其他组件就行,所以这里我们得到当前正在触摸组件的id,然后过滤掉,在调用其他组件的还原的方法就好了。这里有个点我要说一下,可以看到我过滤的条件是
if (id != item.__wxExparserNodeId__)
那这个__wxExparserNodeId__是个什么东西,有很多同学不知道这个东西,因为在官方文档上确实没有这个字段的说明。我们可以现在父组件的relations中的linked函数中打印一下target参数,target是当前被插入父组件的节点实例对象,而nodes就是子组件实例对象的集合,打印代码我就不写了,直接看结果
因为我们在页面添加了十个leftSlideItem组件,所以这里打印出十条信息,对应每个子组件的实例对象,那我们看到每个子组件实例对象都一个__wxExparserNodeId__和__wxWebviewId__,这两个东西是什么在官网也没有找到什么说明,百度也没百度出个什么所以然来,后来我通过一些验证,__wxExparserNodeId__确实可以当做节点id,至于__wxWebviewId__感觉是所在页面id,因为通过getCurrentPages()获取的页面栈中,你会发现每个页面都有个__wxWebviewId__和它的组件中获得__wxWebviewId__的值是一样的,对不对先不下结论,至少目前是没发现啥问题的,并且通过这两个字段的英文意思来推测应该就是这么个意思。
既然组件的id来源确定了,我就就可以在组件开始触摸的时候调用父组件的restoreItems方法来还原其他组件了。那么问题来了,如何在子组件中获得父组件的实例对象呢?很简单,在子组件的relations的linked函数中保存父组件实例对象就好了,这里要注意,在父组件的linked函数是当有子组件插入时会被调用,target参数是被插入的子组件的实例对象,而子组件linked函数是在当前子组件插入到父组件中时调用,参数target是父组件的实例对象。
//leftSlideItem.js
linked: function (target) {
// 每次被插入到父组件时执行,target是父组件节点实例对象,触发在attached生命周期之后
this.parent = target
},
代码很简单直接保存,接下来我们要在子组件被触摸的时候调用父组件的restoreItems方法去还原其它组件。
//leftSlideItem.js
/**
* 开始触摸
*/
touchstart(e) {
this.setData({
touchStartTime: new Date().getTime(), //记录手指刚触摸屏幕时的时间戳
startX: e.touches[0].pageX //记录手指X轴的起始坐标
})
this.parent.restoreItems(this.__wxExparserNodeId__)//还原其它组件
},
我们在子组件刚被触摸的时候也就是touchstart方法中直接调用父组件的restoreItems方法就好了。我们看看效果:
可还行?但是我们一定要组件联动吗?那有可能有的场景需要联动有的就是并不需要联动呢?所以这个特性我们最好给他变成属性让用户有的选择,那这个控制属性是加在父组件还是子组件里呢?其实这里不用太纠结,既然父组件是子组件的管控者,那我们把控制这个特性的属性加在父组件里显然比加载子组件里更加合理,那我们给父组加上一个控制属性
//leftSlideGroup.js
/**
* 组件的属性列表
*/
properties: {
isLinked: { //子组件是否联动
type: Boolean,
value: true
}
},
我们给父组件加上了一个isLinked属性用于控制子组件是否联动,默认值是true表示默认有联动,那接下来我们就写控制逻辑。
//leftSlideGroup.js
/**
* 还原指定id之外的所有item组件
*
* @param {string} id 当前触摸的组件id
*/
restoreItems(id) {
if(!this.data.isLinked)return //如果不联动不做任何处理
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') 获取所有已关联的子节点,且是有序的
nodes.map(function (item) {
//不是指定组件id就执行还原效果
if (id != item.__wxExparserNodeId__) {
item.restore() //还原子组件
}
})
}
这个控制代码就简单了,我直接在restoreItems方法中加了一行代码,如果不联动父组件就不做任何处理就好了。我们来看设置true和false的区别,使用方式我就不展示代码了啊,直接在使用组件页面的leftSlideGroup加上属性就好了。
区别还是很明显的,一个是相互联动,一个是互不影响。那么这个组件联动的功能我们也就写完了。
3.实现组件移除动画
说了这么久,我一直没讲删除功能,之前我说删除不是我写这个组件的主要目的,我们想要删除直接在组件使用页面添加删除事件就好了。接下来我们来写一个删除事件
<!--index.wxml-->
<view class="contentView">
<leftSlideGroup >
<block wx:for="{{list}}" wx:for-item="item" wx:for-index="i" wx:key="*this">
<leftSlideItem id="leftSlideItem">
<view class="leftView" slot="leftView">
<view class="goodsName">
旺仔牛奶
</view>
<view class="barCode">
商品条码:122343434
</view>
<view class="bottomRow">
<text class="qty">实盘数量:12</text>
<text class="unit">货架号:105</text>
</view>
<text class="num">{{item}}</text>
</view>
<view class="rightView" slot="rightView">
<view class="deleteView" bindtap="deleteItem" data-index="{{i}}" >删除</view>
<view class="updateView">修改</view>
</view>
</leftSlideItem>
</block>
</leftSlideGroup>
</view>
//index.js
/**
* 删除item
*/
deleteItem(e) {
var that = this
var index = e.currentTarget.dataset.index
var list = this.data.list
list.splice(index, 1)
that.setData({
list:list
})
},
添加删除的点击事件我就不细讲了,大家都应该知道。接下来我们看效果。
删除倒是能删除,就是整个过程显得很僵硬,不那么舒服,所以这里我们还得加上删除的过渡动画,让整个过程看起来丝滑一点。
那么我们怎么设计这个动画,其实也不需要太花里胡哨,简单点,我们在用户点击删除的时候,直接隐藏被删除的组件,然后将下面的组件往上平移一段距离就好了,控制的方法我们依然写在父组件中,至于具体动画实现我们就写在子组件中就好。那用户点击删除的时候首先执行的动作肯定是被点击组件的隐藏,然后再是其他组件往上平移。我们改造一下子组件
//leftSlideItem.js
/**
* 组件的初始数据
*/
data: {
......
className: '', //类名,用于组件隐藏
offsetY: 0 //删除的时候用得到,需要向上移动的偏移量
},
....
/**
* 隐藏,即添加class=“concealView”,将当前组件高度变为0
*/
concealView() {
this.setData({
className: 'concealView'
})
},
/**
* 设置向上偏移量,删除的时候用到
*
* @param {number} offsetY 需要向上移动的偏移量
*/
moveY(offsetY) {
this.setData({
offsetY: offsetY
})
}
......
<!--leftSlideItem.wxml-->
<view class="leftSlideItem {{className}}" style="bottom:{{ offsetY ? offsetY : 0}}px;" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; {{isTouch ? '' : 'transition: left 300ms'}} ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);{{isTouch ? '' : 'transition: left 300ms' }}">
<slot name="rightView"></slot>
</view>
</view>
/*leftSlideItem.wxss*/
.leftSlideItem{
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
position: relative;
border-radius: 16rpx;
transition: bottom 300ms
}
......
.concealView{
visibility:hidden;
}
我们给leftSlideItem组件的根部view加上了一个变量className的类名,当父组件调用方法concealView()将className设置成‘concealView’,这样css中的样式就会生效,将该组件隐藏起来。同时我们还给组件添加了一个offsetY变量来控制组件向上移动的偏移量,还在css中给组件添加了一个bottom过渡动画,当父组件调用moveY方法就将组件向上平移的指定距离。
子组件的代码写好了,接下来我们改造父组件的代码,首先父组件得知道需要删除哪个子组件,然后才能按需执行控制逻辑,那么移除操作肯定要给父组件一个当前点击组件的下标或id,我们这里选择给父组件传一个当前操作组件的下标会方便一点,那同时动画执行完成之后我们还得通知页面更新数据,所以还得需要一个回调函数,那么我们就可给父组件创建一个方法
//leftSlideGroup.js
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
var that = this
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') //获取所有已关联的子节点,且是有序的
var deleteNode = nodes[indexItem] //当前需要操作的节点
},
创建了一个deleteChild方法并设置了两个参数,参数意义注释都有我就不再解释,刚开始我们肯定得获得即将被删除的子组件的实例,通过下标我们很容易获得该组件实例,我们这里命名为deleteNode。接下来我们得计算一下其他组件的向上的偏移量,这里我们要知道,被删除组件前面的组件是不需要平移的,只有被删除组件后面的组件才需要向上平移,那这个平移的距离 怎么算?首先被删除组件本身的高度肯定是要加上的,还有距离下一个组件距离。如下图
既然这样,我们得取到这几个数据,怎么取?还记得之前我们在leftSlideItem组件中,生命周期ready中调用了measure方法取测量隐藏区域宽度的一个步骤,那我们可以接着在这里面去测量leftSlideItem自身的高度和其他信息。
//leftSlideItem.js
/**
* 组件的初始数据
*/
data: {
......
height: 0, //组件高度
top: 0, //组件的上边界坐标
bottom: 0//组件的下边界坐标
},
......
/**
* 测量节点信息
*/
measure() {
var that = this
this.createSelectorQuery().select('.rightView').boundingClientRect(function (rect) {
that.setData({
rightWidth: rect.width
})
}).exec()
this.createSelectorQuery().select('.leftSlideItem').boundingClientRect(function (rect) {
that.setData({
height: rect.height,
top: rect.top,
bottom: rect.bottom
})
}).exec()
},
我们在子组件添加了三个变量,height(组件高度)、top(组件的上边界坐标)、bottom(组件的下边界坐标),并在measure方法中获取了这三个数据,这里并不能直接获取组件自身距离下一个组件的距离,但是我们可以同top和bottom来计算。那么我们回到父组件,有了子组件提供的这些数据,就可计算组件删除时其他组件向上平移的距离。
//计算被删除节点后面的的节点需要向上的偏移量,offsetY = 当前节点的高+当前节点距离下一个节点的距离
var offsetY = deleteNode.data.height +
(indexItem < nodes.length - 1 ? nodes[indexItem + 1].data.top - deleteNode.data.bottom : 0)
这里高度好说,直接从子组件data中取,计算距离下一个组件的距离时,我们得判断当被删除组件不是最后一个组件时,才会有组件向上移动的,如果删除的是最后一个组件,肯定是没有组件需要向上移动。也就是indexItem < nodes.length - 1时,表示删除的不是最后一个组件,就需要计算向上偏移量,否则就直接返回0就好了!那现在关键数据局都得到了,我们就可以把删除逻辑写完了。
//leftSlideGroup.js
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
var that = this
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') //获取所有已关联的子节点,且是有序的
var deleteNode= nodes[indexItem] //当前需要操作的节点
//计算被删除节点后面的的节点需要向上的偏移量,offsetY = 当前节点的高+当前节点距离下一个节点的距离
var offsetY = deleteNode.data.height +
(indexItem < nodes.length - 1 ? nodes[indexItem + 1].data.top - deleteNode.data.bottom : 0)
nodes.map(function (item, index) {
if (index == indexItem) { //被删的组件
item.concealView() //移除组件
} else if (index > indexItem) { //被删除组件后面的组件
item.moveY(offsetY) //执行平移动画
}
//最后一个之间动画结束,就触回调,这里延迟时间=动画执行时长,子组件的动画时长是300ms
if (index == nodes.length - 1 && callBack) {
setTimeout(function () {
callBack() //动画结束回调
}, 300) //子组件css移动的动画设置的300ms,这里保持一致
}
})
},
知道了关键数据,逻辑就很简单了,遍历nodes,找出index等于indexItem就是需要被移除的组件,我们就执行它的移除函数,index大于indexItem的就是需要向上平移的组件,我们执行平移动画,然后当最后一个动画执行完毕的时候,我们就调用回调函数。那么我们在index页面删除的代码就需要改一改了。
<!--index.wxml-->
<view class="contentView">
<leftSlideGroup id="leftSlideGroup">
......
</leftSlideGroup>
</view>
//index.js
/**
* 删除item
*/
deleteItem(e) {
var that = this
var index = e.currentTarget.dataset.index
var list = this.data.list
//执行删除动画
this.leftSlideGroup.deleteChild(index, function () {
//动画执行完毕,更新数据
list.splice(index, 1)
that.setData({
list: list
})
})
},
onLoad() {
this.leftSlideGroup = this.selectComponent("#leftSlideGroup")
},
这里就是我们先给leftSlideGroup加上id,并在页面生命周期获取它的实例对象,删除的时候要先调用父组件的deleteChild方法去执行删除动画,动画结束后我们再更新数据,当然你要直接更新数据也可以,只不过就没有动画了而已,我们看下效果。
效果怎么?乍一看是不是没啥毛病?组件也删除了,底部组件也向上滑动了。但如果仔细看,我明明删除的3号组件,怎么最后连2号一起删掉了,一下删了两个,咋回事?细心的同学可能就发现了,我们之前让下面的组件往上平移的的时候,设置了一个向上偏移量,但是在跟新数据后偏移量还在,就导致更新数据后那些设置了偏移量的组件始终是往上偏移了那么一段距离,刚好把上面的一个组件覆盖了,看着像是被删除了,实际上只是被覆盖了。这么说可能不一定明白,我画个图解释一下。
这么说应该理解了吧!那既然知道了原因我们解决就是了,我们在数据更新后把偏移量归零就好了。首先我们在leftSlideItem组件的还原方法即restore方法里加上offsetY和className的还原,顺便重新测量节点信息。然后在leftSlideGroup组件的relations中的unlinked函数(这是在子组件被移除后会执行,删除的数据被更新后就会被调用)去调用还原偏移量的方法
//leftSlideItem.js
/**
* 还原
*/
restore() {
this.setData({
offsetX: 0, //偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
offsetY: 0, //向上偏移量
className: '' //类名
})
this.measure()//重新测量节点信息
},
//leftSlideGroup.js
relations: {
......
unlinked: function (target) {
// 每次有子组件被移除时执行,target是该节点实例对象,触发在该节点detached生命周期之后
this.restoreItems(null) //当有子组件被删除后要还原偏移量
}
}
好了我们看下效果。
诶?!怎么还有问题?它怎么向上平移后又往下平移了,怎么回事?细心的同学就会发现,我们往上平移是将组件向上的偏移量offsetY设置成了某个值这里假设是100,由于我们在leftSlideItem.wxss文件中给leftSlideItem类添加过bottom动画,只要 bottom的值也是offsetY有变化它就会执行动画,那当我们点击删除时设置offsetY为100它就向上执行了动画,当数据更新后我们调用了子组件还原方法,将offsetY又设置成了0,所以它又执行了一个offsetY由100到0的动画,也就是我们看到的向下移动的动画了。不过问题知道了,怎么解决呢?我们只需要它向上平移的时候有动画,还原的时候我们不需要动画。那就好说了,当我们offsetY不为0的时候那组件肯定是在执行向上平移,为0的时候那就不需要动画。我们就需要将leftSlideItem.wxss中的bottom动画搬到leftSlideItem.wxml中去做一个判断。
<!--leftSlideItem.wxml-->
<view class="leftSlideItem {{className}}" style="bottom:{{ offsetY ? offsetY : 0}}px; {{offsetY == 0 ? '' : 'transition: bottom 300ms'}}" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; {{isTouch ? '' : 'transition: left 300ms'}} ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);{{isTouch ? '' : 'transition: left 300ms' }}">
<slot name="rightView"></slot>
</view>
</view>
我把leftSlideItem.wxss中leftSlideItem类的transition: bottom 300ms删掉了,搬到了leftSlideItem.wxml中,做了一个判断,当offsetY == 0就不需要动画否则需要动画。好了看下效果。
OK!没毛病 ,那这个删除的特效基本上就算写完了。当然你还想把这个功能做的更加精致一点也行,比如我换一种特效,它整体向上平移我觉得太单调,我要让他依次向上平移中间有个间隔。实现起来很简单,无非值执行动画前加个等待时间,并且等待时间依次递增。好了继续改造代码。
//leftSlideGroup.js
......
/**
* 组件的属性列表
*/
properties: {
removeEffects: { //子组件移除特效,只在删除的时候有用,sequence(依次触发)\together(同时触发)
type: String,
value: 'together'
},
......
},
/**
* 组件的初始数据
*/
data: {
animDuration: 300, //动画时长,单位毫秒
animInterval: 50 //动画间隔时长,单位毫秒,当removeEffects属性为sequence时有效
},
......
methods{
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
var that = this
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') //获取所有已关联的子节点,且是有序的
var deleteNode = nodes[indexItem] //当前需要操作的节点
//计算被删除节点后面的的节点需要向上的偏移量,offsetY = 当前节点的高+当前节点距离下一个节点的距离
var offsetY = deleteNode.data.height +
(indexItem < nodes.length - 1 ? nodes[indexItem + 1].data.top - deleteNode.data.bottom : 0)
var delay = 0 //属性=sequence时有用,移除目标组件后,底下的组件触发移动特效的延迟时间
var animDuration = that.data.animDuration //动画时长,单位毫秒
//遍历操作需要响应的组件
for( var index = indexItem ; index < nodes.length; index++){
var item = nodes[index]
if (index == indexItem) { //被删的组件
item.concealView() //移除组件
} else if (index > indexItem) { //被删除组件后面的组件
var animInterval = that.data.animInterval //动画间隔时长,单位毫秒
var removeEffects = that.data.removeEffects //子组件移除特效,sequence(依次触发)\together(同时触发)
switch (removeEffects) {
case 'sequence':
if (index > indexItem + 1) delay += animInterval //第一个执行动画的组件不需要等待
item.moveY(offsetY,delay) //延时执行平移动画
break
default:
item.moveY(offsetY) //执行平移动画
break
}
}
//最后一个之间动画结束,就触回调,这里延迟时间=动画执行时长+最后一个组件移动前的等待时间
if (index == nodes.length - 1 && callBack) {
setTimeout(function () {
callBack() //动画结束回调
}, animDuration + delay ) 子组件css移动的动画设置的300ms,这里保持一致
}
}
},
}
首先我给leftSlideGroup组件添加了一个属性removeEffects,用于控制删除动画的类型;然后添加了两个data数据,animDuration表示单个子组件执行向上平移动画的时长,之前是在子组件中写的固定300ms,我这里拿到父组件里面方便控制。animInterval表示执行删除动画的时候,每个子组件执行的间隔时长;deleteChild的改动也不多,就是加上了sequence与together特效的执行方式,注释很详细也不复杂就不细细讲了。同时还改动了子组件的部分内容。
//leftSlideItem.js
relations: {
'../leftSlideGroup/leftSlideGroup': {
type: 'parent', // 关联的目标节点应为父节点
linked: function (target) {
// 每次被插入到父组件时执行,target是父组件节点实例对象,触发在attached生命周期之后
this.parent = target
this.setData({
animDuration:target.data.animDuration //获取删除动画执行时长
})
},
......
}
},
methods: {
......
/**
* 设置向上偏移量,删除的时候用到
*
* @param {number} offsetY 需要向上移动的偏移量
* @param {number} delay 延时时间单位毫秒,默认0
*/
moveY(offsetY,delay = 0) {
var that = this
//利用定时器实现依次执行动画效果
setTimeout(function () {
that.setData({
offsetY: offsetY
})
}, delay)
},
......
}
<!--leftSlideItem.wxml-->
<view class="leftSlideItem {{className}}" style="bottom:{{ offsetY ? offsetY : 0}}px; {{offsetY == 0 ? '' : 'transition: bottom ' + animDuration + 'ms'}}" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; {{isTouch ? '' : 'transition: left 300ms'}} ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);{{isTouch ? '' : 'transition: left 300ms' }}">
<slot name="rightView"></slot>
</view>
</view>
这里的获取父组件的animDuration参数,控制删除平移动画时长。同时在设置向上偏移量时加了个定时器,用以实现sequence特效。我们来使用,在index.wxml页面添加删除特效属性
<!--index.wxml-->
<view class="contentView">
<leftSlideGroup id="leftSlideGroup" removeEffects="sequence">
<block wx:for="{{list}}" wx:for-item="item" wx:for-index="i" wx:key="*this">
<leftSlideItem id="leftSlideItem">
<view class="leftView" slot="leftView">
<view class="goodsName">
旺仔牛奶
</view>
<view class="barCode">
商品条码:122343434
</view>
<view class="bottomRow">
<text class="qty">实盘数量:12</text>
<text class="unit">货架号:105</text>
</view>
<text class="num">{{item}}</text>
</view>
<view class="rightView" slot="rightView">
<view class="deleteView" bindtap="deleteItem" data-index="{{i}}" >删除</view>
<view class="updateView">修改</view>
</view>
</leftSlideItem>
</block>
</leftSlideGroup>
</view>
只是在leftSlideGroup标签中加上了 removeEffects="sequence"属性,我们来看看效果。
效果可还行,这个图片看着有点晃,动图软件生成的原因,可能是没有充vip,实际上在手机上操作没这晃的。组件好像是写完了,我用着用着又发现一个问题。
看看,当我们没滚动列表的时候,删除特效没啥问题,但是,当我们滚动之后,再删除,下面的组件不仅没往上平移,反而还往下跑了,咋回事?因为我们之前实现组件联动功能的时候,在组件touchstart方法中写了还原其它组件的代码,还原内容中就有重新测量组件top和bottom等节点信息的步骤,这样就导致,没滚动的时候触摸某个条目触发测量其他组价时,由于位置并没有改变,它们的top和bottom值是不会变的,所以看起来没毛病,当滚动的时候top和bottom的值就肯定变了,但是我们还原的时候过滤了当前正在触摸的组件,也就是在执行删除动画的时候,当前正在触摸的组件节点信息是旧的,但是其他组件信息节点信息时新的,这样计算出来的偏移量就肯定有问题。帮大家回顾一下,以下是相关代码:
//leftSlideItem.js
/**
* 开始触摸
*/
touchstart(e) {
......
this.parent.restoreItems(this.__wxExparserNodeId__) //还原其它组件
},
/**
* 还原
*/
restore() {
this.setData({
offsetX: 0, //偏移量
oldOffsetX: 0, //上一次触摸结束时的偏移量
offsetY: 0, //向上偏移量
className: '' //类名
})
this.measure() //重新测量节点信息
},
//leftSlideGroup.js
/**
* 还原指定id之外的所有item组件
*
* @param {string} id 当前触摸的组件id
*/
restoreItems(id) {
if (!this.data.isLinked) return //如果不联动不做任何处理
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') 获取所有已关联的子节点,且是有序的
nodes.map(function (item) {
//不是指定组件id就执行还原效果
if (id != item.__wxExparserNodeId__) {
item.restore() //还原子组件
}
})
}
既然原因找到了,那我们只需要在删除动画执行之前也更新一下当前触摸的组件的节点信息就好了,这里我建议在侧滑触摸结束的时候更新,也就是组件的touchend方法中,改动很简单。
//leftSlideItem.js
/**
* 触摸结束
*/
touchend(e) {
.......
if(this.parent.data.isLinked)this.measure() //重新测量节点信息
},
这里注意,因为我们是在组件开启联动的时候才需要还原其它组件,所以我们这里也在组件开启联动的时候才重新测量节点信息。看看效果。
好了吧!那么这个问题就解决了。 到此滑动删除的功能算是就写完了。
4.组件细节处理
写了这么多,组件大致上是完成了,但这个组件虽说不是什么特别 复杂的组件但也不是很简单的一个东西,细节方面还是有一些的,这一节主要是用于后续发现什么bug或者不合理的地方进行修复过程的一个记录,为什么不直接在前面内容上进行修改?我之前也是这样想的,后来想想直接对之前的文章进行修改的话,我担心万一我漏掉哪里没有改动,会对读者读起来造成理解障碍,所以我这里就不在前面改了,直接在后面补充bug修复的过程。这里就来处理一下后续发现的问题。
(1)手指在列表上下滑动时,侧滑组件太敏感
为什么说太敏感,就是我明明在上下滑动,并没有侧滑的意图,但是leftSlideItem组件还是会向左滑。上图说话。
you look look!我们上下滑动的时候,手指稍微扭曲一点,组件右边部分就想露头,这种交互显然很不友好。得改!咋整?首先我们得知道用户的意图,用户是不是想上下滚动列表,如果用户只是想上下滑动列表,那我们就禁止组件侧滑出来,问题是我们如何知道用户意图?光靠程序完全知道用户想法肯定不可能,但我们能靠程序数据大致猜一猜,可能有误判,但只要保证大部分时候是对的就行了,那我们也只能这样。
首先我们得知道小细节,滑动事件的触发,肯定不是你手指在屏幕上面移动了1px或者2px就开始触发滑动事件的,它是有一个抖动范围,因为绝大多数的人的手指不可能那么稳,点击的时候无意识偏移一两个像素位是很正常的。如果没有一个抖动范围,很多人明明是想点击某个组件,不小心稍微抖了一丢丢的距离就触发了滑动事件,那肯定是不行。有兴趣的可以自己试一试,看看是不是移动一点点就触发滑动事件,反正我是打印日志试过多次,一般是都是移动15-35rpx才开始触发滑动事件,当然测出的范围可能和我的并不一样,不过这不重要,我们只要知道他是有一个抖动范围的就行了。那么我们也可以利用这个机制去判断用户是否有向上滑动的意图。很简单,既然有一个抖动范围,那说明触发滑动事件(touchmove)的时候手指已经移动了一小段距离,那我们在touchmove中判断手指在Y轴上移动的距离大于X轴上移动的距离,代表他有上下滑动的意图,这时候就禁止组件侧滑事件。
//leftSlideItem.js
/**
* 组件的初始数据
*/
data: {
......
startY: 0, //Y轴起始坐标
isRoll: true, //表示用户本次滑动是否想滚动列表的意图。
},
/**
* 开始触摸
*/
touchstart(e) {
this.setData({
touchStartTime: new Date().getTime(), //记录手指刚触摸屏幕时的时间戳
startX: e.touches[0].pageX, //记录手指X轴的起始坐标
startY: e.touches[0].pageY, //记录手指Y轴的起始坐标
})
this.parent.restoreItems(this.__wxExparserNodeId__) //还原其它组件
},
/**
* 正在触摸
*/
touchmove(e) {
var currX = e.touches[0].pageX //手指当前的X轴坐标
var currY = e.touches[0].pageY //手指当前的Y轴坐标
var startX = this.data.startX //X轴起始坐标
var startY = this.data.startY //Y轴起始坐标
var slideX = Math.abs(currX - startX) //X轴移动的距离
var slideY = Math.abs(currY - startY) //Y轴移动的距离
//当slideY>slideX时表示用户有滚动列表的意图。!this.data.isRoll && !this.data.isTouch是确保只在第一次触发的时候确定用户意图
if (!this.data.isRoll && !this.data.isTouch && slideX < slideY) {
this.setData({
isRoll: true //表示用户本次滑动是想滚动列表的意图。
})
}
if (!this.data.isRoll) { //如果用户想滚动列表,则不触发组件侧滑事件
var oldOffsetX = this.data.oldOffsetX //上一次触摸结束时的偏移量
var offsetX = currX - this.data.startX + oldOffsetX //计算手指在X轴上移动的距离
var rightWidth = this.data.rightWidth //rightView的宽度
if (offsetX < -rightWidth) offsetX = -rightWidth //当手指向左移动的的距离超过rightWidth时那么给offsetX赋值-rightWidth
if (offsetX > 0) offsetX = 0 //当右边的区域已经完成隐藏的时候就不允许向右滑动了
this.setData({
isTouch: true, //手指滑动的时候改变isTouch状态为正在滑动
offsetX: offsetX //更新偏移量
})
}
},
/**
* 触摸结束
*/
touchend(e) {
......
this.setData({
isTouch: false, //手指离开时将状态改为false
isRoll: false, //还原滚动意图为false
offsetX: offsetX,
oldOffsetX: offsetX
})
},
首先我们在data添加了两个变量。startY表示手指开始触摸Y轴坐标,isRoll表示用户本次滑动是想滚动列表的意图。然后在touchstart中记录了一下startY,再在touchmove中计算刚触发滑动事件的时候slideY是否大于slideX,如果slideY>slideX,我们就认定用户本次滑动是想滚动列表,设置isRoll为true,这样就阻止了组件的侧滑。我们看看效果。
这样上滑的时候就不会触发侧滑事件了。我们这里只是做了用户的初始意图判断。那些用户刚开始想上下滚动列表,滑动到半截想左滑组件的极端情况我们就不考虑了,也没必要考虑,你们想想,当用户一只手握着手机,用大拇指向上滑动屏幕的时候,最开始手指是垂直往上滑,但拇指长度有限,越往上手指的滑动路线与水平的夹角就会越小,到最后的滑动路线甚至都快平行于水平线线了,这时候然不成我们还触发侧滑事件?显然不行,这种情况用户压根没有侧滑的意图,所以我们只需要考虑初始意图就好了,只是做一个侧滑组件没必要搞那么智能。那么这个细节我们就处理完了。
(2)当组件联动属性isLinked设为false 的时候删除特效异常。
我们先看下问题,当我将leftSlideGroup的isLinked设为false的时候会发生什么。直接上图,属性设置代码就不贴了。
我们看到,只有第一次动画算是正常,但是之后动画就都失效了,为什么?要知道我们正常情况下每次在删除动画执行完成后会更新删除后的数据,更新数据之后就会调用leftSlideGroup.js中的restoreItems方法去初始化组件的节点数据。但是我们看下之前的restoreItems代码。
/**
* 还原指定id之外的所有item组件
*
* @param {string} id 当前触摸的组件id
*/
restoreItems(id) {
if (!this.data.isLinked) return //如果不联动不做任何处理
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') 获取所有已关联的子节点,且是有序的
nodes.map(function (item) {
//不是指定组件id就执行还原效果
if (id != item.__wxExparserNodeId__) {
item.restore() //还原子组件
}
})
}
我们之前为了实现联动功能,最前面加了一个判断,如果组件不联动也就是isLinked设为false的时候,在删除之后是不会初始化组件节点数据的,所以在当isLinked为false时删除动画才会异常,那怎么办?我们在更新列表数据之后肯定得初始化所有子组件节点信息,但是组件在非联动状态时又不需要还原其它组件的,那岂不是冲突了?表面上看是冲突了哈,但是实际上没有,我们要知道,联动属性是在手指刚触摸组件的时候才会起作用,数据更新后还原操作是在删除动画执行完毕后才执行,这两步代码操作时间节点不一样,所以是可以 区分开来的,那么我么改造一下代码。
//leftSlideGroup.js
relations: {
'../leftSlideItem/leftSlideItem': {
......
unlinked: function (target) {
// 每次有子组件被移除时执行,target是该节点实例对象,触发在该节点detached生命周期之后
this.restoreItems(null,true) //当有子组件被删除后要还原偏移量
}
}
},
/**
* 还原指定id之外的所有item组件
*
* @param {string} id 当前触摸的组件id
* @param {boolean} isCoerce 是否强制执行还原操作
*/
restoreItems(id,isCoerce = false) {
if (!isCoerce && !this.data.isLinked) return //如果不联动不做任何处理
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') 获取所有已关联的子节点,且是有序的
nodes.map(function (item) {
//不是指定组件id就执行还原效果
if (id != item.__wxExparserNodeId__) {
item.restore() //还原子组件
}
})
}
这里我给restoreItems加了一个isCoerce参数,代表是否强制初始化组件,当为false的时候isLinked属性才起作用,当isCoerce为true,则会跳过isLinked判断,强制执行初始化操作。好了看下效果。
额~虽说非联动状态下,删除向上平移的动画没问题了,但是当划出多个组件时,再删除其中某一个,其他已打开的组件会自动还原。这种情况合理吗?在某些场合是合理的,但既然我们定义了isLinked属性是否联动,当isLinked设为false时,那我们这里应该理解为子组件之间互不影响。也就是子组件本身的交互不应该影响到其他组件的状态,在这里当A、B、C三个组件都在已划开状态,然后我删除B时,不应该影响到A和C的状态,也就是A和C不应当关闭,这才符合isLinked属性禁止联动的预期。所以这个问题我们再处理一下。很简单,只要要根据isLinked属性判断,还原的时候isLinked为true时需要还原X轴的偏移量的就好了。
//leftSlideItem.js
/**
* 还原
*/
restore() {
var isLinked = this.parent.data.isLinked //获取父组件是否联动属性
this.setData({
offsetX: isLinked ? 0 : this.data.offsetX, //偏移量
oldOffsetX: isLinked ? 0 : this.data.offsetX, //上一次触摸结束时的偏移量
offsetY: 0, //向上偏移量
className: '' //类名
})
this.measure() //重新测量节点信息
},
我们只需要在leftSlideItem组件中的还原方法restore中判断父组件isLinked的属性值,如果为true则将offsetX和oldOffsetX还原为0,否则还是取原来的值不变就好了。看看效果。
好了,非联动状态的的删除特效的bug就修复了。
(3)删除特效做的无用功
其实在刚开始删除特效初步写完的时候我就明确知道有这个问题,当时是觉得对用户觉得影响应该不大,所以没有处理,现在看来还是有必要处理一下。是这么个问题,当用户删除某个条目的时候,它下面的组件就会往上移,列表条目不多的情况下其实也无所谓,但是当列表条目达到几百上千条甚至更多的时候,问题就很大了,如果达到一定数量,假设我删除了第三条,那下面的几百上千条子组件岂不是都要跟着移动,我们就做了大量的无用功,并且将removeEffects设为sequence时还很耗时,明明用户只能看有限的个条目的动画,我们完全没必要移动所有的组件,在删除的时候给用户能够感知到到的条目做动画就可以了。
问题既然提出来了,那怎么解决。首先怎么确定哪些条目是用户可以感知到的,其实就是用户肉眼可以看到的。首先,被删除的条目用户肯定可以感知不用说,其次就是被删除条目后面已经显示在屏幕上的条目,最后就是即将显示在屏幕上的条目,也就是删除前用户看不见,但是删除后能被用户看见的条目。我画个图。
我们看看上面的图,实线代表用户可看到,虚线代表用户无法看到。假如原先有7个条目,现在我们要删除3号,那么哪些是用户可以感知的,首先12345用户肯定可感知,因为用户在删除前就可以看到,那6号呢?6号删除前用户是看不见的,但是删除后用户就可以看见6号的上部分,所以也是可以感知的。那不能感知就是7号,因为无论删除前和删除后,用户都看不见7号,所以当我们做删除动画的时候,只需要管123456号,不用管7号,而1号和2号又在被删除条目的上面是不需要管的,所以实际上我们只需要管3456号条目的动画就好了。那现在问题是怎么确定哪些条目需要我们执行动画的,用什么条件去判断,首先下标肯定得大于被删除条目的下标,然后我们得确定哪一个条目才是最后一个需要执行动画的条目,也就是上图中的6号,我们得怎么确定哪个条目和上图中的6号情况一样。其实按照上图中的例子,我只需要轮询列表,哪一个条目在删除前它的顶部到屏幕的距离大于屏幕高度,在删除后也就是加上偏移量之后,它的顶部到屏幕顶部距离小于屏幕高度就好。正常情况下确实可以这么判断,也就是当列表的所有条目高度相等的时候可以这么判断,但是如果列表中的条目有个别高度不一样,那就不能这么判断了,就拿上图说,假如5号的高度是三号的3倍,那么删除3号后,5号可能依然没有完全显示出来,这时候就不能这么判断。那怎么办?最稳妥的是,我们执行删除动画轮询的时候,找出第一个删除动画执行完毕后,他的顶部到屏幕顶部的距离大于屏幕高度的(也就是删除动画执行完毕后还在屏幕 外的),那么它的上一个项目就是最后一个需要执行动画的,就拿上图说,正常情况下我们只需要确定了7号是第一个删除后也不会显示的,我们就知道了6号是最后一个需要执行动画的,即便出现极端情况,5号的高度是3号的三倍,那到时候我们只需要确定6号是第一个删除后也不会显示的,我们就知道5号他是最后一个需要执行动画的。这个结论大家一定要想明白。
这里在改造之前,还有个忽略的地方没有处理。之前还没写父组件时我们为了做左滑效果在leftSlideItem.js的ready生命周期中获取了系统信息的。
//leftSlideItem.js
/**
* 组件生命周期
*/
lifetimes: {
/**
* 在组件在视图层布局完成后执行
*/
ready: function () {
......
var sysInfo = wx.getSystemInfoSync()
this.data.systemInfo = sysInfo
this.data.factorPx = sysInfo.windowWidth / 750 //屏幕像素比,屏幕像素大小 : 750
}
}
但是现在还在子组件生命周期中获取显然就不太好,列表如果有100个条目,岂不是就要获取一百遍啊一百遍!所以我们现在把他移到父组件去获取会合适一点。那么我们在父组件中加上代码,子组件中原先获取系统信息的三行代码就删掉了。
//leftSlideGroup.js
/**
* 组件生命周期
*/
lifetimes: {
/**
* 在组件在视图层布局完成后执行
*/
ready: function () {
var sysInfo = wx.getSystemInfoSync()
this.data.systemInfo = sysInfo
this.data.factorPx = sysInfo.windowWidth / 750 //屏幕像素比,屏幕像素大小 : 750
}
}
然后在子组件中,原先获取的相关信息就得到父组件中获取了,实际上只有一行代码:
//leftSlideItem.js
/**
* 触摸结束
*/
touchend(e) {
......
var factorPx = this.data.factorPx //屏幕像素大小 : 750
......
}
上面的那一行代码改成下面的
/**
* 触摸结束
*/
touchend(e) {
......
var factorPx = this.parent.data.factorPx //屏幕像素大小 : 750
......
}
改完了之后我们回归主题,处理我们删除的问题。改造一下leftSlideGroup组件的deleteChild方法。
//leftSlideGroup.js
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
var that = this
var nodes = this.getRelationNodes('../leftSlideItem/leftSlideItem') //获取所有已关联的子节点,且是有序的
var deleteNode = nodes[indexItem] //当前需要操作的节点
var windowHeight = this.data.systemInfo.windowHeight//可使用窗口高度,单位px
//计算被删除节点后面的的节点需要向上的偏移量,offsetY = 当前节点的高+当前节点距离下一个节点的距离
var offsetY = deleteNode.data.height +
(indexItem < nodes.length - 1 ? nodes[indexItem + 1].data.top - deleteNode.data.bottom : 0)
var delay = 0 //属性=sequence时有用,移除目标组件后,底下的组件触发移动特效的延迟时间
var animDuration = that.data.animDuration //动画时长,单位毫秒
//遍历操作需要响应的组件
for (var index = indexItem; index < nodes.length; index++) {
var item = nodes[index]
if (index == indexItem) { //被删的组件
item.concealView() //移除组件
} else if (index > indexItem) { //被删除组件后面的组件
var animInterval = that.data.animInterval //动画间隔时长,单位毫秒
var removeEffects = that.data.removeEffects //子组件移除特效,sequence(依次触发)\together(同时触发)
//预先如果移动后还在屏幕外,则后面的条目都不用执行动画了。
if(item.data.top - offsetY > windowHeight){
setTimeout(function () {
callBack() //动画结束回调
}, animDuration + delay) 子组件css移动的动画设置的300ms,这里保持一致
break
}
console.log(`当前正在执行动画的下标是===${index}`)
switch (removeEffects) {
case 'sequence':
if (index > indexItem) delay += animInterval //第一个执行动画的组件不需要等待
item.moveY(offsetY, delay) //延时执行平移动画
break
default:
item.moveY(offsetY) //执行平移动画
break
}
}
//最后一个之间动画结束,就触回调,这里延迟时间=动画执行时长+最后一个组件移动前的等待时间
if (index == nodes.length - 1 && callBack) {
setTimeout(function () {
callBack() //动画结束回调
}, animDuration + delay) 子组件css移动的动画设置的300ms,这里保持一致
}
}
},
看着挺多,实际上我只加了几行代码,其他 都是之前的,主要是这几行。
//leftSlideGroup.js
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
......
var windowHeight = this.data.systemInfo.windowHeight//可使用窗口高度,单位px
......
//遍历操作需要响应的组件
for (var index = indexItem; index < nodes.length; index++) {
......
if (index == indexItem) { //被删的组件
......
} else if (index > indexItem) { //被删除组件后面的组件
......
//预知如果移动后还在屏幕外,则后面的条目都不用执行动画了。
if(item.data.top - offsetY > windowHeight){
setTimeout(function () {
callBack() //动画结束回调
}, animDuration + delay) 子组件css移动的动画设置的300ms,这里保持一致
break
}
console.log(`当前正在执行动画的下标是===${index}`)
......
}
......
}
},
首先我获取了屏幕可用高度windowHeight。然后在for循环中的index > indexItem条件中加上当预知移动后条目还在屏幕之外我们就跳出循环并触发callBack回调函数,就不用执行后面的动画了。我加了一行打印验证一下,看图。
看看,我删除了第二个条目,也就是下标是1,那我们肉眼看到图中执行平移动画的有下面的四个,也就是下标是2、3、4、5,不能多也不能少。然后我们看看日志是不是和我们预想的一样 。
结果和我们预期的一样,执行动画的只有用户肉眼可以看见的四个条目,其他看不见的就没必要耗费时间再去一个一个执行动画了。 那我们这个问题也就处理完了。
(4)删除动画正在执行的时候用户暴力操作导致界面混乱。
在用户量大的情况下,不可避免的就会发生用户暴力操作的情况,一顿乱按一顿乱划,导致程序出现问题。我们这个组件也有。看看下面这个情况。
上图这种情况,是在当我们删除某个条目的时候,在删除动画还没执行完毕,手指在屏幕上一顿乱滑,就会出现这种问题。首先我们得知道为什么会产生这种问题,是因为用户暴力操作?那不能把问题甩锅给用户,无论用户采用什么操作方式,是正常操作还是暴力操作出现问题,实际上就是你程序有问题,当然也有极个别因为用户暴力操作产生的问题很难解决,那没办法,但我们这个问题是很难解决的吗?显然不是,所以我们得找原因。
首先我们得分析,当动画没有执行完毕的时候,我们手指同时在组件上左滑会触发什么事件,本身的滑动事件不用说,还有一点就是在联动状态下,滑动组件会触发还原其它组件的事件, 是这个原因导致的吗?我们看看还原的时候还原了哪些东西。
//leftSlideItem.js
/**
* 还原
*/
restore() {
var isLinked = this.parent.data.isLinked //获取父组件是否联动属性
var isAnimation = this.parent.data.isAnimation //是否正在执行动画
this.setData({
offsetX: isLinked ? 0 : this.data.offsetX, //偏移量
oldOffsetX: isLinked ? 0 : this.data.offsetX, //上一次触摸结束时的偏移量
offsetY: 0, //向上偏移量
className: '' //类名
})
this.measure() //重新测量节点信息
},
还原了四个变量,有问题吗?当然有问题,你看其中offsetY是Y轴偏移量,你想想正在执行向上平移的动画,你突然给我的Y轴偏移量变成0,那不冲突了么,就乱套了,还有className,这个在删除动画刚执行时,设置为concealView隐藏被删除的组件的,我动画都还没执行完,你给我还原了,那我就在中途就又显示出来了,在数据更新之后又会被删除,就会看到闪一下。那既然这两个数据不能在删除动画正在执行的时候还原,我们来处理一下。首先我们得添加一个变量,用来记录删除动画是否执行完成。
//leftSlideGroup.js
/**
* 组件的初始数据
*/
data: {
......
isAnimation : false //是否正在执行动画
},
/**
* 执行删除动画
*
* @param {number} indexItem 删除的下标
* @param {fun} callBack 动画完成回调函数
*/
deleteChild(indexItem, callBack) {
......
this.setData({
isAnimation:true //正在执行动画
})
//遍历操作需要响应的组件
for (var index = indexItem; index < nodes.length; index++) {
var item = nodes[index]
if (index == indexItem) { //被删的组件
.......
} else if (index > indexItem) { //被删除组件后面的组件
......
//预先如果移动后还在屏幕外,则后面的条目都不用执行动画了。
if(item.data.top - offsetY > windowHeight){
setTimeout(function () {
that.setData({
isAnimation:false //动画执行完毕
})
callBack() //动画结束回调
}, animDuration + delay) 子组件css移动的动画设置的300ms,这里保持一致
break
}
.......
}
//最后一个之间动画结束,就触回调,这里延迟时间=动画执行时长+最后一个组件移动前的等待时间
if (index == nodes.length - 1 && callBack) {
setTimeout(function () {
that.setData({
isAnimation:false //动画执行完毕
})
callBack() //动画结束回调
}, animDuration + delay) 子组件css移动的动画设置的300ms,这里保持一致
}
}
},
我们在父组件leftSlideGroup中添加一个isAnimation数据用以记录是否正在执行删除动画,然后在开始执行动画的时候设为true,动画结束就设为false。最后我们还得在子组件修改一下还原 的方法。
//leftSlideItem.js
/**
* 还原
*/
restore() {
var isLinked = this.parent.data.isLinked //获取父组件是否联动属性
var isAnimation = this.parent.data.isAnimation //是否正在执行动画
this.setData({
offsetX: isLinked ? 0 : this.data.offsetX, //偏移量
oldOffsetX: isLinked ? 0 : this.data.offsetX, //上一次触摸结束时的偏移量
offsetY: isAnimation ? this.data.offsetY : 0, //向上偏移量
className: isAnimation ? this.data.className :'' //类名
})
this.measure() //重新测量节点信息
},
我们在还原offsetY和className判断一下是否正在执行删除动画,如果正在执行则不允许还原,否则可还原。好了在看效果之前,这里我们把动画执行的时间延长一下方便看问题,我们把leftSlideGroup组件的data数据的animDuration设为3000,表示删除动画执行3秒钟,再来看看效果果。
好了没问题了!那这样删除的时候暴力滑动的问题也就解决了!其实如果再暴力一点,当执行动画的时候,你禁止手指触摸事件就好了,动画执行完成再开放触摸事件,有兴趣可以自己试一下。当然有细心的同学发现,当我在删除动画正在执行时滑开的组件,在动画执行完毕后就会被强制关闭,那这个是问题?我觉得这不是问题,竟然是处于联动状态,那删除后就应当还原其它组件,其实在非联动状态下是没有这个问题的。当然如果你们看着不爽你们可以自己改,反正思路我都告诉你们了,我觉得这样就挺好,我就不改了!
(4)组件设置圆角问题
这个问题之前忘记了,因为之前我们是直接在leftSlideItem.wxss中把圆角也就是border-radius写死的。
//leftSlideItem.wxss
.leftSlideItem{
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
position: relative;
overflow: hidden;
border-radius: 16rpx;
}
我们设置的圆角是16rpx,因为这是我们公司UI给的值,我这里写死没问题,你们就不方便了,所以我这里把它提出来,加一个属性专门用于设置圆角,为什么要专门设置一个属性,实际上 我也是验证过的,如果我直接删除这个圆角属性,让开发者在使用的界面去设置圆角,其实很不方便的,所以这里我把它作为属性提出来,会方便一些。首先leftSlideItem.wxss中的这个border-radius就可以删掉了,移到leftSlideItem.wxml中去。我们加上一个borderradius属性。
//leftSlideItem.js
/**
* 组件的属性列表
*/
properties: {
borderradius:{ //设置组件圆角,和css的border-radius一样,直接填 border-radius的属性值就好。
type: String,
value: '16rpx'
}
},
<!--leftSlideItem.wxml-->
<view class="leftSlideItem {{className}}" style=" border-radius: {{borderradius}}; bottom:{{ offsetY ? offsetY : 0}}px; {{offsetY == 0 ? '' : 'transition: bottom ' + animDuration + 'ms'}}" bindtouchmove="touchmove" bindtouchstart="touchstart" bindtouchend="touchend"
bindtouchcancel="touchend">
<view class="leftView" style="left: {{offsetX}}px; {{isTouch ? '' : 'transition: left 300ms'}} ">
<slot name="leftView"></slot>
</view>
<view class="rightView" style="left: calc(100% + {{offsetX}}px + 5rpx);{{isTouch ? '' : 'transition: left 300ms' }}">
<slot name="rightView"></slot>
</view>
</view>
在leftSlideItem中的style添加属性borderradius的绑定就好了。演示我就不演示,只是设置圆角没啥好演示,照着代码帖,不会有错。
四、总结
这篇博客怕是我写过的最长的博客,啰嗦了一点,不过我也是为了把我写这个组件的真实演变过程分享出来,因为我也是在写这篇博客的时候渐渐发现了一些问题,然后不断改进,也算是组件和博客同步在写!我相信对于初学者,如果能看完这篇文章,肯定会有收获的。如果是大神看完了,不好意思耽误您宝贵的时间了,技术含量真不高,哈哈!其实不管是小程序还是vue,又或是android、ios,这些移动端或者网页,想要自定义一个组件,虽说api、语法或者环境不一样,但是我想实现它的想法和思路其实都是互通的!好了,我不废话了,如果组件还有问题可以留言,能处理的我尽量处理。