在鸿蒙应用开发中,下拉刷新是极为常见的交互需求。然而当自定义刷新组件与 List 等滚动容器嵌套时,手势冲突往往令人头疼——要么刷新组件无法下拉,要么上滑时 List 自己滚动,导致整体交互割裂。

本文将从零实现一个无手势冲突、支持自定义头部的下拉刷新组件。系统虽然提供了 Refresh 组件,但下拉刷新的原理依然值得分享,尤其是如何优雅地解决滚动容器的手势竞争问题。下面将详细讲解如何通过 onTouch + enableScrollInteraction 动态控制 List 滚动,实现完美下拉刷新。

最终效果

自定义刷新

一、需求分析

  • 包裹任意滚动容器(List/Grid/Scroll),自动处理手势。
  • 下拉时,刷新头部逐渐露出,带阻尼效果。
  • 达到阈值后显示“松开刷新”,释放后触发刷新。
  • 刷新过程中头部保持可见,完成后回弹。
  • 关键:下拉未松手时上滑,整体组件应跟随手指回退,而不是 List 自己滚动。
  • 支持自定义刷新头(动态文案、加载动画)。
  • 与外部 List 的滚动手势完全隔离。

二、踩坑记录:为什么手势方案频频失败?

最初尝试使用 PanGesture + parallelGesturepriorityGesture,但始终存在两个致命问题:

  1. 上滑时 List 抢走事件List 内部的 PanGesture 优先级高于外层的刷新手势,导致上滑时 List 滚动,而刷新组件无法回退。
  2. 手势判定复杂onGestureJudgeBegin 需要绑定 id、判断手势类型,代码臃肿且易错。

随后尝试 hitTestBehavioronTouchIntercept 动态阻断触摸测试,但 hitTestBehavior 无法动态更新,onTouchInterceptDown 事件时触发,此时无法预知用户是下拉还是上滑,导致动态阻断失效。

最终,我们转向了 onTouch 触摸事件 + 动态控制 ListenableScrollInteraction 方案,从根本上解决了冲突,第二种方案不需要控制enableScrollInteraction只需要处理刷新逻辑即可,但是需要把刷新组件放ListItem中。

三、核心设计思路

3.1 第一种刷新布局结构需要控制(enableScrollInteraction)

Column (外层容器,整体偏移)
├── 刷新头部 (固定高度,独立组件)
└── List (滚动容器,动态控制 enableScrollInteraction)

3.2 第二种刷新布局结构(不需要控制enableScrollInteraction)

Column (外层容器)
└── List 整体偏移
    ├── ListItem (刷新头部,固定高度)
    └── 其他 ListItem (正常内容)

两种方案我都写了,可在工程中查看。

3.3 下面我们按照第一种方案讲

  • .offset({ y: -HEADER_HEIGHT + pullOffset }) 控制整个 Column 的垂直偏移。
  • pullOffset == 0 时,头部完全隐藏(偏移 -60);当 pullOffset 增大时,整体下移,头部逐渐露出。

3.2 手势处理:onTouch 统一接管

  • 在外层 Column 上绑定 .onTouch 回调。
  • onTouchMove 中计算 deltaY,动态更新 pullOffset
  • 关键:当 pullOffset > 0 时,所有移动(包括上滑)都用于调整偏移;当 pullOffset == 0 且滚动容器在顶部且用户下拉时,才开始增加 pullOffset
  • 通过 pullOffset判断 y 值,通过TouchType 获取手势状态。

3.3 动态禁用 List 滚动:enableScrollInteraction

  • List 组件提供 .enableScrollInteraction(bool) 属性,可动态控制其是否响应用户的滚动操作。
  • pullOffset > 0 或刷新中时,设置 enableScrollInteraction(false)List 完全无法滚动,所有触摸由外层 onTouch 处理。
  • pullOffset == 0 且未刷新时,恢复 enableScrollInteraction(true)List 正常滚动。

3.4 双向绑定滚动启用状态

  • RefreshRoot 通过 @Param scrollEnabled 接收外部初始值,通过 @Event onScrollEnabledChange 将内部变化通知外部。
  • 外部 List 使用 .enableScrollInteraction(this.scrollEnabled) 同步状态,实现动态控制。

