前言

Swiper 一直是鸿蒙应用里非常高频的组件。首页 Banner、引导页、卡片滑动、视频流切换,这些场景都离不开它。问题也很集中,开发者过去能拿到索引变化、动画起止、手势偏移这些信息,但始终缺一层更直接的状态信号。

用户到底是在手指跟着拖,还是已经松手进入惯性动画,还是滑动已经彻底结束,这三个阶段在很多业务里都很关键。API 20 把 onScrollStateChanged 补进来之后,Swiper 这条交互链终于完整了。它从 API version 20 开始可用,回调参数为 ScrollState,专门用来感知滑动状态变化。

这项能力真正有价值的地方,在于它把很多原来只能靠猜测和拼接事件实现的逻辑,变成了标准接口。浮层什么时候隐藏,自动轮播什么时候暂停,懒加载什么时候恢复,埋点什么时候上报,这些都能落到明确的状态点上,代码会顺很多。

一、先把滑动状态这件事看明白

Swiper 在这条能力线里一共给了三个状态。Dragging 表示用户正在跟手拖拽,页面位置随着手指实时变化。Settling 表示用户已经松手,组件正在执行离手动画,可能是继续翻页,也可能是回弹。Idle 表示滑动和动画都已经结束,页面停在稳定状态。这三个状态连起来,刚好覆盖一轮完整的滑动生命周期。

理解这三个状态之后,很多以前不好写的逻辑就清楚了。比如 Banner 上有文案浮层,用户开始拖拽时应该立刻隐藏,那就接 Dragging。用户已经松手,但页面还在惯性移动,这时候浮层仍然不该回来,那就继续看 Settling。等页面彻底停住,再恢复浮层或触发后续交互,那就落到 Idle。这种写法会比单纯依赖 onChange 或动画事件稳定很多。

这里还要分清一个边界。onScrollStateChanged 负责告诉你滑动过程处于哪个阶段,onChange 负责告诉你最终索引有没有变。两者解决的不是同一个问题。用户滑了一下又回弹到原页,onScrollStateChanged 会完整走一轮状态,onChange 可能根本不会触发。做埋点、做资源加载、做浮层控制时,这个区别非常重要。

二、最小可用版本怎么接

最基础的接法其实很简单,Swiper 正常写,状态变化事件挂上去就行。下面这段代码保留了最核心的部分,适合作为项目里的起步版本。

import { hilog } from '@kit.PerformanceAnalysisKit'
import { SwiperController, ScrollState } from '@kit.ArkUI'

@Entry
@Component
struct SwiperStateDemo {
  private swiperController: SwiperController = new SwiperController()
  @State currentState: string = 'Idle'
  @State currentIndex: number = 0

  build() {
    Column() {
      Text(`当前状态: ${this.currentState}`)
        .fontSize(14)
        .margin({ bottom: 12 })

      Swiper(this.swiperController) {
        Text('第1页')
          .width('100%')
          .height(260)
          .backgroundColor('#FF6B6B')
          .textAlign(TextAlign.Center)
          .fontSize(36)

        Text('第2页')
          .width('100%')
          .height(260)
          .backgroundColor('#4ECDC4')
          .textAlign(TextAlign.Center)
          .fontSize(36)

        Text('第3页')
          .width('100%')
          .height(260)
          .backgroundColor('#45B7D1')
          .textAlign(TextAlign.Center)
          .fontSize(36)
      }
      .loop(true)
      .autoPlay(true)
      .interval(3000)
      .onChange((index: number) => {
        this.currentIndex = index
        hilog.info(0x0000, 'SwiperDemo', `当前页索引: ${index}`)
      })
      .onScrollStateChanged((state: ScrollState) => {
        this.currentState = this.getStateName(state)
        hilog.info(0x0000, 'SwiperDemo', `滑动状态变化: ${this.currentState}`)
      })
    }
    .width('100%')
    .padding(20)
  }

  private getStateName(state: ScrollState): string {
    if (state === ScrollState.Dragging) {
      return 'Dragging'
    } else if (state === ScrollState.Settling) {
      return 'Settling'
    }
    return 'Idle'
  }
}

这段代码的重点有两个。第一,状态回调和索引变化分开处理。第二,状态值先做一层映射再显示或打日志,后面扩展更方便。做 Swiper 组件封装时,也建议保留这层转换,不要让业务页面直接堆枚举判断。

三、状态回调接进业务场景

这项能力真正能拉开体验差距的地方,在于它很适合接进高频业务逻辑。最典型的两个场景,一个是浮层控制,一个是懒加载控制。

先看浮层控制。Banner 上的标题、说明、按钮,在用户开始拖拽时应该立即消失,避免遮挡内容。滑动完全结束之后,再延迟一点点恢复,页面会更稳,也不会闪。

import { ScrollState } from '@kit.ArkUI'

@Entry
@Component
struct SwiperOverlayDemo {
  @State showOverlay: boolean = true
  @State overlayOpacity: number = 1
  @State currentIndex: number = 0
  @State restoreTimer: number = -1

  private titles: string[] = ['首页推荐', '热门活动', '新品上架']

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Swiper() {
        ForEach(this.titles, (title: string, index) => {
          Text(title)
            .width('100%')
            .height(320)
            .backgroundColor(index === 0 ? '#FF6B6B' : index === 1 ? '#4ECDC4' : '#45B7D1')
            .textAlign(TextAlign.Center)
            .fontSize(40)
            .fontColor(Color.White)
        })
      }
      .loop(true)
      .onChange((index: number) => {
        this.currentIndex = index
      })
      .onScrollStateChanged((state: ScrollState) => {
        this.handleScrollState(state)
      })

