鸿蒙实战:仿小红书“我”的页面——从分层架构到沉浸式交互
完整源码:RedNoteApp
一、引言
小红书 App 设计非常细腻,在“我的”页面中,背景图与头像层叠、向上滚动时标题栏渐变毛玻璃、TabBar 自动吸顶、内容区双列瀑布流且支持左右滑动切换。本文将完整分享仿小红书个人主页的实现过程,涵盖三层架构设计、状态管理 V2 与 AppStorageV2、动态毛玻璃标题栏、嵌套滚动、自定义 TabBar 与红色指示条、WaterFlow + Repeat 高性能懒加载等核心技术点,完整构建出“我的”个人信息页。
二、分层架构与目录
鸿蒙推荐“一次开发,多端部署”的三层架构,本项目严格遵循:
- 产品定制层(products):应用入口,负责全屏初始化、底部 Tab 导航。不同设备形态可替换。
- 基础特性层(features):独立业务模块,每个功能打包为 HSP。个人主页属于
profile模块,预留home、message、publish。 - 公共能力层(common):全局工具类、常量,无业务逻辑,被所有上层模块依赖。
工程目录
RedNoteApp/
├── products/
│ └── default/ # 手机/平板产品(Entry HAP)
│ ├── src/main/ets/entryability/EntryAbility.ets
│ ├── src/main/ets/pages/MainPage.ets
│ └── oh-package.json5
├── features/ # 基础特性层(HSP)
│ ├── home/ # 首页模块(预留)
│ ├── message/ # 消息模块(预留)
│ ├── publish/ # 发布模块(预留)
│ └── profile/ # 个人主页模块
│ ├── src/main/ets/
│ │ ├── pages/ProfilePage.ets
│ │ ├── components/
│ │ │ ├── ProfileHeader.ets
│ │ │ └── NoteWaterfallCard.ets
│ │ ├── model/
│ │ │ ├── UserModel.ets
│ │ │ └── NoteItem.ets
│ ├── index.ets # 模块导出入口(位于根目录)
│ └── oh-package.json5
├── common/ # 公共能力层(HSP)
│ ├── src/main/ets/
│ │ ├── constants/AppConstants.ets
│ │ ├── utils/WindowUtil.ets
│ │ ├── model/SafeAreaState.ets
│ ├── index.ets # 模块导出入口
│ └── oh-package.json5
└── oh-package.json5
依赖关系:products/default → features/profile + common,features/profile → common,无循环依赖。
三、页面效果预览
- 沉浸式头部:背景图全铺,头像、昵称、小红书号、IP属地、简介、标签等悬浮在渐变遮罩上。
- 动态毛玻璃标题栏:向上滚动时,标题栏从透明渐变为半透白并带模糊;状态栏文字由白变黑。
- TabBar 自动吸顶:头部滚出视口后,“笔记/收藏/赞过”TabBar 固定在标题栏下方。
- 红色指示条平滑移动:点击 Tab 或左右滑动时,指示条动画跟随。
- 双列瀑布流:每个 Tab 独立
WaterFlow,支持嵌套滚动。

