先说结论:HarmonyOS6的ArkTS确实解决了这个问题,但方向跟很多人想的不太一样。官方edgeEffect默认是四边统一生效,没法直接关掉某一侧,你要的“只让右侧有渐变、只让底部有回弹”这种事情,需要靠布局层的遮罩或自定义回调解法。这篇文章我会把Grid单边边缘效果的几种实现思路、我踩过的坑、以及最终选型的理由完整摊开来讲。
1. 需求拆解:为什么Grid需要“单边”边缘效果
1.1 先搞清楚你说的“边缘效果”到底是哪一种
在HarmonyOS里,Grid这种可滚动容器,滑到内容边界时通常会有两种反馈:
- 回弹效果:手指继续拖动时,内容会被“拽”出边界一段距离,松手后弹回原位。iOS上那种橡皮筋拉伸感。
- 渐变/渐隐效果:内容接近边界时,边缘部分慢慢变透明,视觉上和屏幕边缘“融合”过去,像被抹掉了一截。
edgeEffect这个API就是控制这两种反馈的。但实际开发里,“边缘效果”这个词范围很广,还有人把网格item滑到末尾时的加载提示、顶部阴影、瀑布流到底部的渐变提示也统称为“边缘效果”。这几种东西的实现路径完全不同,所以先问自己一句:你需要的是滚动物理反馈,还是视觉上的渐变提示?
1.2 单边场景在真实项目里有多普遍
我最早遇到这个需求是在一个商品瀑布流页面。页面结构是“顶部搜索栏 + 下方Grid瀑布流”,搜索栏是固定吸顶的。问题来了:Grid往上滑时,内容会从顶部搜索栏底下穿过去,此时如果Grid自带EdgeEffect.Spring,顶部就会出现回弹拉伸,和吸顶栏叠在一起非常难看。
另一个典型场景是底部Tab页。你希望Grid滑到底部时,内容能有个“到底了”的渐变提示,但底部同时又有一个半透明的操作栏,这时候如果整个Grid在底部触发Fade,会把操作栏后面透出的一截内容也弄成半透明,视觉上很奇怪。
类似需求还包括:
- 横向滚动的分类tab栏,只让左侧出现渐隐,提示“前面还有内容”。
- 带Header吸顶的列表,只让顶部渐变遮罩跟随滚动消失。
- 网格卡片布局,滑到底部需要展示“没有更多了”,但顶部完全不需要边缘反馈。
这些场景的共同点就是:默认的四边统一效果,用起来总是差那么一点意思。
1.3 官方方案在HarmonyOS6上的“够用”与“不够用”
HarmonyOS6的Grid组件,在ArkTS里可以直接这样写:
typescript复制Grid() {
// 内容
}
.edgeEffect(EdgeEffect.Spring)
这行代码一加,上下左右四条边全部生效。EdgeEffect.Fade同理,也是四条边一起渐变。真正让人头疼的是:目前的官方API层面,并没有提供类似edgeEffect({ top: EdgeEffect.None, bottom: EdgeEffect.Spring })这样的参数。也就是说,你没法通过一个开关去关闭某一边。
那是不是就无解了?不是。我后面会分享三种我实际验证过的方案,先说结论:
- 想要平滑连续的单边渐隐效果,用Stack + LinearGradient遮罩最靠谱。
- 想要保留弹簧手感,又只想触发单边回弹,可以用自定义edgeEffect回调去判断边缘类型。
- 只是想让首尾item看起来特殊一点,直接改数据、加样式更简单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 官方edgeEffect的运行机制:先弄懂三个模式,“单边”才有讨论基础
2.1 EdgeEffect三模式对比
官方提供的边缘效果模式,实测下来就这三种:
| 模式 | 效果 | 典型用途 | 四边行为 |
|---|---|---|---|
EdgeEffect.Spring |
拉伸回弹,带阻尼感 | iOS风格下拉回弹 | 四个方向都会生效 |
EdgeEffect.Fade |
边缘渐隐,透明度渐变 | 列表到底的淡出提示 | 滚动方向两端都会生效 |
EdgeEffect.None |
无任何效果 | 分页容器、自绘效果场景 | 全部关闭 |
在HarmonyOS6的API里,设置方式是这样的:
typescript复制// 回弹,且就算没有超过边界也允许触发
.edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
// 渐隐
.edgeEffect(EdgeEffect.Fade)
// 关闭
.edgeEffect(EdgeEffect.None)
2.2 alwaysEnabled这个参数的坑
默认情况下,EdgeEffect.Spring只会在“手指拖动超过边界”时才触发。但如果你在Grid里设置了alwaysEnabled: true,那么即使在边界附近松手,也会有一段惯性回弹动画。这个参数在单边场景里很容易给你添乱——它会让顶部吸顶场景下的回弹感更明显。
我的建议是:如果你要自己控制单边效果,先用EdgeEffect.None把官方默认处理全部关掉,再叠加自己要的效果。否则两边同时起作用,调试的时候你根本分不清哪个是哪个。
2.3 为什么不能直接“只开一边”
有人可能会问:为什么官方不提供一个单边参数?从引擎角度也不难理解。Grid的滚动状态机是一次滚动过程统一管理的,边缘效果的触发条件和滚动位置强相关,引擎很难只对“上边缘”生效而“下边缘”不生效,因为滚动的边界是一个整体。所以官方把它做成了全局策略,单边需求只能在上层用视图手段解决。
明白了这一点,后面三种方案的设计逻辑就顺了:官方不提供单边控制,那我们就绕过它,在视图层自己做。
3. 方案一:Stack + LinearGradient遮罩,最可控的单边渐变
3.1 核心思路
想明白一个点就什么事都好办:渐变边缘效果的本质,就是边缘区域叠了一层“从透明到不透明”的遮罩。
那就不需要依赖edgeEffect了。把Grid放进一个Stack里,在你想显示渐变的那一侧,叠一个半透明渐变层。然后监听Grid的滚动位置,当内容没有到达该侧边缘时,渐变层保持不透明;当内容接近或到达边缘时,降低渐变层透明度甚至直接隐藏。
这样做的好处是:
- 四条边可以完全独立控制。
- 淡入淡出的速度可以自己定义动画曲线。
- 不会影响滚动的手感,因为遮罩层只是视觉元素。
3.2 完整实现代码
下面是我在DevEco Studio 6.0里跑通的版本,纵向双列Grid,只加“底部是否到底”的单边渐隐提示。
typescript复制@Entry
@Component
struct GridSingleEdgeDemo {
private scroller: Scroller = new Scroller()
@State bottomMaskOpacity: number = 1
private totalCount: number = 60
private itemList: number[] = Array.from({ length: this.totalCount }, (_, i) => i)
build() {
Stack({ alignContent: Alignment.Bottom }) {
Grid(this.scroller) {
ForEach(this.itemList, (item: number) => {
GridItem() {
Text(`商品 ${item}`)
.width('100%')
.height(100)
.textAlign(TextAlign.Center)
.fontSize(16)
.backgroundColor(Color.White)
.borderRadius(12)
}
}, (item: number) => item.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.onScrollIndex((first: number, last: number) => {
// 判断是否滑到底部
if (last >= this.totalCount - 1) {
this.bottomMaskOpacity = 0
} else {
this.bottomMaskOpacity = 1
}
})
// 底部渐变遮罩
Column()
.width('100%')
.height(60)
.linearGradient({
colors: [['#00F5F5F5', 0], ['#F5F5F5', 1]]
})
.opacity(this.bottomMaskOpacity)
.animation({ duration: 180, curve: Curve.EaseOut })
.pointerEvents(PointerEvents.None)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
}
3.3 几个值得注意的实现细节
这里我把几个容易出问题的地方单独说一下,这些都是实测后调整过的。
第一,遮罩层的pointerEvents(PointerEvents.None)不能省。 如果不设置,遮罩层会挡在Grid上面,导致手指在底部区域滑动时事件被吞掉,Grid无法滚动。这个属性就是让遮罩层“透明”给触摸事件,只显示视觉内容。
第二,渐变颜色的起止Alpha值必须和页面背景色配合。 我上边写的渐变是#00F5F5F5到#F5F5F5,这俩颜色值取决于Grid容器下一层的背景色。如果你的页面背景是白色,这里就应该是#00FFFFFF到#FFFFFF。不然渐变色和背景色不一致,边缘会有一条很难看的“灰边”。
第三,不要用opacity属性去同时控制渐变色两端的透明度。 如果你既在linearGradient里写了透明端,又在.opacity()里控制整体透明度,会导致渐变的两端被二次叠加透明度,视觉上会出现一个“半透明到底”的效果。通常做法是:渐变本身固定,用.opacity()控制整个图层的显示与隐藏。
3.4 这个方案的边界情况
onScrollIndex的回调参数是当前可见的首尾item索引。用它判断是否到底,存在一个误差:底部最后一行可能没有完全滚出可见区,last就已经等于totalCount - 1了。换句话说,还差一段距离才真正到底时,遮罩就会提前消失。
如果要求更精确,可以用Scroller.currentOffset()去取实际滚动偏移量,结合内容总高度去计算是否到达真实底部。但大多数场景下,基于last索引判断已经够用了。真要求到像素级,可以这样改:
typescript复制.onScrollFrame((offset: OffsetResult) => {
let maxScrollY = this.scroller.currentOffset().yOffset
// 用maxScrollY和Grid的实际内容高度做比较
})
不过onScrollFrame的回调频率很高,高频率去改遮罩透明度,动画做不好会有掉帧风险。我的经验是:优先用onScrollIndex,只有不够用的时候再上onScrollFrame。
4. 方案二:自定义edgeEffect回调里的单边判断
4.1 自定义Effect回调能拿到什么
HarmonyOS6的edgeEffect在设置EdgeEffect.Spring或EdgeEffect.Fade时,支持传入一个自定义回调。虽然它不能直接关闭某一侧效果,但回调参数里携带了当前边缘类型和内容矩形信息,你可以针对不同边缘做差异化处理,比如只对底部边缘执行自定义绘制,顶部直接忽略。
代码结构大致是:
typescript复制Grid() {
// 内容
}
.edgeEffect(EdgeEffect.Spring, {
effect: (effectType: EdgeEffectType, contentRect: Rect, viewport: Rect) => {
// effectType 告诉你当前是哪一边触发了边缘效果
// contentRect 是内容区域
// viewport 是可视区域
}
})
4.2 怎么在回调里识别“当前触发的是哪一边”
effectType这个枚举会区分“顶部边缘效果”“底部边缘效果”“左右边缘效果”等情况。我拿到后可以直接做条件判断:
typescript复制.edgeEffect(EdgeEffect.Spring, {
effect: (effectType: EdgeEffectType, contentRect: Rect, viewport: Rect) => {
if (effectType === EdgeEffectType.Top) {
// 顶部不处理,相当于关掉
return
}
// 其他边缘执行默认回弹
}
})
这个方法我是在一个横向Grid上试的。需求是:横向滚动时,只有右侧触底时可以回弹,左侧往回滚时不需要回弹,因为左侧是一个固定tab起点。实测下来,自定义回调能拿到边缘类型,逻辑上可以做到“按边过滤”。
4.3 这个方案的局限在哪里
说实话,这个方案我最后没有用在线上项目里,原因是可控性还是不够。
首先,自定义回调的触发时机和系统内部状态绑定,不是你想让它触发就触发的。effectType在某些版本下,双向滚动时对上下边缘的判定会受到滚动方向影响,回弹动画已经开始后才进入回调,这时候你关不掉了。
其次,如果用的是EdgeEffect.Fade,你要在回调里自己绘制渐隐效果,相当于自己实现一遍系统的Fade逻辑。系统用了什么采样曲线、什么混合方式,文档里没有完全说清楚,自己画出来的效果和系统手感总是有细微差异。
所以我对这个方案的评价是:适合在系统默认效果“大部分满足、只多了一点点”时,用条件过滤做减法。不适合从零自绘一个完全不同的单边效果。
5. 方案三:数据驱动,首尾GridItem的伪边缘样式
5.1 什么时候该用数据方案
还有一种“伪单边边缘效果”,其实不是滚动边缘效果,而是内容层面的视觉提示。比如:
- 你的目标是提示用户“这里滑到底了”;
- 或者你要在Grid顶部放一个悬浮渐变,随内容滚动消失;
- 又或者你是要做一个横向滚动的卡片轮播,左边卡片露半张,暗示可以左滑。
这种需求直接在数据层做,比滚动效果方案简单得多,也稳定得多。
5.2 一个横向Grid的“右边缘渐隐”示例
比如横向单行Grid,每个卡片宽度120,间距8。要实现“右侧内容渐隐,提示可以继续右滑”,不用任何edgeEffect,直接在第n个item右侧叠一个透明到白色的渐变层,这个渐变层固定存在,不随滚动消失。这样用户看到右侧边缘的卡片“融”进背景色,自然知道后面还有内容。
typescript复制@Entry
@Component
struct HorizontalGridFadeDemo {
private categories: string[] = ['推荐', '数码', '家居', '服饰', '运动', '图书', '美妆', '食品']
build() {
Stack({ alignContent: Alignment.End }) {
Grid() {
ForEach(this.categories, (category: string, index: number) => {
GridItem() {
Text(category)
.width(110)
.height(44)
.textAlign(TextAlign.Center)
.borderRadius(22)
.backgroundColor('#F0F0F0')
}
})
}
.columnsTemplate('110px')
.columnsGap(8)
.scrollable(ScrollDirection.Horizontal)
.edgeEffect(EdgeEffect.None)
// 右侧固定渐变遮罩
Row()
.width(40)
.height('100%')
.linearGradient({
colors: [['#00FFFFFF', 0], ['#FFFFFF', 1]]
})
.pointerEvents(PointerEvents.None)
}
.width('100%')
.height(60)
}
}
这段代码跑起来,右侧始终有一道渐隐。它的优点是不需要监听滚动、不需要动态计算透明度,滚到任何位置都稳定显示。缺点就是它永远都在,不会因为你滚到末尾就消失。如果你希望滚到底后遮罩消失,又得回到方案一去监听滚动位置。
5.3 三个方案怎么选
我把选择依据整理成一个表,方便你按实际场景去套:
| 维度 | 方案一:Stack+遮罩 | 方案二:自定义edgeEffect | 方案三:数据样式 |
|---|---|---|---|
| 实现难度 | 中 | 高 | 低 |
| 是否保留系统弹簧手感 | 否,依赖透明度变化 | 是,但需做条件过滤 | 否,纯视觉 |
| 单边可控性 | 四边完全独立 | 半可控,受系统状态机限制 | 完全是静态的 |
| 性能 | 需要滚动刷新,注意优化 | 跟随系统绘制,基本无感 | 无额外开销 |
| 推荐场景 | 需要精确渐隐/遮罩提示 | 想让系统回弹只触发单边 | Tab横滑提示、底部固定渐隐 |
6. Grid单边效果落地的性能问题和实战避坑
6.1 遮罩层挡住触摸是头号问题
我最初做方案一时,遮罩层忘记加pointerEvents(PointerEvents.None),结果底部两行item怎么也点不到。后来排查发现是遮罩View把触摸事件截胡了。这个问题几乎每个人都会踩一遍,代码里一定要记得写。
6.2 onScrollIndex和onScrollFrame的选择
onScrollIndex只在首尾索引变化时触发,频率很低,用来更新遮罩的显示/隐藏非常合适。而onScrollFrame是每一帧都会回调,如果你在里面做大量的透明度赋值或者属性计算,低端机上Grid滚动会出现肉眼可见的掉帧。
一个额外建议是:不要用@State变量去承载高频变化的透明度值。 @State一旦变化会触发UI刷新,高频刷新就是性能黑洞。如果只是做一次“到底后淡出”,用@State没什么问题;如果是做随滚动距离连续变化的透明度,建议换成普通成员变量,或者用animateTo配合状态一次性过渡,避免每一帧都走一遍整个build。
6.3 真机与模拟器的Fade表现差异
同样的EdgeEffect.Fade,在模拟器上边缘渐隐速度会比较“肉”,真机上则跟手很多。这不是你的代码问题,是模拟器渲染层的GL实现和真机不同。所以测试这类效果时,别用模拟器的效果评判最终结果,多拿两台真机看看。
6.4 折叠屏/平板分栏下的布局偏差
在折叠屏或平板上,Grid可能被放在分栏右侧,宽度不是全屏。这时候你的遮罩渐变宽度如果写死成width(40),视觉上会偏短。更稳妥的做法是给遮罩层加一个width('20%')或者maxWidth(60),并配合.edgeTransition之类的动画属性去平滑显示。我在分栏场景里还用了一个小技巧:让遮罩宽度等于Grid当前可视宽度 * 0.15,这样不管分成几栏,渐变比例看起来都比较一致。
6.5 一个很多人没注意到的“半透明内容重叠”问题
Grid的item如果是半透明背景(比如backgroundColor('rgba(255,255,255,0.6)')),你叠加的渐变遮罩如果也是半透明,两层半透明叠加后会突然变得很“实”,边缘渐变的层级感会瞬间断裂。解决方案是:遮罩尽量放在Grid上层、背景色不要半透明,或者给遮罩加一个blur模糊效果让它不依赖颜色值。直接给遮罩加blur(6)在某些场景下比纯渐变更好用,因为模糊出来的过渡更接近真实“边缘消失”的物理感知。
写在最后的实战感受
如果从头再选一次,我大概率还是会用方案一。Grid单边边缘效果这类需求,与其去猜系统edgeEffect的内部状态机,不如直接在布局层把视觉和触摸彻底分离。遮罩方案虽然要多写几行代码,但胜在逻辑透明、边界可控、未来想改成“两侧出现、一侧消失”也就是加一个Column的事。
最后一个小技巧送给你:在做这种叠加层效果时,把遮罩层的背景色、透明度、高度都抽成常量配置,放在build()外面。后期设计师调细节的时候,你只需要改配置,不用动整个组件逻辑。我因为前期偷懒写死,后来改了三版才彻底抽干净,这块时间省下来,真的能早点下班。
