文章目录
-
- 问题一:List 里嵌 Swiper,纵向滚动直接废了
-
- 现象
- 为什么会这样?
- 解决方案:用 onTouch 判断方向,动态控制 Swiper 开关
- 效果
- 问题二:Refresh + List + Swiper 三层嵌套,手势完全卡死
-
- 现象
- 为什么三层嵌套会炸?
- 解决方案:nestedScroll + onTouch 双管齐下
-
- 第一步:给 List 配置 nestedScroll
- 第二步:在 Swiper 层加上方向判断
- 完整页面结构
- 效果
- 踩坑记录
-
- 坑 1:onTouch 的坐标要用 screenX/screenY
- 坑 2:别忘了在 Up/Cancel 时重置状态
- 坑 3:nestedScroll 的方向参数容易搞反
- 坑 4:阈值别设太大也别设太小
- 写在最后
搞了两天,终于把三层手势嵌套的烂摊子收拾干净了。
事情是这样的:我在做一个图片浏览器应用,需求听起来不复杂——一个纵向列表,每个卡片里嵌一个横向 Swiper 做图片轮播,外面还要套一个下拉刷新。三层叠在一起,联调阶段直接炸了:列表滑不动、下拉刷新和滚动互相抢手势、Swiper 翻页也抽风。
这篇文章把我踩过的坑和最终的解决方案全部记录下来,希望帮你少走弯路。
问题一:List 里嵌 Swiper,纵向滚动直接废了
现象
每个 ListItem 里放了一个横向 Swiper 来展示图片。手指在卡片区域上下滑动的时候,列表纹丝不动,只有水平方向才能触发 Swiper 翻页。

这其实是一个经典问题。
为什么会这样?
ArkUI 的手势识别机制是这样的:当多个可滚动组件嵌套时,系统会根据初始触摸方向来决定由哪个组件消费整个手势事件。Swiper 组件虽然设计上是处理水平滑动的,但它内部的手势识别器并不会"主动放弃"纵向方向的触摸事件。
说白了,Swiper 把手指按下的那一刻就"霸占"了触摸事件,不管你是横着划还是竖着划,List 根本拿不到这个手势。
解决方案:用 onTouch 判断方向,动态控制 Swiper 开关
核心思路其实就一句话:手指竖着滑的时候,把 Swiper 关掉,让 List 接管。
@State swiperEnabled: boolean = true
private touchStartX: number = 0
private touchStartY: number = 0
// 判断手指移动方向是否为水平
private isHorizontalMove(offsetX: number, offsetY: number): boolean {
const absX = Math.abs(offsetX)
const absY = Math.abs(offsetY)
return absX > absY && absX > 10 // 10px 阈值,防止误判
}
然后在 Swiper 内部的容器上绑定 onTouch:
Swiper(this.swiperController) {
ForEach(this.imageList, (images: string[]) => {
Column() {
// 每个 Swiper 页面的内容
ForEach(images, (src: string) => {
Image(src).width('100%').height(200).objectFit(ImageFit.Cover)
})
}
.onTouch((event: TouchEvent) => {
switch (event.type) {
case TouchType.Down:
// 记录手指按下的位置
this.touchStartX = event.touches[0].screenX
this.touchStartY = event.touches[0].screenY
break
case TouchType.Move:
if (this.touchStartX !== 0 && this.touchStartY !== 0) {
const offsetX = event.touches[0].screenX – this.touchStartX
const offsetY = event.touches[0].screenY – this.touchStartY
// 竖着滑就关掉 Swiper,横着滑保持开启
this.swiperEnabled = this.isHorizontalMove(offsetX, offsetY)
}
break
case TouchType.Up:
case TouchType.Cancel:
// 手指抬起,恢复 Swiper
this.touchStartX = 0
this.touchStartY = 0
this.swiperEnabled = true
break
}
})
})
}
.enabled(this.swiperEnabled) // 关键:动态控制 Swiper 是否响应手势
.loop(true)
.width('100%')
.height(240)

这里有个细节要注意:absX > 10 这个阈值很重要。如果不加阈值,手指刚按下去时的微小抖动就会导致方向误判。我实测 10px 左右的阈值体验最好,基本不会误判,方向识别也够灵敏。
效果
改完之后,在卡片区域竖着滑就正常触发 List 滚动,横着滑就触发 Swiper 翻页。两者互不干扰。
问题二:Refresh + List + Swiper 三层嵌套,手势完全卡死
现象
解决了 List + Swiper 的问题后,我在最外面又套了一层 Refresh 做下拉刷新。结构变成了这样:
Refresh(下拉刷新)
└── List(纵向滚动)
└── ListItem
└── Swiper(横向翻页)
三层嵌套后,下拉刷新和列表滚动开始打架:有时候列表滚着滚着突然触发下拉刷新,有时候下拉刷新完全没反应,有时候 Swiper 翻页也会误触。三种手势互相抢,整个页面的操作体验完全崩坏。
为什么三层嵌套会炸?
问题出在 ArkUI 的默认嵌套滚动策略上。
系统默认的行为是"子组件优先"——当外层和内层组件都能响应同一个方向的滚动时,子组件的手势优先。这就导致了一个连锁问题:
- List 在顶部继续下拉时,手势本应传递给 Refresh,但被 Swiper 或 List 自己拦截了
- Refresh 下拉触发的阈值和 List 滚动的判断产生了竞争
- 三层的触摸事件传递链路太长,中间任何一层"吃掉"事件都会导致异常
解决方案:nestedScroll + onTouch 双管齐下
这个问题的解决需要两步配合。
第一步:给 List 配置 nestedScroll
nestedScroll 是 ArkUI 提供的嵌套滚动协调机制。它允许子组件在滚动到边界时,把剩余的手势"传递"给父组件。
List({ scroller: this.listScroller }) {
ForEach(this.cardList, (card: CardData) => {
ListItem() {
this.CardComponent(card) // 包含 Swiper 的卡片
}
})
}
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST, // 向下滚:父组件优先
scrollBackward: NestedScrollMode.SELF_FIRST // 向上滚:自己优先
})
.width('100%')
.layoutWeight(1)

