鸿蒙 ArkTS 自定义下拉刷新组件:从手势冲突到完美解决方案
本文介绍了一种在鸿蒙应用中实现无冲突下拉刷新的方案。针对自定义刷新组件与滚动容器的手势冲突问题,提出两种布局结构:一种通过动态控制enableScrollInteraction属性管理滚动行为,另一种将刷新组件嵌入ListItem中。重点分析了第一种方案的核心设计思路,包括使用onTouch统一接管手势、动态禁用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
希望本文能帮助鸿蒙开发者了解下拉刷新的过程,愉快地构建高质量的交互界面。欢迎交流、指正。
更多推荐



所有评论(0)