鸿蒙Swiper,切换动效(类似Android ViewPage2 PageTransformer)

1,653 阅读4分钟

一、背景

继上篇之后,继续对项目进行优化,本篇也是关于常见的交互实现,即Swiper左右滑动的伸缩效果

二、目标

如上图所示,在滑动过程中,移走的缩小,移入的放大,同时展示相邻两项的一部分,这在app中也是一种很常见的交互。

三、实现步骤

在开始之前,我们可以先回顾一下原来Android中的实现;步骤也很简单,我们在ViewPage2外层设置可以突破边界,即:android:clipChildren="false", 然后PageTransformer来实现,滑动过程中的的动画;代码如下:

mBinding.vp2SquareChild.setPageTransformer(PetPageTransformer())

使用setPageTransformerViewPage2设置PageTransformer对象

class PetPageTransformer :  ViewPager2.PageTransformer {
    override fun transformPage(page: View, position: Float) {
        page.setPadding(
            page.context.resources.getDimension(R.dimen.d_6).toInt(),
            0,
            page.context.resources.getDimension(R.dimen.d_6).toInt(),
            0
        )
        var scale = 0.0f
        var alpha = 0.0f
        if(position in 0.0f..1.0F) {  //1
            scale = 1.0f - (position * 0.1f)
            alpha = 1.0f - (position * 0.5f)
        }else if (-1.0f <= position && position < 0.0f){   //2
            scale = (position * 0.1f) + 1.0f
            alpha = (position * 0.5f) + 1.0f
        }

        page.scaleY = scale
        page.alpha = alpha
    }
}

PageTransformer内部做两件事:

  • 设置padding

加上前面的android:clipChildren="false" ,实现相邻项的部分展示

  • 根据返回的position做动画处理

position为正数或负数表示相邻项的移动比例,根据这个比例换算出动画执行的属性值,且设置给view即可。

关于Android的实现就完成了,那鸿蒙上实现会不会类似呢,我觉得差不多,哈哈:

1、设置相邻项边距

我们需要相邻项展示部分内容的效果,如下图:

在鸿蒙中这个比较简单,只要给Swiper设置下面的属性即可:

.prevMargin(this.swiperMargin) // 漏出前一项的部分
.nextMargin(this.swiperMargin)// 漏出后一项的部分

2、设置相邻项的缩放动画