这里的关键配置:
- scrollForward: PARENT_FIRST:当列表往下滚动(也就是手指往下拉)时,让父组件(Refresh)优先处理。这样当列表已经在顶部、用户继续下拉时,手势会顺畅地传递给 Refresh 触发刷新。
- scrollBackward: SELF_FIRST:当列表往上滚动(也就是手指上滑浏览更多内容)时,让 List 自己优先处理,避免被父组件抢走。
第二步:在 Swiper 层加上方向判断
光配置 nestedScroll 还不够,因为 Swiper 还是会拦截纵向的触摸事件。所以需要把问题一的方案也加进来,形成完整的三层治理:
@Component
struct ImageCardView {
@Prop imageData: CardData
@State swiperEnabled: boolean = true
private touchStartX: number = 0
private touchStartY: number = 0
build() {
Column() {
Text(this.imageData.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 8 })
Swiper() {
ForEach(this.imageData.images, (src: string) => {
Image(src)
.width('100%')
.height(200)
.objectFit(ImageFit.Cover)
.borderRadius(8)
})
}
.enabled(this.swiperEnabled)
.loop(true)
.height(220)
.onTouch((event: TouchEvent) => {
switch (event.type) {
case TouchType.Down:
this.touchStartX = event.touches[0].screenX
this.touchStartY = event.touches[0].screenY
break
case TouchType.Move:
if (this.touchStartX !== 0 && this.touchStartY !== 0) {
const dx = event.touches[0].screenX – this.touchStartX
const dy = event.touches[0].screenY – this.touchStartY
this.swiperEnabled = Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10
}
break
case TouchType.Up:
case TouchType.Cancel:
this.touchStartX = 0
this.touchStartY = 0
this.swiperEnabled = true
break
}
})
}
.padding(12)
}
}
完整页面结构
把上面的零件组装起来:
@Entry
@Component
struct ImageBrowserPage {
@State isRefreshing: boolean = false
@State cardList: CardData[] = []
private listScroller: Scroller = new Scroller()
build() {
Refresh({ refreshing: $$this.isRefreshing }) {
List({ scroller: this.listScroller }) {
ForEach(this.cardList, (card: CardData) => {
ListItem() {
ImageCardView({ imageData: card })
}
})
}
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
.width('100%')
.layoutWeight(1)
}
.onRefreshing(() => {
// 模拟网络请求
setTimeout(() => {
this.loadData()
this.isRefreshing = false
}, 1500)
})
}
private loadData() {
// 加载数据逻辑
}
}
效果
三层手势各司其职:
- 手指在图片区域横滑 → Swiper 翻页
- 手指在任意区域竖滑 → List 正常滚动
- List 已在顶部时继续下拉 → 触发 Refresh 刷新
终于不打架了。
踩坑记录
坑 1:onTouch 的坐标要用 screenX/screenY
我一开始用 event.touches[0].x 和 event.touches[0].y 来计算偏移量,结果发现数值不太对,方向判断时灵时不灵。后来换成 screenX 和 screenY(相对于屏幕的绝对坐标),一切正常了。
x/y 是相对于组件本身的局部坐标,在嵌套结构中会因为组件的偏移而产生偏差。screenX/screenY 才是稳妥的选择。
坑 2:别忘了在 Up/Cancel 时重置状态
我第一版代码忘了在 TouchType.Cancel 时重置 touchStartX 和 touchStartY。结果在某些场景下(比如滑到一半来了个电话,或者系统弹窗打断了手势),swiperEnabled 会一直卡在 false,Swiper 就再也翻不了页了。
记住:Up 和 Cancel 都要处理,都要重置。
坑 3:nestedScroll 的方向参数容易搞反
scrollForward 和 scrollBackward 这两个参数名挺容易让人困惑的。简单记忆:
- scrollForward = 内容向下移动(手指下拉)
- scrollBackward = 内容向上移动(手指上滑浏览)
配置的时候搞反了会导致下拉刷新更难触发,或者上滑列表时整页跟着动。
坑 4:阈值别设太大也别设太小
方向判断的阈值我试了好几组。设成 5px 太灵敏,手指稍微抖一下就误判;设成 20px 又太迟钝,需要很明显地横着划才能触发 Swiper。最后 10px 是比较平衡的选择,你也可以根据你的 Swiper 尺寸和实际体验微调。
写在最后
ArkUI 的手势系统在简单场景下用起来很省心,但一旦涉及多层嵌套,确实容易让人头疼。
回头看看,其实核心就两个技巧:一是用 onTouch 判断手势方向,动态开关子组件的手势响应;二是用 nestedScroll 协调父子组件之间的滚动传递。把这两个吃透了,大部分手势冲突问题都能解决。 如果你也在做类似的嵌套手势场景,希望这篇文章能帮你省点时间。有问题欢迎评论区交流。



