鸿蒙-NestedScrollTabContainer-mvvm-demo

📅 2026/7/22 2:34:37
鸿蒙-NestedScrollTabContainer-mvvm-demo
之前学了安卓版的吸顶嵌套可左右翻页上下滑动的rv安卓demo-CeilingNestedScrollView-CSDN博客之后下一个需求就是做鸿蒙版的这个需求然后鸿蒙也没有这种组件凭借俺对安卓的理解也很快promot出了鸿蒙版哈哈哈哈真是机智如俺不过这个没有tabbar吸顶效果哈。之前很少用mvvm结果现在项目全是mvvm。嗯用mvvm重构一下吧再封装成单个组件人模狗样版组件封装1.背景NestedScrollTabContainer 是一个可复用的嵌套滑动 Tab 容器解决 Scroll Swiper List 三层嵌套下的滑动分发问题。 典型场景顶部 Header 区域 多 Tab 内列表区手指上滑先滚走 Header 再滚列表手指下滑先把内层列表滚到顶再让外层 Scroll 把 Header 显示出来。2.划分原则共性、稳定的布局进容器业务差异大的留外面。封装时问自己三句话不同页面这段代码会一样吗一样 → 进容器不一样 → 留外面。这段代码跟业务数据有关吗无关 → 进容器有关系 → 留外面。这段代码要不要被外部主动触发要如 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 绑定到 List6.控制器传递约定ArkTS 中 Component 不能被外部 new 实例化外部无法直接持有容器引用来调方法。 因此改为 外部持有控制器传给容器使用 的反向依赖控制器声明方用途SwiperController外部 Page外部点击 Tab 时调 changeIndex(index) 切换容器内部用同一实例绑定 SwiperScroller外层外部 Page外部 scrollToTop() 等场景调用 scrollTo(...)容器内部用同一实例绑定 ScrollScroller每个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 的方法拿 Promisethis.nestedScrollModel.queryTabs().then(tabs { this.tabs tabs; // Trace 字段赋值自动触发 UI 刷新 }).catch(...);4.关键约束约束做法ViewModel 不持有 View通过 callback 接口反向调用不 import PageModel 不持有 ViewModel只返回 Promise由 ViewModel .then 处理状态可观察ObservedV2 TraceUI 自动响应生命周期对称aboutToAppear 注入 / aboutToDisappear 反注册闭包不泄漏callback 是 Page 成员反注册后 ViewModel 断引用整体是标准的单向数据流 MVVM《数据下行、事件上行、状态自动同步》。安卓vs鸿蒙差异安卓端结构NestedScrollLayout.javaNestedScrollLayout (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* 外层 ScrollSELF_ONLY — 自己处理自己的滚动效果视频无参考安卓的效果吧。效果图可以来一张源码纯享版核心组件负责当组件核心容器响应滑动事件tab改变/** * 嵌套滑动Tab容器 (Demo) * * 结构外层 Scroll 包裹 Header SwiperSwiper 内每个 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(): PromiseNestedScrollTabConfig[]; }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(): PromiseNestedScrollTabConfig[] { return new PromiseNestedScrollTabConfig[]((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.增加footerfooter需要放在list里面。2.懒加载分页加载懒加载是为了不让大列表卡顿懒加载是lazyforeach分页加载是因为服务端数据太大一次性下发不完3.错误页/空白页处理这个放在tabContentBuilder里面比较好。一般进页面是查第一个tab数据如果失败该tab展示异常或者空白切到其他tab的时候才懒加载其他tab其他tab仍然可以继续请求数据正常展示然后切回原来异常tab的时候继续请求数据直到数据请求成功4.tab动态化。这个根据业务规则来有的强诉求tab动态化即端上代码不消费具体的tab类型只做容器和兼容处理。