HarmonyOS ArkTS 实战:阅读器顶部栏"高度收缩 + 背景透明度过渡"(@AnimatableExtend 方案,能直接抄)
需求场景很典型:阅读器页面一滚动,顶部栏从"有点高、信息完整"逐步收缩成"更薄、更沉浸";同时背景从透明慢慢变成不透明(避免内容顶到状态栏后看不清)。
这类动效如果用"手动 onScroll 改样式",很容易出现:跳变、卡顿、代码散。 我这里给你一套更工程化的写法: 用
@AnimatableExtend把"高度"和"背景透明度"都做成可动画参数,你只负责给目标值,系统负责补间过渡。
1. 效果目标(先把体验说清楚)
我们把顶部栏拆成两个维度:
1)高度收缩(Height)
- 初始:比如 64
- 收缩后:比如 44
- 滚动时随进度线性变化(或你自己换曲线)
2)背景透明度(Alpha)
- 初始:0(完全透明,沉浸)
- 收缩后:1(完全不透明,保证可读性)
- 同样随滚动进度变化
同时再加两个"人写出来的细节":
- 标题渐隐 / 渐显:大标题在展开态更明显,收缩后变淡或缩小
- 阴影出现:背景变实后加一点阴影,像真实 App 顶部栏一样"压住内容"
2. 整体结构(别急着写动画,先搭骨架)
我建议阅读器页结构像这样(你之前有 Web 阅读器,照样适用):
sql
Stack(全屏)
├── 内容层(Web / List / Scroll)
└── 顶部栏(Overlay,永远浮在最上)
顶部栏用 Stack/Row 都行,关键是: 它不参与内容布局(不然高度变化会挤压内容,体验很怪)。
3. 核心思路:用 1 个"折叠进度"驱动一切
我们定义一个折叠进度 collapseT:
- 取值范围:
0 ~ 1 0:完全展开1:完全收缩
然后把所有视觉变化都变成数学映射:
height = lerp(expandedH, collapsedH, collapseT)alpha = lerp(0, 1, collapseT)titleOpacity = 1 - collapseT(或者更柔一点)shadowOpacity = collapseT
这样代码会特别干净: 滚动只负责更新 collapseT,其它都是派生出来的。
4. 关键:@AnimatableExtend 两个扩展方法
为什么要用
@AnimatableExtend? 因为你会发现:有些属性你想"丝滑过渡",直接写.height().backgroundColor()并不一定能跟你预期一样顺。 用@AnimatableExtend的思路是: 让参数可动画,参数每帧变化,你每帧把它应用到组件属性上。
4.1 扩展 1:可动画高度
java
@AnimatableExtend(Column)
function animHeight(h: number) {
this.height(h)
}
这里我扩展在
Column上,是因为顶部栏我会用Column/Row/Stack包一层。 你也可以写@AnimatableExtend(Stack)或@AnimatableExtend(Row),看你实际结构。
4.2 扩展 2:可动画背景透明度(用 alpha 控制)
背景透明度最稳的做法: 用 rgba(或 Color + alpha 的方式)生成颜色,把 alpha 作为动画参数。
typescript
@AnimatableExtend(Stack)
function animBgAlpha(alpha: number) {
// alpha: 0~1
// 用 RGBA 生成颜色(下面是白底示例,你可以换成你的主题色)
this.backgroundColor(`rgba(255, 255, 255, ${alpha})`)
}
注意:有的同学会直接在 stateStyles 里写背景,但我们这里是"连续渐变",更适合用 animatable 参数驱动。
5. 完整示例:阅读器页(滚动驱动顶部栏动效)
这里我用
Scroll+Column模拟内容滚动。 你如果是Web,只要把"滚动位置 scrollTop / progress"换成你 Web 上报的值即可(你前面已经做过 onMessage 上报进度,那套能直接接)。
5.1 关键工具函数:lerp + clamp
typescript
function clamp01(x: number): number {
if (x < 0) return 0
if (x > 1) return 1
return x
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
5.2 页面代码(可直接抄,逻辑尽量写得像真实项目)
scss
@Entry
@Component
struct ReaderPage {
// 顶部栏展开/收缩配置(你可以按设计稿改)
private readonly expandedH: number = 64
private readonly collapsedH: number = 44
// 这个是"动画目标值",我们只改它,不直接改 UI
@State private collapseT: number = 0
// 真实滚动高度(示例用 scrollY 近似)
@State private scrollY: number = 0
// 当 scrollY 到达这个阈值时,顶部栏基本收缩完成
private readonly collapseRange: number = 120
build() {
Stack() {
// ========= 内容层 =========
Scroll() {
Column({ space: 12 }) {
// 模拟内容
ForEach(new Array(60).fill(0).map((_, i) => i), (i: number) => {
Text(`第 ${i + 1} 段内容:这里模拟阅读器正文...`)
.fontSize(16)
.fontColor('#222')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
})
}
.padding({ top: this.expandedH + 8 }) // 给内容让出顶部栏空间(很关键!)
}
.onScroll((xOffset: number, yOffset: number) => {
this.scrollY = yOffset
// 把滚动距离映射成 0~1
const t = clamp01(yOffset / this.collapseRange)
// 只更新目标值,让动画系统去补间
// 这里用 animateTo 更"人写",可控性更好
animateTo({ duration: 220, curve: Curve.EaseInOut }, () => {
this.collapseT = t
})
})
// ========= 顶部栏(覆盖层) =========
this.TopBar()
}
.width('100%')
.height('100%')
.backgroundColor('#F6F7F9')
}
@Builder
TopBar() {
// 派生:高度与背景透明度
const h = lerp(this.expandedH, this.collapsedH, this.collapseT)
const bgAlpha = lerp(0.0, 1.0, this.collapseT)
const titleOpacity = lerp(1.0, 0.0, this.collapseT) // 大标题渐隐
const smallTitleOpacity = lerp(0.0, 1.0, this.collapseT) // 小标题渐显
const shadowAlpha = lerp(0.0, 0.12, this.collapseT) // 阴影随收缩出现
Stack() {
// 顶部栏内容
Row() {
Text('返回')
.fontSize(14)
.fontColor('#2F7BFF')
.padding(8)
.onClick(() => {
// TODO: router back
})
Blank()
// 收缩后显示的小标题(更像真实阅读器)
Text('章节标题')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#222')
.opacity(smallTitleOpacity)
Blank()
Text('目录')
.fontSize(14)
.fontColor('#2F7BFF')
.padding(8)
.onClick(() => {
// TODO: open chapter list
})
}
.padding({ left: 12, right: 12 })
.height('100%')
.alignItems(VerticalAlign.Center)
// 展开态的大标题(更沉浸、更像"阅读页顶部信息")
Column() {
Text('书名 / 阅读器')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#111')
.opacity(titleOpacity)
}
.position({ x: 16, y: 10 })
}
// 关键:可动画高度 + 可动画背景透明度
.animHeight(h)
.animBgAlpha(bgAlpha)
// 轻微阴影:背景变实的时候出现一点压住正文的感觉
.shadow({
radius: 12,
color: `rgba(0,0,0,${shadowAlpha})`,
offsetX: 0,
offsetY: 6
})
.width('100%')
.position({ x: 0, y: 0 })
}
}
6. 这段代码里最容易忽略的"真实细节"
6.1 内容顶部 padding 必须给出来
你看到我这里写了:
css
.padding({ top: this.expandedH + 8 })
这是很现实的问题: 顶部栏是 overlay,如果内容不让空间,正文会被盖住,看起来像"顶到状态栏",体验会很差。
你也可以做得更精细:padding 跟着顶部栏高度动态变化。 但一般阅读器里为了稳定,直接用展开高度固定 padding 就够用。