之前学了安卓版的吸顶嵌套可左右翻页上下滑动的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 绑定到 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类型,只做容器和兼容处理。