鸿蒙ArkTS如何解决自定义下拉刷新组件的手势冲突问题?

摘要:在鸿蒙应用开发中,下拉刷新是极为常见的交互需求。然而当自定义刷新组件与 List 等滚动容器嵌套时,手势冲突往往令人头疼——要么刷新组件无法下拉,要么上滑时 List 自己滚动,导致整体交互割裂。 本文将从零实现一个无手势冲突、支持自定义头
在鸿蒙应用开发中,下拉刷新是极为常见的交互需求。然而当自定义刷新组件与 List 等滚动容器嵌套时,手势冲突往往令人头疼——要么刷新组件无法下拉,要么上滑时 List 自己滚动,导致整体交互割裂。 本文将从零实现一个无手势冲突、支持自定义头部的下拉刷新组件。系统虽然提供了 Refresh 组件,但下拉刷新的原理依然值得分享,尤其是如何优雅地解决滚动容器的手势竞争问题。下面将详细讲解如何通过 onTouch + enableScrollInteraction 动态控制 List 滚动,实现完美下拉刷新。 最终效果 一、需求分析 包裹任意滚动容器(List/Grid/Scroll),自动处理手势。 下拉时,刷新头部逐渐露出,带阻尼效果。 达到阈值后显示“松开刷新”,释放后触发刷新。 刷新过程中头部保持可见,完成后回弹。 关键:下拉未松手时上滑,整体组件应跟随手指回退,而不是 List 自己滚动。 支持自定义刷新头(动态文案、加载动画)。 与外部 List 的滚动手势完全隔离。 二、踩坑记录:为什么手势方案频频失败? 最初尝试使用 PanGesture + parallelGesture 或 priorityGesture,但始终存在两个致命问题: 上滑时 List 抢走事件:List 内部的 PanGesture 优先级高于外层的刷新手势,导致上滑时 List 滚动,而刷新组件无法回退。 手势判定复杂:onGestureJudgeBegin 需要绑定 id、判断手势类型,代码臃肿且易错。 随后尝试 hitTestBehavior 和 onTouchIntercept 动态阻断触摸测试,但 hitTestBehavior 无法动态更新,onTouchIntercept 在 Down 事件时触发,此时无法预知用户是下拉还是上滑,导致动态阻断失效。 最终,我们转向了 onTouch 触摸事件 + 动态控制 List 的 enableScrollInteraction 方案,从根本上解决了冲突,第二种方案不需要控制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 }) 完全隐藏导航栏下边。 List 的 enableScrollInteraction 为 true,用户可以正常滚动列表。 5.2 下拉开始(Dragging → OverDragging) 用户手指触摸并向下滑动,onTouchMove 被触发。 组件检测到 pullOffset == 0、List 已在顶部(scroller.currentOffset().yOffset === 0)且滑动方向向下(deltaY > 0),开始增加 pullOffset。 阻尼算法介入: 下拉距离 ≤ HEADER_HEIGHT (60vp) 时,pullOffset 线性增加(无阻尼),头部平滑露出。 超过阈值后,pullOffset 增长逐渐放缓(阻尼生效),模拟橡皮筋拉伸感。 当 pullOffset ≥ HEADER_HEIGHT 时,状态切换为 OverDragging,刷新头显示“松开刷新”。 同时,通过 setScrollEnabled(false) 禁用 List 的滚动,确保所有触摸事件由外层处理,实现整体下移。 5.3 上滑回退(未松手) 若用户在下拉未松手时改为上滑(deltaY < 0),pullOffset 会随之减小(线性减少),整个内容跟随手指向上移动。 List 仍被禁用滚动,因此不会出现列表内容单独滚动的现象,体验跟手自然。 当 pullOffset 回落到 0 时,状态恢复 Idle,List 的滚动能力恢复。 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 动态控制列表滚动,彻底避免了手势冲突,实现了流畅、跟手的下拉刷新体验。该方案结构清晰,可扩展性强,适用于 List、Grid、Scroll 等任意滚动容器。 工程名称:HappyRefresh 点击下载:HappyRefresh 希望本文能帮助鸿蒙开发者了解下拉刷新的过程,愉快地构建高质量的交互界面。欢迎交流、指正。