发现腾讯视频的首页轮播图组件挺有意思,左右滑动时,两张照片不是Swiper传统的并列显示,而是拼接在一起,通过滑动距离,控制左右照片显示的比例,今天模仿着实现一个。
先看实现效果
实现思路
通过观察,使用Stack布局,将要展示的照片堆叠展示,当左右滑动时,将最上面的照片裁剪,则露出下面的照片,这样就实现了预期效果,因此,需要实现照片裁剪和滑动监听处理照片的显示层级。
实现过程
裁剪照片
使用clipShape接口将组件裁剪为所需的形状。调用该接口后,可以保留该形状覆盖的组件部分,同时移除组件的其余部分。裁剪形状本身是不可见的。
支持裁剪的形状:
使用路径裁剪
由于裁剪的图形不是一个规则的形状,所以这里采用路径裁剪,使用了一个椭圆加直线的图形,类似实现了预期效果。SVG路径描述规范参数:
| |
|---|
| |
| 从当前点到给定的(x, y)坐标画一条线,该坐标成为新的当前点。 |
| 从当前点绘制一条水平线到给定的x坐标,等效于将y坐标指定为当前点y坐标的L命令。 |
| 从当前点绘制一条垂直线到给定的y坐标,等效于将x坐标指定为当前点x坐标的L命令。 |
| 使用(x1, y1)作为曲线起点的控制点,(x2, y2)作为曲线终点的控制点,从当前点到(x, y)绘制三次贝塞尔曲线。 |
| (x2, y2)作为曲线终点的控制点,绘制从当前点到(x, y)绘制三次贝塞尔曲线。若前一个命令是C或S,则起点控制点是上一个命令的终点控制点相对于起点的映射。 |
| 使用(x1, y1)作为控制点,从当前点到(x, y)绘制二次贝塞尔曲线。 |
| 绘制从当前点到(x, y)绘制二次贝塞尔曲线。若前一个命令是Q或T,则控制点是上一个命令的终点控制点相对于起点的映射。 |
| |
| 通过将当前路径连接回当前子路径的初始点来关闭当前子路径。 |
绘制路径效:
new PathShape({ commands:`M -100 0 L${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} 0 A${this.getUIContext().vp2px(this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/8)}0 0 1 ${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/4)} L${this.getUIContext().vp2px(this.imageWidth)} ${this.getUIContext().vp2px(this.imageHeight)} L-100 ${this.getUIContext().vp2px(this.imageHeight)} Z`})
这样就实现了图片的弧形裁剪
通过滑动监听改变裁剪的位置
前面有专门介绍过手势系列,不了解的可以回去看一下前面几篇,这里主要介绍图片处理。图片的排列使用Stack布局,设置zIndex,使其按照顺序层级排列,这里通过滑动改变zIndex的值,实现循环效果。
处理向左滑动
当图片向左滑动时,可以发现,当前显示的图片位于最上层,下一张图片位于下一层,其他图片默认全部位于0最底层。因此将PathShape向左平移手指滑动的距离即可。需要注意的是,裁剪的属性每个图片都有,所以需要判断,只有最上层的一张图片currentIndex才需要裁剪。并且在滑动开始时,设置下一张要展示的图片nextIndex。
Stack(){ Image(item) .borderRadius(5) .objectFit(ImageFit.Cover) .clipShape(new PathShape({ commands: `M -100 0 L${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} 0 A${this.getUIContext().vp2px(this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/8)}0 0 1 ${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/4)} L${this.getUIContext().vp2px(this.imageWidth)} ${this.getUIContext().vp2px(this.imageHeight)} L-100 ${this.getUIContext().vp2px(this.imageHeight)} Z` }).position({x:index==this.currentIndex?this.clipOffsetX:0}))}.width(this.imageWidth).height(this.imageHeight).borderRadius(5).zIndex(index==this.currentIndex?2:index==this.nextIndex?1:0)
this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length
滑动结束后,将裁剪图形移到最左边,并且将右侧即将展示的照片设置为最上层要展示的照片。if (Math.abs(this.moveOffsetX)>200) {this.getUIContext().animateTo({ duration: 300, onFinish:()=>{ // 滑动到距离大于200时,松手继续向左滑动直到不显示,最后切换照片 this.currentIndex=(this.currentIndex-1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.clipOffsetX= 0 }}, () => { this.clipOffsetX= this.maxOffsetClipX});}
处理向右滑动
向右滑动和向左滑动有点区别,当向右滑动时,要将当前显示的照片zIndex设置为1,左侧要显示的照片设置为2,即当前显示的照片为nextIndex,将要显示的照片设置为currentIndex。开始滑动时,切换照片显示层级
this.currentIndex = (this.currentIndex+1+this.imgs.length)%this.imgs.lengththis.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length
滑动结束后,当前显示照片即为最上层照片,因此只需将裁剪图形的偏移设置0即不裁剪效果。onActionEnd(() => { this.isMove=false if (Math.abs(this.moveOffsetX)>200) { //触发切换动画 this.getUIContext().animateTo({ duration: 300, onFinish:()=>{ if (this.clipOffsetX>0) { //向左移到结束后 重新设置图片显示 // 滑动到距离大于200时,松手继续向左滑动直到不显示,最后切换照片 this.currentIndex=(this.currentIndex-1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.clipOffsetX= 0 } } }, () => { if(this.moveOffsetX>0){ //向右移到 this.clipOffsetX=0 }else { this.clipOffsetX= this.maxOffsetClipX } }); }}
处理滑动取消,不触发切换
当滑动距离小于设置的阈值时,不触发切换,只需将裁剪图形偏移归位即可。由于向右滑动时,改变了图片的显示层级,将左侧要显示的照片设置成了currentIndex,因此这里需要再将下一张照片设置成最上层显示的。
if(this.moveOffsetX>0){ //向右移到 this.currentIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.clipOffsetX=0}else { this.clipOffsetX= 0}
处理左滑触发后又触发右滑
当开始向左滑动触发后,又向右滑动更多距离,这时,需要将当前显示的图片设为nextIndex,因此需要记录一下开始滑动方向。
if(!this.rightDirection&&event.offsetX>0){ // 开始向左滑动 变为向右滑动 this.rightDirection = true this.currentIndex = (this.currentIndex+1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length}
处理右滑触发后又触发左滑
同理,当触发相反方向滑动后,需要调整照片显示层级。
if(this.rightDirection&&event.offsetX<0){ this.rightDirection = false this.currentIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length}
完成源码
import { PathShape } from '@kit.ArkUI'import { MyNavigation } from '../utils/MyAttributeModifier'@Entry@ComponentV2struct ClipShapeSwiperTest{ private imgs:Resource[]=[$r('app.media.img_gallery_1'),$r('app.media.img_gallery_4'),$r('app.media.img_gallery_5')] private maxOffsetClipX:number =320+134 @Local imageWidth:number=320 @Local imageHeight:number=200 @Local clipOffsetX:number=0 //裁剪图形偏移 @Local moveOffsetX:number=0 //手指移到偏移 @Local currentIndex:number =this.imgs.length-1 @Local nextIndex:number=this.currentIndex-1 @Local isMove:boolean = false //是否在滑动中 @Local rightDirection:boolean = false // 是否向右滑动 build() { Column(){ Stack(){ ForEach(this.imgs,(item: ResourceStr, index: number)=>{ Stack(){ Image(item) .borderRadius(5) .objectFit(ImageFit.Cover) .clipShape(new PathShape({ commands: `M -100 0 L${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} 0 A${this.getUIContext().vp2px(this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/8)}0 0 1 ${this.getUIContext().vp2px(this.imageWidth+this.imageHeight/2)} ${this.getUIContext().vp2px(this.imageHeight*3/4)} L${this.getUIContext().vp2px(this.imageWidth)} ${this.getUIContext().vp2px(this.imageHeight)} L-100 ${this.getUIContext().vp2px(this.imageHeight)} Z` }).position({x:index==this.currentIndex?this.clipOffsetX:0})) }.width(this.imageWidth).height(this.imageHeight) .borderRadius(5) .zIndex(index==this.currentIndex?2:index==this.nextIndex?1:0) }) }.width('100%').height(this.imageHeight).alignContent(Alignment.Center) .gesture( PanGesture({ direction: PanDirection.Horizontal }) .onActionStart((event: GestureEvent) => { this.isMove=true if (event.offsetX>0) { this.rightDirection = true //开始向右滑动 this.currentIndex = (this.currentIndex+1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length }else { this.rightDirection = false //开始向左滑动 this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length } }) .onActionUpdate((event: GestureEvent) => { this.moveOffsetX = event.offsetX if(event.offsetX>0){ this.clipOffsetX=-this.maxOffsetClipX+event.offsetX }else { this.clipOffsetX=event.offsetX } if(this.rightDirection&&event.offsetX<0){ this.rightDirection = false this.currentIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length } if(!this.rightDirection&&event.offsetX>0){ // 开始向左滑动 变为向右滑动 this.rightDirection = true this.currentIndex = (this.currentIndex+1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length } }) .onActionEnd(() => { this.isMove=false if (Math.abs(this.moveOffsetX)>200) { //触发切换动画 this.getUIContext().animateTo({ duration: 300, onFinish:()=>{ if (this.clipOffsetX>0) { //向左移到结束后 重新设置图片显示 // 滑动到距离大于200时,松手继续向左滑动直到不显示,最后切换照片 this.currentIndex=(this.currentIndex-1+this.imgs.length)%this.imgs.length this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.clipOffsetX= 0 } } }, () => { if(this.moveOffsetX>0){ this.clipOffsetX=0 }else { this.clipOffsetX= this.maxOffsetClipX } }); }else { if(this.moveOffsetX>0){ //向右移到 this.currentIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length this.clipOffsetX=0 }else { this.clipOffsetX= 0 } } }) ) } }}