我们知道Android中是通过transformPage(page: View, position: Float) 函数回调来处理的,Swiper也有类似的函数,这就是:

 .onGestureSwipe((index, event)

index我们可以理解,指当前展示的项,event是SwiperAnimationEvent:

declare interface SwiperAnimationEvent {
    /**
     * Offset of the current page to the start position of the swiper main axis. The unit is vp.
     *
     * @type { number }
     * @default 0.0 vp
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @since 10
     */
    /**
     * Offset of the current page to the start position of the swiper main axis. The unit is vp.
     *
     * @type { number }
     * @default 0.0 vp
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @crossplatform
     * @atomicservice
     * @since 11
     */
    currentOffset: number;
    /**
     * Offset of the target page to the start position of the swiper main axis. The unit is vp.
     *
     * @type { number }
     * @default 0.0 vp
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @since 10
     */
    /**
     * Offset of the target page to the start position of the swiper main axis. The unit is vp.
     *
     * @type { number }
     * @default 0.0 vp
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @crossplatform
     * @atomicservice
     * @since 11
     */
    targetOffset: number;
    /**
     * Start speed of the page-turning animation. The unit is vp/s.
     *
     * @type { number }
     * @default 0.0 vp/s
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @since 10
     */
    /**
     * Start speed of the page-turning animation. The unit is vp/s.
     *
     * @type { number }
     * @default 0.0 vp/s
     * @syscap SystemCapability.ArkUI.ArkUI.Full
     * @crossplatform
     * @atomicservice
     * @since 11
     */
    velocity: number;
}

有三个参数:

  • currentOffset

Swiper当前显示元素在主轴方向上,相对于Swiper起始位置的位移。单位VP,默认值为0。

  • targetOffset

Swiper动画目标元素在主轴方向上,相对于Swiper起始位置的位移。单位VP,默认值为0。

  • velocity

Swiper离手动画开始时的离手速度。单位VP/S,默认值为0。

但是我想说的是,在使用过程中,我发现只有currentOffset是有值的,其他的都是0,(有没有知道这是为啥)。

既然只有currentOffset有值,那就那这个判断吧。同时还有个情况,因为是命令式编程,Android在transformPage(page: View, position: Float) 的回调中可以直接获取page这个View对象修改,但是鸿蒙是响应式的,所以我们不能直接改,应该是要设置个变量,然后,将变量设置给Component,同时因为是多个Component,所以要建一个Array来存储每个Component的scale数据。

.onGestureSwipe((index, event) => {
  const currentOffset = event.currentOffset;
  const targetOffset = event.targetOffset;
  const velocity = event.velocity;


  console.debug(`currentOffset -> ${currentOffset} `)
  console.debug(`targetOffset -> ${targetOffset} `)
  console.debug(` index -> ${index} `)
  console.debug(` velocity -> ${velocity} `)


  if (currentOffset < 0) {
    if (this.isIndexValid(this.currentSwiperIndex + 1)) {
      const changeScale = (this.minScale + (px2vp(-currentOffset) * 2 / this.displayWidth * 0.4))
      this.cardsScale[this.currentSwiperIndex  + 1] = changeScale > this.maxScale ? this.maxScale : changeScale
    }

    if (this.isIndexValid(this.currentSwiperIndex)) {
      const start = this.cardsScale[this.currentSwiperIndex]
      const changeScale = this.maxScale - (px2vp(-currentOffset) * 2 / this.displayWidth * 0.4)
      this.cardsScale[this.currentSwiperIndex] = changeScale < this.minScale ? this.minScale : changeScale
    }

    if (this.isIndexValid(this.currentSwiperIndex - 1)) {
      this.cardsScale[this.currentSwiperIndex  - 1] = this.minScale;
    }
  } else {
    if (this.isIndexValid(this.currentSwiperIndex - 1)) {
      const changeScale = (this.minScale + (px2vp(currentOffset) * 2 / this.displayWidth * 0.4))
      this.cardsScale[this.currentSwiperIndex  - 1] = changeScale > this.maxScale ? this.maxScale : changeScale
    }

    if (this.isIndexValid(this.currentSwiperIndex)) {
      const start = this.cardsScale[index]
      const changeScale = this.maxScale - (px2vp(currentOffset) * 2 / this.displayWidth * 0.4)
      this.cardsScale[this.currentSwiperIndex] = changeScale < this.minScale ? this.minScale : changeScale
    }

    if (this.isIndexValid(index + 1)) {
      this.cardsScale[index + 1] = this.minScale
    }
  }

})

根据currentOffset的正负值可以判断,当前的滑动反向,通过方向,我们根据index为基准,分别设置前后项的缩放属性值,因为currentOffset是具体的滑动偏移量,不是比例,所以要根据偏移量与屏幕的比例(这个值可以自己调整) 来转换成缩放值。

有时可能上面的转换值会有误差,所以在onChange的回调里面做一个校准:

private maxScale = 1.0
private minScale = 0.9

.onChange((index) => {
  if (this.isIndexValid(index + 1)) {
    this.cardsScale[index + 1] = this.minScale
  }

  if (this.isIndexValid(index)) {
    this.cardsScale[index] = this.maxScale
  }

  if (this.isIndexValid(index - 1)) {
    this.cardsScale[index - 1] = this.minScale;
  }

})

3、其他问题

上面的流程结束后,基本功能就完成了,还有点遗留问题,就是我们在滑动过程中,如果此时没有滑动出一页,手指离开屏幕,Swiper会滚动回去:

我们发现中间变小的卡片在回弹之后,并没有回到之前的缩放比例,是因为在我们的手指弹起后,onGestureSwipe回调不会继续执行了,所以后续的事件没有了;

由于此时没有onGestureSwipe这个事件了,我们不能直接解决这个问题,只能先从侧面做一些修补。根据场景可知,我们需要在手指弹起的时候,做一个校准,相当于前面的onChange回调的内容,于是我们就不用前面的onChange,直接在onTouch回调之后,判断手势为TouchType.Up时,做个延迟调用一下上面onChange的内容即可。

.onTouch((event)=>{
  // 如果手指起来的时候,并没有滑动出一页,则要置回原有的缩放状态
  if(event.type === TouchType.Up){
    setTimeout(()=>{
      if (this.isIndexValid(this.currentSwiperIndex + 1)) {
        this.cardsScale[this.currentSwiperIndex + 1] = this.minScale
      }

      if (this.isIndexValid(this.currentSwiperIndex)) {
        this.cardsScale[this.currentSwiperIndex] = this.maxScale
      }

      if (this.isIndexValid(this.currentSwiperIndex - 1)) {
        this.cardsScale[this.currentSwiperIndex - 1] = this.minScale;
      }
    },200)
  }
})

这里使用setTimeout是因为我们使用 .index($$this.currentSwiperIndex),且事件触发在onTouch中,currentSwiperIndex可能还没有生效,没有生效改的就是旧的,没有意义,还会错乱。

4、完整代码

@Component
export struct CardSwiperView {
  // 卡片数据源
  @Link @Watch('onDataUpdated') data: Array<PetBodyType>
  @BuilderParam itemBody: (item: PetBodyType, index: number) => void;
  // 卡片数据列表
  @State private cardsList: SwiperIteInfo[] = [];
  // 卡缩放度列表
  @State private cardsScale: number[] = [];
  // 屏幕宽度
  private displayWidth: number = 0;
  // Swiper 两侧的偏移量
  private swiperMargin: number = 40;
  // Swiper 当前索引值
  @State private currentSwiperIndex: number = 0;
  private maxScale = 1.0
  private minScale = 0.9

  onDataUpdated(options: Array<PetBodyType>): void {
    this.updateCache()
  }

  aboutToAppear(): void {
    const displayData: display.Display = display.getDefaultDisplaySync();
    this.displayWidth = px2vp(displayData.width);

    this.updateCache()
  }

  private updateCache() {
    this.cardsList = []
    this.cardsScale = []

    this.data.forEach((item, index) => {
      this.cardsList.push(item);
      if (index === this.currentSwiperIndex) {
        this.cardsScale.push(this.maxScale);
      } else {
        this.cardsScale.push(this.minScale);
      }

    })
  }

  build() {
    Column() {
      Swiper() {
        ForEach(this.data, (item: PetBodyType, index: number) => {
          if (this.itemBody) {
            Row() {
              this.itemBody(item, index)
            }.scale({ x: this.cardsScale[index], y: this.cardsScale[index] })
            .animation({
              duration:100
            })

          }
        }, (item: PetBodyType, index: number) => JSON.stringify(item))
      }
      .margin({ top: 20 })
      .loop(true)
      .indicator(false)
      .index($$this.currentSwiperIndex)
      .prevMargin(this.swiperMargin)
      .nextMargin(this.swiperMargin)
      .duration(200)
      .curve(Curve.Friction)
      .onTouch((event)=>{
        // 如果手指起来的时候,并没有滑动出一页,则要置回原有的缩放状态
        if(event.type === TouchType.Up){
          setTimeout(()=>{
            if (this.isIndexValid(this.currentSwiperIndex + 1)) {
              this.cardsScale[this.currentSwiperIndex + 1] = this.minScale
            }

            if (this.isIndexValid(this.currentSwiperIndex)) {
              this.cardsScale[this.currentSwiperIndex] = this.maxScale
            }

            if (this.isIndexValid(this.currentSwiperIndex - 1)) {
              this.cardsScale[this.currentSwiperIndex - 1] = this.minScale;
            }
          },200)
        }
      })
      .onGestureSwipe((index, event) => {
        const currentOffset = event.currentOffset;
        const targetOffset = event.targetOffset;
        const velocity = event.velocity;


        console.debug(`currentOffset -> ${currentOffset} `)
        console.debug(`targetOffset -> ${targetOffset} `)
        console.debug(` index -> ${index} `)
        console.debug(` velocity -> ${velocity} `)


        if (currentOffset < 0) {
          if (this.isIndexValid(this.currentSwiperIndex + 1)) {
            const changeScale = (this.minScale + (px2vp(-currentOffset) * 2 / this.displayWidth * 0.4))
            this.cardsScale[this.currentSwiperIndex  + 1] = changeScale > this.maxScale ? this.maxScale : changeScale
          }

          if (this.isIndexValid(this.currentSwiperIndex)) {
            const start = this.cardsScale[this.currentSwiperIndex]
            const changeScale = this.maxScale - (px2vp(-currentOffset) * 2 / this.displayWidth * 0.4)
            this.cardsScale[this.currentSwiperIndex] = changeScale < this.minScale ? this.minScale : changeScale
          }

          if (this.isIndexValid(this.currentSwiperIndex - 1)) {
            this.cardsScale[this.currentSwiperIndex  - 1] = this.minScale;
          }
        } else {
          if (this.isIndexValid(this.currentSwiperIndex - 1)) {
            const changeScale = (this.minScale + (px2vp(currentOffset) * 2 / this.displayWidth * 0.4))
            this.cardsScale[this.currentSwiperIndex  - 1] = changeScale > this.maxScale ? this.maxScale : changeScale
          }

          if (this.isIndexValid(this.currentSwiperIndex)) {
            const start = this.cardsScale[index]
            const changeScale = this.maxScale - (px2vp(currentOffset) * 2 / this.displayWidth * 0.4)
            this.cardsScale[this.currentSwiperIndex] = changeScale < this.minScale ? this.minScale : changeScale
          }

          if (this.isIndexValid(index + 1)) {
            this.cardsScale[index + 1] = this.minScale
          }
        }

      })
      .onAnimationStart((index, targetIndex) => {

      })
      .height("100%")
      .width("100%")
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
  }

  isIndexValid(index: number): boolean {
    return index >= 0 && index < this.cardsList.length;
  }


}


export interface SwiperIteInfo {
  scale:number
}

export class PetBodyType implements SwiperIteInfo{
   scale: number = 1
   shapeType: string = ''
   shapeTitle: string = ''
   shapeDesc: string = ''
   shapeUrl: string = ''
}

四、总结

这篇只是简单说下Swiper华东的动效实现,如果复杂一点的也可以通过onGestureSwipe来实现,只是比Android麻烦的是需要缓存这些动画属性数据。最后就没有最后了。