3.5 阻尼算法

  • 前 60vp 线性(无阻尼),轻拉即见头部。
  • 超出 60vp 后使用平滑曲线:dampedExtra = maxVisualExtra * (extra / (extra + DAMPING_FACTOR)),最大视觉偏移限制为 MAX_PULL_DISTANCE(200vp)。

四、完整代码实现

4.1 状态枚举 RefreshState.ets

/**
 * 刷新组件的状态机
 */
export enum RefreshState {
  /** 空闲,未下拉 */
  Idle,
  /** 下拉中,距离小于阈值 */
  Dragging,
  /** 下拉中,距离超过阈值 */
  OverDragging,
  /** 刷新中 */
  Refreshing,
  /** 刷新完成,等待回弹 */
  Completed
}

4.2 刷新控制器 RefreshController.ets


import { RefreshState } from '../model/RefreshState';
import { RefreshController } from '../controller/RefreshController';
import { DefaultHeader } from './DefaultHeader';

@ComponentV2
export struct RefreshRoot {
  @Require @Param scroller: Scroller;
  @Require @Param controller: RefreshController;
  @BuilderParam headerBuilder?: () => void;
  @Require @BuilderParam contentBuilder: () => void;

  // 外部传入滚动启用状态,内部通过事件通知变化
  @Param scrollEnabled: boolean = true;
  @Event onScrollEnabledChange?: (enabled: boolean) => void;

  @Local private pullOffset: number = 0;
  @Local private state: RefreshState = RefreshState.Idle;
  private lastY: number = 0;
  private isTouching: boolean = false;
  private readonly MAX_PULL_DISTANCE: number = 200;
  private readonly HEADER_HEIGHT: number = 60;
  private readonly DAMPING_FACTOR: number = 140;

  private get threshold(): number {
    return this.HEADER_HEIGHT;
  }

  private applyDamping(offset: number): number {
    if (offset <= 0) return 0;
    const thres = this.threshold;
    if (offset <= thres) return offset;
    const extra = offset - thres;
    const maxVisualExtra = this.MAX_PULL_DISTANCE - thres;
    const dampedExtra = maxVisualExtra * (extra / (extra + this.DAMPING_FACTOR));
    return thres + Math.min(maxVisualExtra, dampedExtra);
  }

  private setScrollEnabled(enabled: boolean): void {
    if (this.scrollEnabled === enabled) return;
    this.onScrollEnabledChange?.(enabled);
  }

  private updateScrollEnabled(): void {
    const shouldEnable = this.pullOffset === 0 && this.state !== RefreshState.Refreshing;
    this.setScrollEnabled(shouldEnable);
  }

