發(fā)現(xiàn)騰訊視頻的首頁輪播圖組件挺有意思,左右滑動(dòng)時(shí),兩張照片不是Swiper傳統(tǒng)的并列顯示,而是拼接在一起,通過滑動(dòng)距離,控制左右照片顯示的比例,今天模仿著實(shí)現(xiàn)一個(gè)。
先看實(shí)現(xiàn)效果

實(shí)現(xiàn)思路
通過觀察,使用Stack布局,將要展示的照片堆疊展示,當(dāng)左右滑動(dòng)時(shí),將最上面的照片裁剪,則露出下面的照片,這樣就實(shí)現(xiàn)了預(yù)期效果,因此,需要實(shí)現(xiàn)照片裁剪和滑動(dòng)監(jiān)聽處理照片的顯示層級。
實(shí)現(xiàn)過程
裁剪照片
使用clipShape接口將組件裁剪為所需的形狀。調(diào)用該接口后,可以保留該形狀覆蓋的組件部分,同時(shí)移除組件的其余部分。裁剪形狀本身是不可見的。
支持裁剪的形狀:
| 形狀 | 說明 |
|---|---|
| CircleShape | 圓形 |
| EllipseShape | 橢圓 |
| PathShape | 路徑 |
| RectShape | 矩形 |
使用路徑裁剪
由于裁剪的圖形不是一個(gè)規(guī)則的形狀,所以這里采用路徑裁剪,使用了一個(gè)橢圓加直線的圖形,類似實(shí)現(xiàn)了預(yù)期效果。
SVG路徑描述規(guī)范參數(shù):
| 命令 | 說明 |
|---|---|
| M | 在給定的(x, y)坐標(biāo)處開始一個(gè)新的子路徑。 |
| L | 從當(dāng)前點(diǎn)到給定的(x, y)坐標(biāo)畫一條線,該坐標(biāo)成為新的當(dāng)前點(diǎn)。 |
| H | 從當(dāng)前點(diǎn)繪制一條水平線到給定的x坐標(biāo),等效于將y坐標(biāo)指定為當(dāng)前點(diǎn)y坐標(biāo)的L命令。 |
| V | 從當(dāng)前點(diǎn)繪制一條垂直線到給定的y坐標(biāo),等效于將x坐標(biāo)指定為當(dāng)前點(diǎn)x坐標(biāo)的L命令。 |
| C | 使用(x1, y1)作為曲線起點(diǎn)的控制點(diǎn),(x2, y2)作為曲線終點(diǎn)的控制點(diǎn),從當(dāng)前點(diǎn)到(x, y)繪制三次貝塞爾曲線。 |
| S | (x2, y2)作為曲線終點(diǎn)的控制點(diǎn),繪制從當(dāng)前點(diǎn)到(x, y)繪制三次貝塞爾曲線。若前一個(gè)命令是C或S,則起點(diǎn)控制點(diǎn)是上一個(gè)命令的終點(diǎn)控制點(diǎn)相對于起點(diǎn)的映射。 |
| Q | 使用(x1, y1)作為控制點(diǎn),從當(dāng)前點(diǎn)到(x, y)繪制二次貝塞爾曲線。 |
| T | 繪制從當(dāng)前點(diǎn)到(x, y)繪制二次貝塞爾曲線。若前一個(gè)命令是Q或T,則控制點(diǎn)是上一個(gè)命令的終點(diǎn)控制點(diǎn)相對于起點(diǎn)的映射。 |
| A | 從當(dāng)前點(diǎn)到(x, y)繪制一條橢圓弧。 |
| Z | 通過將當(dāng)前路徑連接回當(dāng)前子路徑的初始點(diǎn)來關(guān)閉當(dāng)前子路徑。 |
繪制路徑效:

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`
})
這樣就實(shí)現(xiàn)了圖片的弧形裁剪
通過滑動(dòng)監(jiān)聽改變裁剪的位置
前面有專門介紹過手勢系列,不了解的可以回去看一下前面幾篇,這里主要介紹圖片處理。圖片的排列使用Stack布局,設(shè)置zIndex,使其按照順序?qū)蛹壟帕?,這里通過滑動(dòng)改變zIndex的值,實(shí)現(xiàn)循環(huán)效果。
處理向左滑動(dòng)
當(dāng)圖片向左滑動(dòng)時(shí),可以發(fā)現(xiàn),當(dāng)前顯示的圖片位于最上層,下一張圖片位于下一層,其他圖片默認(rèn)全部位于0最底層。因此將PathShape向左平移手指滑動(dòng)的距離即可。需要注意的是,裁剪的屬性每個(gè)圖片都有,所以需要判斷,只有最上層的一張圖片currentIndex才需要裁剪。并且在滑動(dòng)開始時(shí),設(shè)置下一張要展示的圖片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)
開始滑動(dòng)時(shí),設(shè)置下一張要展示的照片
this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length
滑動(dòng)結(jié)束后,將裁剪圖形移到最左邊,并且將右側(cè)即將展示的照片設(shè)置為最上層要展示的照片。
if (Math.abs(this.moveOffsetX)>200) {
this.getUIContext().animateTo({
duration: 300,
onFinish:()=>{
// 滑動(dòng)到距離大于200時(shí),松手繼續(xù)向左滑動(dòng)直到不顯示,最后切換照片
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
});
}
處理向右滑動(dòng)
向右滑動(dòng)和向左滑動(dòng)有點(diǎn)區(qū)別,當(dāng)向右滑動(dòng)時(shí),要將當(dāng)前顯示的照片zIndex設(shè)置為1,左側(cè)要顯示的照片設(shè)置為2,即當(dāng)前顯示的照片為nextIndex,將要顯示的照片設(shè)置為currentIndex。
開始滑動(dòng)時(shí),切換照片顯示層級
this.currentIndex = (this.currentIndex+1+this.imgs.length)%this.imgs.length
this.nextIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length
滑動(dòng)結(jié)束后,當(dāng)前顯示照片即為最上層照片,因此只需將裁剪圖形的偏移設(shè)置0即不裁剪效果。
onActionEnd(() => {
this.isMove=false
if (Math.abs(this.moveOffsetX)>200) { //觸發(fā)切換動(dòng)畫
this.getUIContext().animateTo({
duration: 300,
onFinish:()=>{
if (this.clipOffsetX>0) { //向左移到結(jié)束后 重新設(shè)置圖片顯示
// 滑動(dòng)到距離大于200時(shí),松手繼續(xù)向左滑動(dòng)直到不顯示,最后切換照片
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
}
});
}
}
處理滑動(dòng)取消,不觸發(fā)切換
當(dāng)滑動(dòng)距離小于設(shè)置的閾值時(shí),不觸發(fā)切換,只需將裁剪圖形偏移歸位即可。由于向右滑動(dòng)時(shí),改變了圖片的顯示層級,將左側(cè)要顯示的照片設(shè)置成了currentIndex,因此這里需要再將下一張照片設(shè)置成最上層顯示的。
if(this.moveOffsetX>0){ //向右移到
this.currentIndex = (this.currentIndex-1+this.imgs.length)%this.imgs.length
this.clipOffsetX=0
}else {
this.clipOffsetX= 0
}
處理左滑觸發(fā)后又觸發(fā)右滑
當(dāng)開始向左滑動(dòng)觸發(fā)后,又向右滑動(dòng)更多距離,這時(shí),需要將當(dāng)前顯示的圖片設(shè)為nextIndex,因此需要記錄一下開始滑動(dòng)方向。
if(!this.rightDirection&&event.offsetX>0){ // 開始向左滑動(dòng) 變?yōu)橄蛴一瑒?dòng)
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
}
處理右滑觸發(fā)后又觸發(fā)左滑
同理,當(dāng)觸發(fā)相反方向滑動(dòng)后,需要調(diào)整照片顯示層級。
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
@ComponentV2
struct 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 //是否在滑動(dòng)中
@Local rightDirection:boolean = false // 是否向右滑動(dòng)
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 //開始向右滑動(dòng)
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 //開始向左滑動(dòng)
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){ // 開始向左滑動(dòng) 變?yōu)橄蛴一瑒?dòng)
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) { //觸發(fā)切換動(dòng)畫
this.getUIContext().animateTo({
duration: 300,
onFinish:()=>{
if (this.clipOffsetX>0) { //向左移到結(jié)束后 重新設(shè)置圖片顯示
// 滑動(dòng)到距離大于200時(shí),松手繼續(xù)向左滑動(dòng)直到不顯示,最后切換照片
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
}
}
})
)
}
}
}