前言

Swiper 这类组件,首页 Banner、引导页、卡片切换、内容流滑动都会用到。过去做这类交互时,索引变化可以用 onChange,动画起止可以看 onAnimationStartonAnimationEnd,跟手偏移还能靠 onGestureSwipe 拿到一部分信息。但很多业务真正想要的是另一层能力,用户什么时候开始拖,什么时候已经松手进入惯性动画,什么时候页面彻底停住。API 20 把 onScrollStateChanged 补进来之后,这条链路才算完整了。这个事件从 HarmonyOS 6.0.0(20) 开始支持,回调参数为 ScrollState

这项能力一旦接到业务里,很多原来写得比较别扭的逻辑就能顺下来。浮层显隐、自动轮播暂停与恢复、图片预加载时机、埋点上报时机,都可以挂到明确的状态点上去做,代码会更稳,交互也更自然。

一、先把三种滑动状态看清楚

onScrollStateChanged 返回的是 ScrollState 枚举。这里一共三个值。Dragging 表示用户正在跟手拖拽,页面位置跟着输入持续变化。Settling 表示用户已经松手,Swiper 正在执行离手动画,可能会继续翻页,也可能会回弹。Idle 表示滑动和动画都已经结束,组件停在稳定状态。API 20 的新增声明里已经把这个事件挂在 SwiperAttribute 上,官方示例和博客里也给出了这三个状态的典型语义。

这三个状态连起来,刚好覆盖了一轮完整的滑动过程。Idle 进入 Dragging,说明用户开始主动操作;Dragging 进入 Settling,说明手指已经离开,组件开始处理后续动画;Settling 回到 Idle,说明最终结果已经落定。业务里只要把这条顺序记住,很多时序判断都会清楚很多。

这里还有一个很容易忽略的点,onScrollStateChangedonChange 解决的不是同一个问题。onScrollStateChanged 告诉你滑动过程处在哪个阶段,onChange 告诉你索引是否真的变了。用户轻轻拨一下页面又回弹回来,状态回调会完整触发,索引变化可能根本不会发生。做埋点、资源加载、浮层控制时,这个区别一定要分清。

二、先把最小可用版本接起来

这项能力的接法很直接,Swiper 正常写,状态回调挂上去就行。下面这个例子保留了最核心的部分,适合先在项目里跑通。

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

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

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

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

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

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

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

这段代码里最重要的地方有两个。一个是索引变化和状态变化分开处理,别把两套逻辑混到一个回调里。另一个是状态值先做一层转换再显示或打日志,后面如果项目里要做统一封装,这层写法会很顺。onScrollStateChanged 本身就是 API 20 的新增属性,直接挂在 SwiperAttribute 上。

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

这项能力最适合优先接到两个地方。一个是浮层控制,一个是资源加载控制。它们都对时序很敏感,也最容易受益于这种状态拆分。

先看浮层控制。Banner 上的标题、说明、按钮,在用户开始拖拽时就应该隐藏,避免遮挡内容。离手动画阶段继续保持隐藏,等页面彻底停住后再恢复。这个逻辑用状态回调来写会非常顺。

import { ScrollState } from '@kit.ArkUI';

@Entry
@Component
struct SwiperOverlayDemo {
  @State overlayOpacity: number = 1;
  @State currentIndex: number = 0;
  private restoreTimer: number = -1;
  private titles: string[] = ['首页推荐', '热门活动', '新品上架'];

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Swiper() {
        ForEach(this.titles, (title: string, index: number) => {
          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);
      });

      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;
    }
  }
}

这个写法的好处很实际,拖拽一开始就能立刻响应,离手动画阶段不会误闪,恢复显示也有了明确时机。用 onChange 去做这件事,通常都会慢半拍。

再看图片懒加载。滑动过程中先暂停预加载,等停下来再恢复,这样做很适合图片多、网络紧、设备性能一般的场景。拖拽和惯性动画阶段,用户的注意力本来就在滑动过程里,这时候先把资源压力收住,停下来再补,页面会轻很多。

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) => {
          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 当成翻页成功。它只能说明用户已经松手,Swiper 已经进入离手动画,最终结果还没完全落定。翻页成功还是回弹到原位,要靠 onChange 来判断。做曝光、做索引埋点、做最终状态提交时,这个边界一定要分清。

第三个坑,是忽略版本兼容。onScrollStateChanged 从 API 20 开始支持,如果项目还要兼容更低版本,最好加一层能力判断。新版本直接走状态回调,低版本再退回到 onAnimationStartonAnimationEndonChange 的组合写法。HarmonyOS 的 API 兼容性策略本来就建议按版本做保护,这里也一样。

总结

onScrollStateChanged 这次补进来,Swiper 的交互状态终于有了完整信号。Dragging 负责跟手阶段,Settling 负责离手动画阶段,Idle 负责最终停止阶段。很多以前只能靠多个事件拼出来的逻辑,现在都可以落到一个标准回调上。

项目里更适合优先接这项能力的地方也很明确。浮层显隐、自动轮播暂停与恢复、图片预加载、埋点采集,这几类业务最容易吃到收益。状态回调负责过程,onChange 负责结果,两层职责拆开之后,Swiper 相关的交互代码会顺很多,后面也更容易维护。

Logo

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

更多推荐