  private animateToOffset(targetOffset: number): void {
    this.getUIContext().animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.pullOffset = targetOffset;
      this.updateScrollEnabled();
    });
  }

  aboutToAppear(): void {
    this.controller.onStateChange((newState: RefreshState) => {
      this.state = newState;
      this.updateScrollEnabled();
      if (newState === RefreshState.Refreshing) {
        this.animateToOffset(this.threshold);
      } else if (newState === RefreshState.Completed || newState === RefreshState.Idle) {
        this.animateToOffset(0);
      }
    });
  }

  private onTouchStart(event: TouchEvent): void {
    if (event.touches.length > 0) {
      this.lastY = event.touches[0].y;
      this.isTouching = true;
    }
  }

  private onTouchMove(event: TouchEvent): void {
    if (!this.isTouching) return;
    const currentY = event.touches[0].y;
    const deltaY = currentY - this.lastY;
    this.lastY = currentY;

    if (this.pullOffset > 0) {
      let newOffset = this.pullOffset + deltaY;
      if (newOffset < 0) newOffset = 0;
      if (newOffset > this.MAX_PULL_DISTANCE) newOffset = this.MAX_PULL_DISTANCE;
      this.pullOffset = newOffset;
      this.updateScrollEnabled();

      if (this.state !== RefreshState.Refreshing && this.state !== RefreshState.Completed) {
        let newState: RefreshState;
        if (this.pullOffset >= this.threshold) {
          newState = RefreshState.OverDragging;
        } else if (this.pullOffset > 0) {
          newState = RefreshState.Dragging;
        } else {
          newState = RefreshState.Idle;
        }
        if (newState !== this.state) {
          this.state = newState;
          this.controller.setState(newState);
        }
      }
      return;
    }

    if (deltaY > 0 && this.scroller.currentOffset().yOffset === 0) {
      this.pullOffset = this.applyDamping(deltaY);
      if (this.pullOffset > this.MAX_PULL_DISTANCE) this.pullOffset = this.MAX_PULL_DISTANCE;
      this.updateScrollEnabled();
      if (this.state !== RefreshState.Refreshing && this.state !== RefreshState.Completed) {
        let newState: RefreshState;
        if (this.pullOffset >= this.threshold) {
          newState = RefreshState.OverDragging;
        } else if (this.pullOffset > 0) {
          newState = RefreshState.Dragging;
        } else {
          newState = RefreshState.Idle;
        }
        if (newState !== this.state) {
          this.state = newState;
          this.controller.setState(newState);
        }
      }
      return;
    }
  }

  private onTouchEnd(event: TouchEvent): void {
    this.isTouching = false;
    if (this.state === RefreshState.Refreshing || this.state === RefreshState.Completed) return;
    if (this.pullOffset >= this.threshold) {
      this.controller.startRefresh();
    } else {
      this.animateToOffset(0);
      this.state = RefreshState.Idle;
      this.controller.setState(RefreshState.Idle);
    }
  }

  private onTouchCancel(event: TouchEvent): void {
    this.isTouching = false;
    this.onTouchEnd(event);
  }

  build(): void {
    Column() {
      Column() {
        if (this.headerBuilder) {
          this.headerBuilder();
        } else {
          DefaultHeader({
            state: this.state,
            pullDistance: this.pullOffset,
            threshold: this.threshold
          });
        }
      }
      .width('100%')
      .height(this.HEADER_HEIGHT)

      Column() {
        this.contentBuilder();
      }
      .width('100%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .offset({ y: -this.HEADER_HEIGHT + this.pullOffset })
    .onTouch((event: TouchEvent) => {
      switch (event.type) {
        case TouchType.Down: this.onTouchStart(event); break;
        case TouchType.Move: this.onTouchMove(event); break;
        case TouchType.Up: this.onTouchEnd(event); break;
        case TouchType.Cancel: this.onTouchCancel(event); break;
      }
      return false;
    })
  }
}

4.5 使用示例 Index.ets

import { RefreshRoot } from '../components/RefreshRoot';
import { RefreshController } from '../controller/RefreshController';
import { RefreshState } from '../model/RefreshState';

@Entry
@ComponentV2
struct Index {
  private scroller: Scroller = new Scroller();
  private refreshController: RefreshController = new RefreshController();
  @Local listData: string[] = [];
  @Local isLoading: boolean = false;
  @Local refreshStateText: string = '下拉刷新';
  @Local refreshStateEnum: RefreshState = RefreshState.Idle;
  @Local scrollEnabled: boolean = true;

  aboutToAppear(): void {
    for (let i = 1; i <= 30; i++) this.listData.push(`初始数据项 ${i}`);
    this.refreshController.onStateChange((state) => {
      this.refreshStateEnum = state;
      this.refreshStateText = this.getHeaderText(state);
      if (state === RefreshState.Refreshing && !this.isLoading) this.loadNewData();
    });
  }

  async loadNewData(): Promise<void> {
    this.isLoading = true;
    await new Promise<void>((resolve: () => void) => setTimeout(resolve, 1500));
    const newItems: string[] = [];
    const timestamp = Date.now();
    for (let i = 1; i <= 5; i++) newItems.push(`新数据 ${timestamp} - ${i}`);
    this.listData = [...newItems, ...this.listData];
    this.isLoading = false;
    this.refreshController.finishRefresh();
  }

  private getHeaderText(state: RefreshState): string {
    switch (state) {
      case RefreshState.Refreshing: return '刷新中...';
      case RefreshState.OverDragging: return '松开刷新';
      case RefreshState.Dragging: return '下拉刷新';
      case RefreshState.Completed: return '刷新完成';
      default: return '下拉刷新';
    }
  }

  @LocalBuilder
  refreshHeader(): void {
    Row() {
      if (this.refreshStateEnum === RefreshState.Refreshing) LoadingProgress().width(24).height(24).color('#FF6600');
      Text(this.refreshStateText).fontSize(14).fontColor('#FF6600').margin({ left: this.refreshStateEnum === RefreshState.Refreshing ? 8 : 0 });
    }
    .backgroundColor(Color.Pink).width('100%').height(60).justifyContent(FlexAlign.Center);
  }

  @LocalBuilder
  contentBuilder(): void {
    List({ scroller: this.scroller }) {
      ForEach(this.listData, (item:string,index:number) => {
        ListItem() {
          Text(item).width('100%').height(60).fontSize(16).textAlign(TextAlign.Center)
            .backgroundColor(index % 2 === 0 ? '#FAFAFA' : '#F0F0F0')
            .borderRadius(8).margin({ top: 4, left: 12, right: 12 });
        }
      });
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Auto)
    .enableScrollInteraction(this.scrollEnabled)
  }

  build() {
    Column() {
      Row() { Text("标题") }.width('100%').height(60).justifyContent(FlexAlign.Center).backgroundColor(Color.White).zIndex(99);
      RefreshRoot({
        scroller: this.scroller,
        controller: this.refreshController,
        scrollEnabled: this.scrollEnabled,
        onScrollEnabledChange: (enabled) => { this.scrollEnabled = enabled; },
        headerBuilder: this.refreshHeader,
        contentBuilder: this.contentBuilder
      })
    }
    .width('100%').height('100%')
  }
}

五、下拉刷新总结

整个下拉刷新过程可以概括为以下关键步骤,它们共同构成了流畅、无冲突的用户体验:

5.1 初始状态(Idle)

  • 刷新头部通过 .offset({ y: -HEADER_HEIGHT }) 完全隐藏导航栏下边。
  • ListenableScrollInteractiontrue,用户可以正常滚动列表。

5.2 下拉开始(Dragging → OverDragging)

  1. 用户手指触摸并向下滑动,onTouchMove 被触发。
  2. 组件检测到 pullOffset == 0List 已在顶部(scroller.currentOffset().yOffset === 0)且滑动方向向下(deltaY > 0),开始增加 pullOffset
  3. 阻尼算法介入:
    • 下拉距离 ≤ HEADER_HEIGHT (60vp) 时,pullOffset 线性增加(无阻尼),头部平滑露出。
    • 超过阈值后,pullOffset 增长逐渐放缓(阻尼生效),模拟橡皮筋拉伸感。
  4. pullOffset ≥ HEADER_HEIGHT 时,状态切换为 OverDragging,刷新头显示“松开刷新”。
  5. 同时,通过 setScrollEnabled(false) 禁用 List 的滚动,确保所有触摸事件由外层处理,实现整体下移。

5.3 上滑回退(未松手)

  • 若用户在下拉未松手时改为上滑(deltaY < 0),pullOffset 会随之减小(线性减少),整个内容跟随手指向上移动。
  • List 仍被禁用滚动,因此不会出现列表内容单独滚动的现象,体验跟手自然。
  • pullOffset 回落到 0 时,状态恢复 IdleList 的滚动能力恢复。

5.4 松手触发刷新

  • 手指抬起时,检查当前 pullOffset 是否 ≥ HEADER_HEIGHT
  • 若达到阈值:调用 controller.startRefresh(),状态切换为 Refreshing,并通过动画将 pullOffset 固定为 HEADER_HEIGHT(头部完全露出)。
  • 若未达到阈值:调用 animateToOffset(0)pullOffset 动画归零,头部回弹隐藏,状态回到 Idle

5.5 刷新中与完成

  • 刷新过程中,enableScrollInteraction 保持为 false,用户无法滚动 List,头部始终显示“刷新中…”。
  • 外部数据加载完成后,调用 controller.finishRefresh(),状态变为 Completed 再快速变为 Idle,同时动画将 pullOffset 归零,头部隐藏。
  • 动画结束后恢复 enableScrollInteraction(true)List 恢复滚动能力。

5.6 关键状态机转换

Idle → (下拉) → Dragging → (超过阈值) → OverDragging → (松手且达标) → Refreshing → (加载完成) → Completed → Idle
                ↓ (未达标松手)                                  ↓ (取消)
                Idle                                           Idle

通过 onTouch 统一处理触摸偏移,结合 enableScrollInteraction 动态控制列表滚动,彻底避免了手势冲突,实现了流畅、跟手的下拉刷新体验。该方案结构清晰,可扩展性强,适用于 ListGridScroll 等任意滚动容器。

希望本文能帮助鸿蒙开发者了解下拉刷新的过程,愉快地构建高质量的交互界面。欢迎交流、指正。

Logo

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

更多推荐