完整源码RedNoteApp

一、引言

小红书 App 设计非常细腻,在“我的”页面中,背景图与头像层叠、向上滚动时标题栏渐变毛玻璃、TabBar 自动吸顶、内容区双列瀑布流且支持左右滑动切换。本文将完整分享仿小红书个人主页的实现过程,涵盖三层架构设计状态管理 V2 与 AppStorageV2动态毛玻璃标题栏嵌套滚动自定义 TabBar 与红色指示条WaterFlow + Repeat 高性能懒加载等核心技术点,完整构建出“我的”个人信息页。

二、分层架构与目录

鸿蒙推荐“一次开发,多端部署”的三层架构,本项目严格遵循:

  • 产品定制层(products):应用入口,负责全屏初始化、底部 Tab 导航。不同设备形态可替换。
  • 基础特性层(features):独立业务模块,每个功能打包为 HSP。个人主页属于 profile 模块,预留 homemessagepublish
  • 公共能力层(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/defaultfeatures/profile + commonfeatures/profilecommon,无循环依赖。

三、页面效果预览

  • 沉浸式头部:背景图全铺,头像、昵称、小红书号、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 平滑移动。

如果觉得本文对你有帮助,请点赞、收藏、转发支持!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