鸿蒙-NestedScrollTabContainer-mvvm-demo
2026/7/22 2:34:29 网站建设 项目流程

之前学了安卓版的吸顶嵌套可左右翻页上下滑动的rv:

安卓demo-CeilingNestedScrollView-CSDN博客之后,下一个需求就是做鸿蒙版的这个需求,然后鸿蒙也没有这种组件,凭借俺对安卓的理解,也很快promot出了鸿蒙版,哈哈哈哈,真是机智如俺,不过这个没有tabbar吸顶效果哈。之前很少用mvvm,结果现在项目全是mvvm。嗯,用mvvm重构一下吧,再封装成单个组件(人模狗样版)

组件封装

1.背景

NestedScrollTabContainer 是一个可复用的"嵌套滑动 Tab 容器",解决 Scroll + Swiper + List 三层嵌套下的滑动分发问题。 典型场景:顶部 Header 区域 + 多 Tab 内列表区,手指上滑先滚走 Header 再滚列表;手指下滑先把内层列表滚到顶,再让外层 Scroll 把 Header 显示出来。

2.划分原则

共性、稳定的布局进容器;业务差异大的留外面。

封装时问自己三句话:

  1. 不同页面这段代码会一样吗?一样 → 进容器;不一样 → 留外面。

  2. 这段代码跟业务数据有关吗?无关 → 进容器;有关系 → 留外面。

  3. 这段代码要不要被外部主动触发?要(如 scrollTo / changeIndex)→ 不放在容器方法里,改用控制器由外部持有。

3.进容器(共性)

以下内容由容器内部 build() 承担,调用方无需关心:

维度内容
外层滚动Scroll(this.parentScroller) { Column() { ... } }
Swiper 通用配置indicator(false) / loop(false) / width('100%') / height('100%')
内层 Swiper 与控制器绑定Swiper(this.swiperController)
滑动分发方向nestedScroll({ scrollForward: SELF_ONLY, scrollBackward: SELF_ONLY })
多层嵌套手势分发ScrollForward 先消费外层 → 再内层;ScrollBackward 先内层 → 再外层
第一Tab右滑退出手势监听onGestureSwipe:第一个Tab且向右滑动时触发回调
防重复退出标记private isPopping,节流 onFirstTabSwipeOut
背景样式支持 bgColor 纯色 或 useLinearGradient 渐变(二选一)
4.滑动冲突解决方式

容器内部存在两层嵌套滚动:外层 Scroll(包裹 Header + Swiper)与内层 List(每个 Tab 的列表)。 关键手法是用 ArkUI 的 nestedScroll 配置 + 外层 Scroll 采用 SELF_ONLY,把滑动事件链路拆解成可预期的优先级:

手指上滑(scrollForward,内容向上滚): 外层 Scroll 先消费 → Header 被滚走 → 然后内层 List 接管继续滚 内层 List 配置:nestedScroll({ scrollForward: PARENT_FIRST, scrollBackward: SELF_FIRST })

手指下滑(scrollBackward,内容向下滚): 内层 List 先消费 → 列表滚到顶 → 然后外层 Scroll 接管 → Header 显示出来

外层 Scroll 用 SELF_ONLY 的用意: 确保外层与内层的 nestedScroll 链路独立可控,避免外层"无脑"拦截事件导致内层 List 在 Header 还没滚走时就已经开始滚动。

Tab 间横向切换: 由 Swiper 自身处理,不参与纵向滑动分发;onGestureSwipe 监听第一个 Tab 的右滑手势,触发 onFirstTabSwipeOut 回调(通常调 viewModel.popPage)。

5.留外面(业务差异大)

以下内容由调用方自行实现,通过 builder / callback 注入:

维度注入方式说明
Header 布局@BuilderParam headerBuilder每个页面 Header 结构差异大,不强行参数化,调用方写 @Builder 直接嵌入
Tab 内容(每个Tab的List结构)@BuilderParam contentBuilder每页 Item 渲染逻辑完全不同,调用方根据 currentTabIndex 渲染
Tab 切换的业务回调onTabChange?: (index) => void例如切换Tab后刷新对应列表、切换tabType 维度等
当前Tab索引来源@Prop currentTabIndex由调用方 @State 维护,并通过 onTabChange 同步
第一Tab右滑退出动作onFirstTabSwipeOut?: () => void通常调用 viewModel.popPage()
内层 List 的 Scroller 数组Page 自己持有 Scroller[]Page 在 tabContentBuilder 里按 tab.index 选用对应 Scroller 绑定到 List
6.控制器传递约定