      if (this.showOverlay) {
        Column() {
          Text(this.titles[this.currentIndex])
            .fontSize(24)
            .fontColor(Color.White)

          Text('了解更多 >')
            .fontSize(14)
            .fontColor('#DDDDDD')
        }
        .padding(16)
        .backgroundColor('rgba(0,0,0,0.45)')
        .borderRadius(12)
        .margin({ bottom: 24 })
        .opacity(this.overlayOpacity)
        .transition(TransitionEffect.OPACITY.animation({ duration: 300 }))
      }
    }
    .width('100%')
    .height('100%')
  }

  private handleScrollState(state: ScrollState): void {
    if (state === ScrollState.Dragging) {
      if (this.restoreTimer !== -1) {
        clearTimeout(this.restoreTimer)
        this.restoreTimer = -1
      }
      this.overlayOpacity = 0
    } else if (state === ScrollState.Settling) {
      this.overlayOpacity = 0
    } else if (state === ScrollState.Idle) {
      this.restoreTimer = setTimeout(() => {
        this.overlayOpacity = 1
        this.restoreTimer = -1
      }, 300) as number
    }
  }
}

再看图片懒加载。这个场景特别适合利用状态回调做节流。用户跟手滑动和离手动画期间,先暂停相邻页预加载,等 Swiper 彻底停住,再恢复加载逻辑。这样做能明显减轻滑动中的资源压力。

import { ScrollState } from '@kit.ArkUI'

interface ImageItem {
  title: string
  loaded: boolean
}

@Entry
@Component
struct SwiperLazyLoadDemo {
  @State currentIndex: number = 0
  @State canPreload: boolean = true
  @State imageList: ImageItem[] = [
    { title: '图片1', loaded: true },
    { title: '图片2', loaded: false },
    { title: '图片3', loaded: false },
    { title: '图片4', loaded: false }
  ]

  build() {
    Column() {
      Swiper() {
        ForEach(this.imageList, (item: ImageItem, index) => {
          Stack() {
            if (item.loaded) {
              Text(item.title)
                .width('100%')
                .height(260)
                .backgroundColor('#DCEEFF')
                .textAlign(TextAlign.Center)
                .fontSize(32)
            } else {
              Column() {
                Progress({ value: 30, total: 100 }).width(40)
                Text('加载中...')
                  .fontSize(14)
                  .margin({ top: 8 })
              }
              .width('100%')
              .height(260)
              .backgroundColor('#F5F5F5')
              .justifyContent(FlexAlign.Center)
            }
          }
        })
      }
      .onChange((index: number) => {
        this.currentIndex = index
        this.loadCurrent(index)
      })
      .onScrollStateChanged((state: ScrollState) => {
        if (state === ScrollState.Dragging || state === ScrollState.Settling) {
          this.canPreload = false
        } else if (state === ScrollState.Idle) {
          this.canPreload = true
          this.preloadAdjacent(this.currentIndex)
        }
      })

      Text(`当前页: ${this.currentIndex}`)
        .fontSize(12)
        .margin({ top: 16 })
    }
    .padding(20)
  }

  private loadCurrent(index: number): void {
    if (!this.imageList[index].loaded) {
      this.imageList[index].loaded = true
      this.imageList = [...this.imageList]
    }
  }

  private preloadAdjacent(index: number): void {
    if (!this.canPreload) {
      return
    }

    const targets = [
      (index + 1) % this.imageList.length,
      (index + 2) % this.imageList.length
    ]

    targets.forEach((i) => {
      if (!this.imageList[i].loaded) {
        this.imageList[i].loaded = true
      }
    })

    this.imageList = [...this.imageList]
  }
}

这类写法的价值很实际,用户滑动时页面轻,停下来后资源再补齐,体验会更稳。

四、开发时有几个坑要提前避开

第一个坑是把 Dragging 当成普通低频事件来用。这个状态在用户跟手拖拽时会频繁进入,配套逻辑必须轻。埋点、日志、复杂计算、网络请求这些操作都不适合直接塞进去。更稳的处理方式,是在 Dragging 里只做轻量级状态切换,把真正耗时的逻辑放到 Idle 之后。

第二个坑是把 Settling 当成页面切换成功。这个理解很常见,也很危险。进入 Settling 只说明用户已经松手,Swiper 进入了离手动画阶段,后面可能切到下一页,也可能直接回弹。页面到底有没有切换,还是要看 onChange。所以业务里如果要统计最终停留页、触发页面曝光、做精确埋点,状态回调和索引回调要配合用。

第三个坑是忽略版本兼容。onScrollStateChanged 从 API 20 开始支持,项目如果还要兼容更低版本,最好做一层隔离。新版本直接接状态回调,低版本则退回到 onAnimationStartonAnimationEndonChange 的组合方案。鸿蒙的 API 兼容性机制本来就建议开发者针对版本差异做保护判断,这里也一样。

总结

Swiper 这次补进来的 onScrollStateChanged,解决的是滑动过程里长期缺失的那层状态感知。Dragging 让你知道用户正在跟手拖拽,Settling 让你知道组件已经进入离手动画,Idle 则把最终停止这个时间点明确交了出来。很多以前只能靠经验拼出来的逻辑,现在都可以落到一个标准回调上。

浮层显隐、资源加载、自动轮播控制、埋点采集,这几类业务最适合优先接入。状态回调负责过程感知,onChange 负责结果确认。把这两层职责拆开之后,Swiper 相关的交互代码会顺很多,后面也更容易维护。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