四、核心技术点一:动态毛玻璃标题栏
4.1 布局与滚动监听
使用 Stack 将悬浮标题栏与 Scroll 叠加,并通过 onDidScroll 累计滚动偏移量:
@ComponentV2
export struct ProfilePage {
@Local safeArea: SafeAreaState = AppStorageV2.connect<SafeAreaState>(SafeAreaState, () => new SafeAreaState())!;
@Local titleBarHeight: number = 0;
@Local titleBarOpacity: number = 0;
@Local headerHeight: number = 0;
@Local isSticky: boolean = false;
private scrollYOffset: number = 0;
build() {
Stack() {
this.NavigationTitleBar();
Scroll(this.scroller) {
Column() {
ProfileHeader({
user: this.user,
titleBarHeight: this.titleBarHeight,
headerHeight: this.headerHeight,
onHeaderHeightChange: (height: number) => {
this.headerHeight = height;
}
})
if (!this.isSticky) {
this.CustomTabBar();
}
Tabs({
controller: this.tabController,
index: this.currentTabIndex
}) {
ForEach(this.tabTitles, (_: string, index: number) => {
TabContent() {
WaterFlow({
layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW,
scroller: this.waterFlowScrollers[index]
}) {
Repeat<NoteItem>(this.currentNoteList)
.each((ri: RepeatItem<NoteItem>) => {
FlowItem() {
NoteWaterfallCard({ item: ri.item })
}
.width('100%')
})
.key((item: NoteItem) => item.id)
.virtualScroll({ totalCount: this.currentNoteList.length })
}
.columnsTemplate('1fr 1fr')
.columnsGap(this.columnsGap)
.rowsGap(this.rowsGap)
.width('100%')
.padding(this.paddingValue)
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
}
});
}
.barHeight(0)
.scrollable(true)
.vertical(false)
.animationDuration(0)
.onChange((index: number) => {
this.currentTabIndex = index;
this.currentNoteList = this.getDataByIndex(index);
this.getUIContext().animateTo({ duration: 200 }, () => {
this.bgTransX = this.getIndicatorOffset(index);
});
});
}
.width('100%');
}
.scrollBar(BarState.Off)
.onDidScroll((_, yOffset) => {
this.scrollYOffset += yOffset;
this.scrollYOffset = Math.max(0, this.scrollYOffset);
let opacity = this.scrollYOffset / this.titleBarHeight;
this.titleBarOpacity = Math.min(1, Math.max(0, opacity));
const threshold = this.headerHeight;
this.isSticky = this.scrollYOffset >= threshold;
});
if (this.isSticky) {
Row() {
this.CustomTabBar();
}
.position({ top: this.titleBarHeight })
.zIndex(2);
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5');
}
}
4.2 标题栏样式动态变化
标题栏的模糊半径、背景色随 titleBarOpacity 线性变化:
@Builder
NavigationTitleBar() {
Row() {
Image($r('app.media.navigation_menu'))
.width(30)
.height(30)
.objectFit(ImageFit.Contain);
Row({ space: 16 }) {
Image($r('app.media.scan')).width(30).height(30).objectFit(ImageFit.Contain);
Image($r('app.media.share')).width(26).height(26).objectFit(ImageFit.Contain);
}
}
.width('100%')
.padding({ top: 16 + this.safeArea.statusBarHeightVp, left: 16, right: 16, bottom: 16 })
.backdropBlur(this.titleBarOpacity * 10)
.backgroundBlurStyle(this.titleBarOpacity > 0.5 ? BlurStyle.Thin : BlurStyle.NONE)
.position({ top: 0 })
.backgroundColor(`rgba(0,0,0,${this.titleBarOpacity > 0.5 ? 0.2 : 0.0})`)
.zIndex(999)
.onAreaChange((_, newValue: Area) => {
this.titleBarHeight = newValue.height as number;
})
.justifyContent(FlexAlign.SpaceBetween);
}
backdropBlur:只模糊背后内容,文字清晰。backgroundBlurStyle:超过阈值时启用系统毛玻璃。backgroundColor:超过阈值时添加半透黑底,增强质感。
4.3 状态栏颜色联动(防抖)
使用 @Monitor 监听 titleBarOpacity 变化,自动更新状态栏(带 50ms 防抖):
@Monitor('titleBarOpacity')
onTitleBarOpacityChange() {
this.updateStatusBarAppearance();
}
private async updateStatusBarAppearance() {
if (this.updateStatusBarTimer !== -1) clearTimeout(this.updateStatusBarTimer);
this.updateStatusBarTimer = setTimeout(async () => {
const textColor = this.titleBarOpacity >= 1 ? '#000000' : '#FFFFFF';
const bgColor = `rgba(255, 255, 255, ${this.titleBarOpacity})`;
await WindowUtil.setStatusBarStyle(bgColor, textColor);
this.updateStatusBarTimer = -1;
}, 50);
}
五、核心技术点二:完美嵌套滚动
5.1 需求分析
期望滚动行为:
- 向上滑动:外层
Scroll先滚动(让头部消失、TabBar 吸顶),滚到底后再滚动内层WaterFlow。 - 向下滑动:内层
WaterFlow先滚动,到顶后再滚动外层Scroll。
5.2 nestedScroll 配置
在 WaterFlow 上配置 nestedScroll,实现双向优先级控制:
WaterFlow({ layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW, scroller: this.waterFlowScrollers[index] }) {
Repeat<NoteItem>(this.currentNoteList)
.each((ri: RepeatItem<NoteItem>) => {
FlowItem() {
NoteWaterfallCard({ item: ri.item })
}
.width('100%')
})
.key((item: NoteItem) => item.id)
.virtualScroll({ totalCount: this.currentNoteList.length })
}
.columnsTemplate('1fr 1fr')
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST, // 向上滚动:父组件优先
scrollBackward: NestedScrollMode.SELF_FIRST // 向下滚动:子组件优先
})
5.3 独立滚动位置
每个 Tab 使用独立的 Scroller,切换 Tab 时滚动位置自动保持:
private waterFlowScrollers: Scroller[] = [new Scroller(), new Scroller(), new Scroller()];
六、核心技术点三:自定义 TabBar 与红色指示条
自定义 TabBar 包含固定宽度文字和红色指示条。为了让指示条始终位于当前选中文字的正下方(水平居中于按钮,垂直固定在底部),需要动态计算水平偏移量。
private getIndicatorOffset(index: number) {
return index * this.barWidth + (this.barWidth - this.indicatorWidth) / 2;
}
计算公式说明:
index:当前选中的 Tab 索引(0、1、2)。barWidth:每个 Tab 按钮的固定宽度(70vp)。indicatorWidth:红色指示条的固定宽度(40vp)。
实现原理:
- 每个按钮左边缘为
index * barWidth,中心点为左边缘 +barWidth/2。 - 指示条垂直方向通过
position({ bottom: 0 })固定在按钮底部。 - 水平方向要使指示条中心与按钮中心对齐,则指示条左边缘 = 按钮中心 -
indicatorWidth/2。 - 整理得:
index * barWidth + (barWidth - indicatorWidth) / 2。
完整 TabBar 构建器:
@Builder
CustomTabBar() {
Row() {
Stack() {
Row() {
ForEach(this.tabTitles, (title: string, index: number) => {
Text(title)
.width(this.barWidth)
.height(this.barHeight)
.onClick(() => {
this.getUIContext().animateTo({ duration: 200 }, () => {
this.bgTransX = this.getIndicatorOffset(index);
});
this.tabController.changeIndex(index);
this.currentTabIndex = index;
this.currentNoteList = this.getDataByIndex(index);
})
})
}
Line()
.width(this.indicatorWidth)
.height(2)
.backgroundColor('#FF5A5F')
.translate({ x: this.bgTransX })
.position({ bottom: 0 })
}
.width(this.tabTitles.length * this.barWidth);
}
.width('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.White)
}
Tabs 组件隐藏系统 bar,在 onChange 中同步指示条位置:
Tabs(...)
.barHeight(0)
.scrollable(true)
.onChange((index) => {
this.currentTabIndex = index;
this.currentNoteList = this.getDataByIndex(index);
this.getUIContext().animateTo({ duration: 200 }, () => {
this.bgTransX = this.getIndicatorOffset(index);
});
})
七、公共窗口工具类与安全区域适配
common 模块中的 WindowUtil 封装了全屏初始化、状态栏控制、安全区域获取,并同步更新全局 SafeAreaState(使用 AppStorageV2)。
// SafeAreaState 定义
@ObservedV2
export class SafeAreaState {
@Trace statusBarHeightVp: number = 0;
@Trace navigationBarHeightVp: number = 0;
@Trace keyboardHeight: number = 0;
}
// WindowUtil 关键方法
static async initWindowAndStore(windowStage: window.WindowStage): Promise<void> {
const mainWindow = windowStage.getMainWindowSync();
WindowUtil.setMainWindow(mainWindow);
await WindowUtil.setFullScreen(true, mainWindow);
await WindowUtil.setStatusBarStyle('#00000000', '#000000');
const densityScale = display.getDefaultDisplaySync().densityDPI / 160;
const statusBarPx = WindowUtil.getStatusBarHeightPx();
const navPx = WindowUtil.getNavigationIndicatorHeightPx();
const safeArea = AppStorageV2.connect<SafeAreaState>(SafeAreaState, () => new SafeAreaState())!;
safeArea.statusBarHeightVp = statusBarPx / densityScale;
safeArea.navigationBarHeightVp = navPx / densityScale;
}
页面中通过 @Local safeArea 获取全局单例:
@Local safeArea: SafeAreaState = AppStorageV2.connect<SafeAreaState>(
SafeAreaState, () => new SafeAreaState()
)!;
八、头部组件与瀑布流卡片
8.1 头部组件 ProfileHeader
使用 Stack 叠加背景图和内容区,底部渐变提高文字可读性,高度回传给父组件用于吸顶判断。
@ComponentV2
export struct ProfileHeader {
@Param @Require user: UserProfile;
@Param @Require titleBarHeight: number;
@Param @Require headerHeight: number;
@Event onHeaderHeightChange?: (height: number) => void;
build() {
Stack({ alignContent: Alignment.Bottom }) {
Image(this.user.bgImage).width('100%').height('100%').objectFit(ImageFit.Cover);
Column() {
// 头像、昵称、小红书号、简介、标签、统计数据、操作按钮(完整代码见仓库)
}
.linearGradient({
direction: GradientDirection.Bottom,
colors: [[Color.Transparent, 0], [Color.Black, 0.9]]
})
.onAreaChange((_, newArea) => {
if (newArea.height > 0) this.onHeaderHeightChange?.(newArea.height as number);
})
}
.width('100%')
.height(this.headerHeight + this.titleBarHeight)
}
}
8.2 瀑布流卡片 NoteWaterfallCard
@Component
export struct NoteWaterfallCard {
@Prop item: NoteItem;
build() {
Column() {
Image(this.item.cover).width('100%').height(this.item.height).objectFit(ImageFit.Cover);
Column() {
Text(this.item.title).maxLines(2);
Row() {
Image(this.item.avatar).width(20).height(20).borderRadius(10);
Text(this.item.author);
Image(this.item.isLiked ? $r('app.media.like') : $r('app.media.unlike'));
Text(`${this.item.likes}`);
}
}
}
.shadow({ radius: 4 })
}
}
九、踩坑与解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
WaterFlow 设置 columnsTemplate 不生效 |
未设置 FlowItem 宽度 100% |
必须设置 .width('100%') |
| 外层滚动不生效 | WaterFlow 设置了 .height('100%') |
WaterFlow 不设置固定高度,由内容自适应 |
| Tab 切换后滚动位置丢失 | 所有 Tab 共用同一个 Scroller |
为每个 Tab 创建独立 Scroller |
| 状态栏颜色变化生硬 | 滚动回调中频繁调用窗口 API | 增加 50ms 防抖 + @Monitor |
| 吸顶 TabBar 位置不对 | 未考虑状态栏高度 | 通过 @Local safeArea.statusBarHeightVp 动态计算 top |
十、总结
本文完整实现了小红书“我”的页面,重点解决了嵌套滚动、毛玻璃标题栏和自定义 TabBar 吸顶三大核心交互。整体核心经验:
- 三层架构:模块解耦,代码可复用,支持多端部署。
- 毛玻璃标题栏:
backdropBlur+ 滚动偏移量,配合状态栏联动。 - 嵌套滚动:
nestedScroll配置PARENT_FIRST/SELF_FIRST,简单高效。 - 自定义指示条:精确计算偏移量,结合
animateTo平滑移动。
如果觉得本文对你有帮助,请点赞、收藏、转发支持!
更多推荐

所有评论(0)