ArkTS 中 @Component 不能被外部 new 实例化,外部无法直接持有容器引用来调方法。 因此改为 "外部持有控制器,传给容器使用" 的反向依赖:

控制器声明方用途
SwiperController外部 Page外部点击 Tab 时调 changeIndex(index) 切换;容器内部用同一实例绑定 Swiper
Scroller(外层)外部 Page外部 scrollToTop() 等场景调用 scrollTo(...);容器内部用同一实例绑定 Scroll
Scroller(每个Tab)外部 Page外部维护一个 Scroller[],按当前 tabIndex 选对应实例调用

俺写大需求终于是先做架构设计再写代码了,而不是直接一上来就代码的暴发户土狗了。

架构简述

1.分层结构
nestedscroll/ ├── pages/ │ └── NestedScrollDemoPage.ets ← View 层(ArkTS struct @Component) ├── viewmodel/ │ ├── INestedScrollViewModel.ets ← ViewModel 接口 + Callback 接口 │ └── NestedScrollViewModelImpl.ets ← ViewModel 实现(@ObservedV2 + @Trace) ├── model/ │ ├── INestedScrollModel.ets ← Model 接口 │ ├── NestedScrollModelImpl.ets ← Model 实现(mock Tab 数据) │ └── NestedScrollTabConfig.ets ← DTO └── components/ └── NestedScrollTabContainer.ets ← 业务专用 UI 组件
2.通信方式

单向数据流 + 双向控制流

┌────────────────┐ setCallback(callback) ┌──────────────────┐ │ │ ─────────────────────────────────► │ │ │ View (Page) │ │ ViewModel │ │ │ ◄───────────────────────────────── │ (@ObservedV2) │ └────────────────┘ @Trace 字段变化触发 UI 重渲染 └──────────────────┘ │ ▲ │ │ 用户事件 │ Promise resolve/reject │ 调用 │ onTabClick/onSwiper │ ▼ │ TabChange │ ┌──────────────────┐ └────────────────────────┼──────────────────────►│ Model │ │ │ (mock 数据) │ │ └──────────────────┘ │ callback.popPage / showLoading / ...
3.三个方向的通信

1. View → ViewModel(控制流,下行)

Page 通过持有 viewModel 实例直接调接口方法:

this.viewModel.pageOnReady(); // 页面就绪时拉数据 this.viewModel.onTabClick(tabIndex); // 点击 Tab this.viewModel.onSwiperTabChange(idx); // Swiper 滑动

2. ViewModel → View(状态流,上行)

ViewModel 用 @ObservedV2 + @Trace 暴露状态,Page 在 build() 里直接读这些字段。字段变化时 ArkUI 自动触发重渲染:

// ViewModelImpl @Trace tabs: NestedScrollTabConfig[] = []; @Trace currentTabIndex: number = 0; @Trace pageLoading: boolean = false; // Page 的 build() Tabs({ index: this.viewModel.currentTabIndex }) { ... }

3. ViewModel → View(事件流,异步回调)

Page 在 aboutToAppear 注入 callback、aboutToDisappear 反注册。ViewModel 通过 callback 通知 Page 执行"非状态型"操作(弹 Toast、跳转页面等):

// Page 注入 private nestedScrollViewModelCallback: NestedScrollViewModelCallback = { popPage: (): void => { this.pathStack.pop(); } }; aboutToAppear() { this.viewModel.setCallback(this.nestedScrollViewModelCallback); } aboutToDisappear() { this.viewModel.setCallback(undefined); // 反注册,防内存泄漏 } // ViewModel 触发 private callback?.popPage?.();

4. ViewModel → Model(数据流)

ViewModel 内部 new ModelImpl,调 Model 的方法拿 Promise:

this.nestedScrollModel.queryTabs().then(tabs => { this.tabs = tabs; // @Trace 字段赋值,自动触发 UI 刷新 }).catch(...);
4.关键约束
约束做法
ViewModel 不持有 View通过 callback 接口反向调用,不 import Page
Model 不持有 ViewModel只返回 Promise,由 ViewModel .then 处理
状态可观察@ObservedV2 + @Trace,UI 自动响应
生命周期对称aboutToAppear 注入 / aboutToDisappear 反注册
闭包不泄漏callback 是 Page 成员,反注册后 ViewModel 断引用

整体是标准的单向数据流 MVVM,《数据下行、事件上行、状态自动同步》。

安卓vs鸿蒙差异

安卓端结构(NestedScrollLayout.java)

NestedScrollLayout (NestedScrollingParent) └── 外层 RecyclerView ├── HeaderVH (header) └── FooterVH └── ViewPager (左右切换Tab) └── Fragment → 内层 RecyclerView (列表) 安卓冲突解决方式(onNestedPreScroll): * isPinnedMode():当 Footer 完全可见(Header 滚走了),进入 pinned 模式 * pinned 模式:滚动优先给内层 child RV * 非 pinned 模式:滚动给外层 parent RV(滚走 Header)

鸿蒙对应结构:

外层 Scroll (parentScroller) ← 对应安卓外层 RecyclerView ├── Header (headerBuilder) ← 对应安卓 HeaderVH ├── Tab (tabBarBuilder) ← 对应安卓 HeaderVH 中的 Tab └── Swiper (swiperController) ← 对应安卓 FooterVH 中的 ViewPager └── List (nestedScroll) ← 对应安卓内层 RecyclerView

鸿蒙冲突解决方式(nestedScroll):
* 内层 List:
* scrollForward: PARENT_FIRST — 手指上滑时,外层 Scroll 先消费(滚走 Header),然后内层 List 再滚
* scrollBackward: SELF_FIRST — 手指下滑时,内层 List 先消费(滚到顶),然后外层 Scroll 再滚(显示 Header)
* 外层 Scroll:SELF_ONLY — 自己处理自己的滚动

效果视频

无,参考安卓的效果吧。效果图可以来一张

源码纯享版

核心组件

负责当组件核心容器,响应滑动事件,tab改变

/** * 嵌套滑动Tab容器 (Demo) * * 结构:外层 Scroll 包裹 Header + Swiper,Swiper 内每个 Tab 是 List。 * * 滑动分发: * - 手指上滑(scrollForward):外层 Scroll 先消费(滚走 Header),然后内层 List 再滚 => PARENT_FIRST * - 手指下滑(scrollBackward):内层 List 先消费(滚到顶),然后外层 Scroll 再滚(显示 Header) => SELF_FIRST * * 注意:外层 Scroll 使用 SELF_ONLY,确保外层与内层 nestedScroll 链路独立可控。 */ @Component export struct NestedScrollTabContainer { /** 当前 Tab 索引 */ @Prop currentTabIndex: number = 0; @Prop bgColor: string = '#FFFFFF'; @Prop useLinearGradient: boolean = false; @Prop gradientAngle: number = 180; @Prop gradientColors: Array<[string, number]> = []; /** 内层 Swiper 控制器(由外部传入,便于外部主动 changeIndex) */ swiperController: SwiperController = new SwiperController(); /** 外层 Scroll 控制器(由外部传入,便于外部 scrollTo) */ parentScroller: Scroller = new Scroller(); /** 防止重复退出 */ private isPopping: boolean = false; /** Tab 切换回调 */ onTabChange?: (index: number) => void; /** 第一个 Tab 右滑退出页面回调 */ onFirstTabSwipeOut?: () => void; /** Header 区域内容 */ @BuilderParam headerBuilder: () => void = this.defaultHeader; /** Swiper 中的每个 Tab 的内容(由外部按 currentTabIndex 渲染) */ @BuilderParam contentBuilder: () => void = this.defaultContent; @Builder private defaultHeader() { } @Builder private defaultContent() { } build() { Stack() { Scroll(this.parentScroller) { Column() { this.headerBuilder() Swiper(this.swiperController) { this.contentBuilder() } .index(this.currentTabIndex) .indicator(false) .loop(false) .width('100%') .height('100%') .onChange((index: number) => { if (this.currentTabIndex !== index) { this.onTabChange?.(index); } }) .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => { // 第一个 Tab 且向右滑出时,触发外部退出逻辑 if (index === 0 && extraInfo.currentOffset > 0 && !this.isPopping) { this.isPopping = true; this.onFirstTabSwipeOut?.(); } }) } .width('100%') .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None) .linearGradient(this.useLinearGradient ? { angle: this.gradientAngle, colors: this.gradientColors } : { angle: 0, colors: [] }) .nestedScroll({ scrollForward: NestedScrollMode.SELF_ONLY, scrollBackward: NestedScrollMode.SELF_ONLY }) } .width('100%') .height('100%') .backgroundColor(this.useLinearGradient ? Color.Transparent : this.bgColor) } }

model抽象层,代码少不用这一个抽象层,只要model就好了,代码太少model可能都不用,这里只是意思一下,因为俺在学mvvm。标准开发一般没这么少接口

/** * 嵌套滑动 Demo Model 接口 */ import { NestedScrollTabConfig } from "./NestedScrollTabConfig"; export interface INestedScrollModel { /** * 查询 Tab 列表数据 */ queryTabs(): Promise<NestedScrollTabConfig[]>; }

model数据源

/** * 嵌套滑动 Demo Model 实现 */ import { INestedScrollModel } from './INestedScrollModel'; import { NestedScrollTabConfig, NestedScrollListItem } from './NestedScrollTabConfig'; const TAB_COUNT = 4; const ITEM_COUNT_PER_TAB = 30; const TAB_NAMES: string[] = ['推荐', '关注', '热门', '最新']; const TAB_THEME_COLORS: string[] = ['#33B62E', '#1E88E5', '#E53935', '#8E24AA']; const ITEM_COLORS: string[] = ['#FFFFFF', '#F5F5F5', '#FFF8E1', '#E8F5E9']; export class NestedScrollModelImpl implements INestedScrollModel { queryTabs(): Promise<NestedScrollTabConfig[]> { return new Promise<NestedScrollTabConfig[]>((resolve, reject) => { try { const tabs = this.buildMockTabs(); resolve(tabs); } catch (e) { reject(e); } }); } private buildMockTabs(): NestedScrollTabConfig[] { const tabs: NestedScrollTabConfig[] = []; for (let i = 0; i < TAB_COUNT; i++) { tabs.push(new NestedScrollTabConfig(i, TAB_NAMES[i], TAB_THEME_COLORS[i], this.buildMockItems(i))); } return tabs; } private buildMockItems(tabIndex: number): NestedScrollListItem[] { const items: NestedScrollListItem[] = []; for (let i = 0; i < ITEM_COUNT_PER_TAB; i++) { const id = tabIndex * ITEM_COUNT_PER_TAB + i + 1; items.push(new NestedScrollListItem( id, `${TAB_NAMES[tabIndex]} - 第${i + 1}条`, `这是 ${TAB_NAMES[tabIndex]} Tab 下的第 ${i + 1} 条 mock 数据`, ITEM_COLORS[i % ITEM_COLORS.length] )); } return items; } }

model实体类

/** * 嵌套滑动 Demo 的 Tab 配置数据模型 */ /** 列表项数据 */ export class NestedScrollListItem { id: number = 0; title: string = ''; subtitle: string = ''; color: string = '#FFFFFF'; constructor(id: number, title: string, subtitle: string, color: string) { this.id = id; this.title = title; this.subtitle = subtitle; this.color = color; } } /** Tab 配置 */ export class NestedScrollTabConfig { index: number = 0; name: string = ''; themeColor: string = ''; listData: NestedScrollListItem[] = []; constructor(index: number, name: string, themeColor: string, listData: NestedScrollListItem[]) { this.index = index; this.name = name; this.themeColor = themeColor; this.listData = listData; } }

viewmodel 交互抽象层,事件响应

/** * 嵌套滑动 Demo ViewModel 接口 */ export interface INestedScrollViewModel { /** 页面就绪:拉取 Tab 数据 */ pageOnReady(): void; /** 点击 Tab;返回是否真的发生了切换 */ onTabClick(tabIndex: number): boolean; /** Swiper 滑动切换 Tab */ onSwiperTabChange(index: number): void; /** 第一个 Tab 继续向右滑(退出页面) */ onFirstTabSwipeOut(): void; } /** * View 回调能力(由 Page 在 aboutToAppear 时注入) */ export interface NestedScrollViewModelCallback { popPage?: () => void; }

viewmodel 交互实现层。处理具体事件,数据状态管理

import { NestedScrollModelImpl } from '../model/NestedScrollModelImpl'; import { NestedScrollTabConfig } from '../model/NestedScrollTabConfig'; import { INestedScrollViewModel, NestedScrollViewModelCallback } from './INestedScrollViewModel'; const VM_TAG = 'NestedScrollVM'; /** * 嵌套滑动 Demo ViewModel 实现 * 通过 @ObservedV2 + @Trace 让 View 层感知状态变化 */ @ObservedV2 export class NestedScrollViewModelImpl implements INestedScrollViewModel { private nestedScrollModel: NestedScrollModelImpl; private callback?: NestedScrollViewModelCallback; @Trace tabs: NestedScrollTabConfig[] = []; @Trace currentTabIndex: number = 0; @Trace pageLoading: boolean = false; constructor() { this.nestedScrollModel = new NestedScrollModelImpl(); } aboutToAppear(): void { } aboutToDisappear(): void { } setCallback(callback: NestedScrollViewModelCallback | undefined): void { this.callback = callback; } pageOnReady(): void { this.showPageLoading(); this.queryTabs(); } private queryTabs(): void { this.nestedScrollModel.queryTabs() .then((tabs: NestedScrollTabConfig[]) => { this.tabs = tabs; this.hidePageLoading(); }) .catch((e: Error) => { console.error(`${VM_TAG} queryTabs error: ${e?.message ?? e}`); this.hidePageLoading(); //TODO::异常处理 }); } onTabClick(tabIndex: number): boolean { if (this.currentTabIndex === tabIndex) { return false; } this.currentTabIndex = tabIndex; return true; } onSwiperTabChange(index: number): void { if (this.currentTabIndex !== index) { this.currentTabIndex = index; } } onFirstTabSwipeOut(): void { this.callback?.popPage?.(); } private showPageLoading(): void { this.pageLoading = true; } private hidePageLoading(): void { this.pageLoading = false; } }

view page层。view组件组装/展示、数据渲染

/** * View 层:嵌套滑动 Demo 页 */ import { NestedScrollTabContainer } from '../components/NestedScrollTabContainer'; import { NestedScrollViewModelImpl } from '../viewmodel/NestedScrollViewModelImpl'; import { NestedScrollViewModelCallback } from '../viewmodel/INestedScrollViewModel'; import { NestedScrollTabConfig, NestedScrollListItem } from '../model/NestedScrollTabConfig'; @Builder export function NestedScrollDemoPageBuilder(name: string, param: Object) { NestedScrollDemoPage() } @Entry @Component struct NestedScrollDemoPage { private viewModel: NestedScrollViewModelImpl = new NestedScrollViewModelImpl(); private parentScroller: Scroller = new Scroller(); private swiperController: SwiperController = new SwiperController(); private childScrollers: Scroller[] = [ new Scroller(), new Scroller(), new Scroller(), new Scroller() ]; private pathStack: NavPathStack = new NavPathStack(); private nestedScrollViewModelCallback: NestedScrollViewModelCallback = { popPage: (): void => { this.pathStack.pop(); } }; build() { NavDestination() { Stack() { NestedScrollTabContainer({ currentTabIndex: this.viewModel.currentTabIndex, useLinearGradient: true, gradientAngle: 180, gradientColors: [ ['#33B62E', 0.0], ['#00B365', 1.0] ], swiperController: this.swiperController, parentScroller: this.parentScroller, onTabChange: (index: number) => { this.viewModel.onSwiperTabChange(index); }, onFirstTabSwipeOut: () => { this.viewModel.onFirstTabSwipeOut(); }, headerBuilder: () => { this.headerBuilder() }, contentBuilder: () => { this.swiperContentBuilder() } }) } } .width('100%') .height('100%') .backgroundColor('#00B365') .hideTitleBar(true) .hideBackButton(true) .hideToolBar(true) .onReady((context: NavDestinationContext) => { this.pathStack = context.pathStack; this.viewModel.pageOnReady(); }) } aboutToAppear() { this.viewModel.setCallback(this.nestedScrollViewModelCallback); this.viewModel.aboutToAppear(); } aboutToDisappear() { this.viewModel.setCallback(undefined); this.viewModel.aboutToDisappear(); } @Builder headerBuilder() { Column() { Column() { Text('嵌套滑动 Demo') .fontSize(24) .fontColor('#FFFFFF') .fontWeight(FontWeight.Bold) .margin({ top: 48, bottom: 8 }) Text('下滑内层列表到顶后,外层 Header 才会下移出现') .fontSize(12) .fontColor('#E0FFFFFF') .margin({ bottom: 16 }) } .width('100%') .alignItems(HorizontalAlign.Center) Row() { ForEach(this.viewModel.tabs, (tab: NestedScrollTabConfig) => { Text(tab.name) .fontSize(this.viewModel.currentTabIndex === tab.index ? 16 : 14) .fontColor(this.viewModel.currentTabIndex === tab.index ? '#FFFFFF' : '#B3FFFFFF') .fontWeight(this.viewModel.currentTabIndex === tab.index ? FontWeight.Bold : FontWeight.Normal) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .borderRadius(16) .backgroundColor(this.viewModel.currentTabIndex === tab.index ? '#33FFFFFF' : Color.Transparent) .onClick(() => { const changed = this.viewModel.onTabClick(tab.index); if (changed) { this.swiperController.changeIndex(tab.index); } }) }) } .width('100%') .height(48) .justifyContent(FlexAlign.Center) .margin({ bottom: 8 }) } .width('100%') .alignItems(HorizontalAlign.Center) } @Builder swiperContentBuilder() { ForEach(this.viewModel.tabs, (tab: NestedScrollTabConfig) => { this.tabContentBuilder(tab) }) } @Builder tabContentBuilder(tab: NestedScrollTabConfig) { List({ scroller: this.childScrollers[tab.index], space: 8 }) { ForEach(tab.listData, (item: NestedScrollListItem) => { ListItem() { Column() { Text(item.title) .fontSize(16) .fontColor('#333333') .fontWeight(FontWeight.Medium) Text(item.subtitle) .fontSize(12) .fontColor('#666666') .margin({ top: 4 }) Text(`#${item.id}`) .fontSize(11) .fontColor('#999999') .margin({ top: 4 }) } .width('100%') .padding(16) .backgroundColor(item.color) .borderRadius(12) .alignItems(HorizontalAlign.Start) } .padding({ left: 12, right: 12 }) }) ListItem() { Text('— 已经到底啦 —') .width('100%') .textAlign(TextAlign.Center) .fontSize(12) .fontColor('#FFFFFF') .padding({ top: 16, bottom: 32 }) } } .width('100%') .height('100%') .listDirection(Axis.Vertical) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None) .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }) } }

撒花撒花~

后续优化

这个是基础组件,如果项目使用,还需要根据项目进行拓展。比如:

1.增加footer,footer需要放在list里面。

2.懒加载+分页加载,懒加载是为了不让大列表卡顿,懒加载是lazyforeach,分页加载是因为服务端数据太大一次性下发不完

3.错误页/空白页处理,这个放在tabContentBuilder里面比较好。一般进页面是查第一个tab数据,如果失败该tab展示异常或者空白,切到其他tab的时候才懒加载其他tab,其他tab仍然可以继续请求数据正常展示,然后切回原来异常tab的时候继续请求数据,直到数据请求成功

4.tab动态化。这个根据业务规则来,有的强诉求tab动态化,即端上代码不消费具体的tab类型,只做容器和兼容处理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询