ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

折叠屏开合后,List/WaterFlow 为什么会跳回顶部

2026/9/5 4:28:53 拓冰建站 浏览量
折叠屏开合后,List/WaterFlow 为什么会跳回顶部 前言折叠屏开合时List和WaterFlow跳回顶部不是简单的滚动丢失问题。真正的问题是屏幕形态变了旧布局下的滚动位置已经不能直接套到新布局里。List还好通常只是阅读位置丢了WaterFlow更明显展开或折叠后列数变化卡片会重新排布所以看起来像跳到了另一块内容甚至像顺序乱了。社区里的问题场景是页面里List混排WaterFlow在 Mate X5 上做开合连续适配展开/折叠后出现两个现象List回到顶部阅读位置丢失。WaterFlow跳到完全不同的位置卡片排列看起来乱了。问题地址https://developer.huawei.com/consumer/cn/forum/topicview?tid0207222393140300721fid0109140870620153026核心不是保存偏移量而是保存可见项索引很多人处理列表恢复时会先想到currentOffset().yOffset。在普通直板机、固定宽度页面里这种方式有时能用。但折叠屏开合后yOffset的含义变了。折叠态下WaterFlow是 2 列展开态变成 4 列。同一批卡片在纵向上占用的高度会变短旧的yOffset 1800到了新布局里已经不是用户刚才看到的那一屏。所以案例没有保存偏移量而是保存首条可见项索引privatesavedListIndex:number0;privatesavedFlowIndex:number0;List保存savedListIndexWaterFlow保存savedFlowIndex。索引和数据顺序绑定不依赖当前屏幕宽度也不依赖列数。屏幕从折叠态切到展开态后第 20 条数据仍然是第 20 条数据这比保存像素偏移稳定得多。两个滚动容器要分开管案例页面里有两个独立滚动区域上面是List下面是WaterFlow。它们各自有自己的ScrollerprivatelistScroller:ScrollernewScroller();privateflowScroller:ScrollernewScroller();这一步很关键。List的阅读位置和WaterFlow的阅读位置不是同一个东西。折叠屏开合后如果只恢复一个外层滚动位置内部瀑布流自己的可见项仍然可能错掉。案例的处理方式很直接谁负责滚动就记录谁的可见项索引谁丢了位置就用自己的Scroller恢复。onScrollIndex持续记录当前看到哪一项List的位置记录在onScrollIndex里完成.onScrollIndex((start:number){if(this.isRestoring){return;}this.savedListIndexstart;})start就是当前首条可见项索引。用户滑动列表时它会不断更新。WaterFlow也是同样的思路.onScrollIndex((start:number){if(this.isRestoring){return;}this.savedFlowIndexstart;})这里的isRestoring不是可有可无的保护位。折叠屏开合时布局变化本身可能触发滚动回调。假设用户原本看到第 20 条开合瞬间组件内部先回到了顶部如果不拦住这次回调savedListIndex会被改成 0。后面再执行恢复逻辑就只能恢复到顶部。很多“明明调用了scrollToIndex但还是回顶部”的问题本质就是保存值在恢复前被覆盖了。折叠状态变化时先锁住恢复流程案例通过foldDisplayModeChange监听折叠屏形态变化aboutToAppear():void{this.initSections(2);display.on(foldDisplayModeChange,this.onFoldDisplayModeChange);}aboutToDisappear():void{display.off(foldDisplayModeChange,this.onFoldDisplayModeChange);}真正的处理在onFoldDisplayModeChange里privateonFoldDisplayModeChange(mode:display.FoldDisplayMode):void{if(this.isRestoring){return;}this.isRestoringtrue;this.layoutStablefalse;constcrossCountmodedisplay.FoldDisplayMode.FOLD_DISPLAY_MODE_FULL?4:2;this.updateSectionsCrossCount(crossCount);setTimeout((){this.layoutStabletrue;this.restorePosition();this.isRestoringfalse;},this.restoreDelay);};这段代码做了三件事。先把isRestoring置为true。从这一刻开始List和WaterFlow的onScrollIndex都不会再改写保存的索引。然后根据折叠状态更新瀑布流列数。Mate X5 展开态使用 4 列折叠态使用 2 列constcrossCountmodedisplay.FoldDisplayMode.FOLD_DISPLAY_MODE_FULL?4:2;this.updateSectionsCrossCount(crossCount);最后延迟 150ms再恢复滚动位置。这个延迟不是为了“等一等看运气”而是给窗口尺寸变化、组件测量、WaterFlow列布局重算留时间。布局还没稳定就调用scrollToIndex很容易按旧 viewport 恢复结果还是偏。WaterFlowSections负责把列数变化交给框架感知瀑布流错位的核心在列数变化。折叠态 2 列展开态 4 列同一个数据源会生成完全不同的视觉排列。案例没有直接改某个普通变量而是用WaterFlowSections描述瀑布流分组Statesections:WaterFlowSectionsnewWaterFlowSections();初始化时生成一个 sectionprivateinitSections(crossCount:number):void{constsection:SectionOptions{itemsCount:this.flowData.totalCount(),crossCount:crossCount,columnsGap:8,rowsGap:8,margin:{top:8,left:12,bottom:8,right:12},onGetItemMainSizeByIndex:(index:number){return120(index%5)*24;}};this.sections.splice(0,0,[section]);}开合变化时通过splice替换 sectionprivateupdateSectionsCrossCount(crossCount:number):void{this.sections.splice(0,1,[{itemsCount:this.flowData.totalCount(),crossCount:crossCount,columnsGap:8,rowsGap:8,margin:{top:8,left:12,bottom:8,right:12},onGetItemMainSizeByIndex:(index:number){return120(index%5)*24;}}]);}splice的意义在于让WaterFlowSections的变化被框架明确感知。列数从 2 到 4不只是一个数字变了而是整个瀑布流排列规则变了。让框架按 section 更新比直接改内部属性更稳。onGetItemMainSizeByIndex也很重要。瀑布流卡片不等高时框架需要知道每个 item 的主轴尺寸。这里用120 (index % 5) * 24模拟不同高度。高度规则稳定开合后的重排结果也更可控。SLIDING_WINDOW用来降低瀑布流重排的割裂感案例构建WaterFlow时使用了WaterFlowLayoutMode.SLIDING_WINDOWWaterFlow({scroller:this.flowScroller,sections:this.sections,layoutMode:WaterFlowLayoutMode.SLIDING_WINDOW}){LazyForEach(this.flowData,(item:ProductItem){FlowItem(){// item UI}},(item:ProductItem)item.id)}WaterFlow的麻烦点在于列数一变视觉布局必然重算。SLIDING_WINDOW的作用是让瀑布流按滑窗方式组织布局减少动态变化时全量重建带来的跳变感。再配合scrollToIndex恢复目标就变成“回到同一批数据附近”而不是“回到旧布局下某个像素偏移”。这就是瀑布流开合后不再乱跳的关键。这里还有一个细节LazyForEach的 key 用的是业务 id。(item:ProductItem)item.id稳定 key 能保证数据项和组件复用关系稳定。折叠屏开合后视觉位置可以变但数据项身份不能乱。如果 key 不稳定瀑布流重排时就更容易出现卡片错位、复用异常、看起来像顺序乱了的问题。布局稳定后用索引恢复两个区域最终恢复逻辑在restorePositionprivaterestorePosition():void{if(!this.layoutStable){return;}this.listScroller.scrollToIndex(this.savedListIndex,true,ScrollAlign.CENTER);this.flowScroller.scrollToIndex(this.savedFlowIndex,true,ScrollAlign.CENTER);}layoutStable控制恢复时机。它为false时说明当前还处在开合后的布局变化过程中这时不执行滚动恢复。恢复时分别调用两个Scrollerthis.listScroller.scrollToIndex(this.savedListIndex,true,ScrollAlign.CENTER);this.flowScroller.scrollToIndex(this.savedFlowIndex,true,ScrollAlign.CENTER);ScrollAlign.CENTER会把目标 index 对齐到视口中间附近。这样处理比默认顶对齐更自然尤其是用户在阅读中间内容时展开或折叠后不会突然贴到屏幕顶部。childrenMainSize让 List 的索引定位更准List这里加了一行.childrenMainSize(newChildrenMainSize(72))这行的作用是给列表项主轴尺寸一个稳定参考。List项高度稳定时框架按 index 计算滚动位置会更准。如果列表项高度完全不可控折叠屏开合后即使用索引恢复也可能出现轻微偏差。案例里的ListItem高度基本固定再加上childrenMainSize恢复结果会更稳定。完整源码import { display } from kit.ArkUI; // 数据模型 class ArticleItem { id: string; title: string; desc: string; constructor(id: string, title: string, desc: string) { this.id id; this.title title; this.desc desc; } } class ProductItem { id: string; name: string; price: number; color: string; constructor(id: string, name: string, price: number, color: string) { this.id id; this.name name; this.price price; this.color color; } } // 通用数据源IDataSource class BasicDataSourceT implements IDataSource { private listeners: DataChangeListener[] []; public dataArray: T[] []; public totalCount(): number { return this.dataArray.length; } public getData(index: number): T { return this.dataArray[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const pos this.listeners.indexOf(listener); if (pos 0) { this.listeners.splice(pos, 1); } } notifyDataReload(): void { this.listeners.forEach((listener: DataChangeListener) { listener.onDataReloaded(); }); } } class ListDataSource extends BasicDataSourceArticleItem { constructor() { super(); for (let i 0; i 50; i) { this.dataArray.push( new ArticleItem(a${i}, 资讯标题 ${i 1}, 这是第 ${i 1} 条资讯的摘要描述内容...) ); } } } class FlowDataSource extends BasicDataSourceProductItem { private readonly colors: string[] [#FF6B6B, #4ECDC4, #FFD93D, #6C5CE7, #45B7D1, #FF9F43]; constructor() { super(); for (let i 0; i 60; i) { this.dataArray.push( new ProductItem(p${i}, 商品 ${i 1}, 19 (i % 80), this.colors[i % this.colors.length]) ); } } } // 页面 Entry Component struct Index { // 两个独立滚动容器 private listScroller: Scroller new Scroller(); private flowScroller: Scroller new Scroller(); private listData: ListDataSource new ListDataSource(); private flowData: FlowDataSource new FlowDataSource(); State sections: WaterFlowSections new WaterFlowSections(); State layoutStable: boolean true; // 连续记录的首条可见项索引比偏移量更可靠与布局尺寸解耦 private savedListIndex: number 0; private savedFlowIndex: number 0; // 防止快速连续折叠/展开时重复触发恢复 private isRestoring: boolean false; private readonly restoreDelay: number 150; aboutToAppear(): void { this.initSections(2); // 默认折叠态 2 列 display.on(foldDisplayModeChange, this.onFoldDisplayModeChange); } aboutToDisappear(): void { display.off(foldDisplayModeChange, this.onFoldDisplayModeChange); } // 折叠屏开合回调保存位置 - 调整列数 - 布局稳定后恢复 private onFoldDisplayModeChange (mode: display.FoldDisplayMode): void { if (this.isRestoring) { return; } this.isRestoring true; this.layoutStable false; // 1. 首条可见项索引已由 onScrollIndex 持续记录此时布局尚未重排索引有效 // 2. 根据展开态调整瀑布流列数折叠态 2 列展开态 4 列 const crossCount mode display.FoldDisplayMode.FOLD_DISPLAY_MODE_FULL ? 4 : 2; this.updateSectionsCrossCount(crossCount); // 3. 等待布局稳定后再恢复位置viewport 尺寸更新完成 setTimeout(() { this.layoutStable true; this.restorePosition(); this.isRestoring false; }, this.restoreDelay); }; private initSections(crossCount: number): void { const section: SectionOptions { itemsCount: this.flowData.totalCount(), crossCount: crossCount, columnsGap: 8, rowsGap: 8, margin: { top: 8, left: 12, bottom: 8, right: 12 }, onGetItemMainSizeByIndex: (index: number) { return 120 (index % 5) * 24; // 模拟瀑布流不等高卡片 } }; this.sections.splice(0, 0, [section]); } // 通过 splice 动态更新分组的 crossCount避免直接改属性导致 UI 异常 private updateSectionsCrossCount(crossCount: number): void { this.sections.splice(0, 1, [{ itemsCount: this.flowData.totalCount(), crossCount: crossCount, columnsGap: 8, rowsGap: 8, margin: { top: 8, left: 12, bottom: 8, right: 12 }, onGetItemMainSizeByIndex: (index: number) { return 120 (index % 5) * 24; } }]); } // 布局稳定后用「索引」恢复位置不用偏移量避免新旧布局尺寸不一致导致误差 private restorePosition(): void { if (!this.layoutStable) { return; } this.listScroller.scrollToIndex(this.savedListIndex, true, ScrollAlign.CENTER); this.flowScroller.scrollToIndex(this.savedFlowIndex, true, ScrollAlign.CENTER); } build() { Column() { Text(折叠屏阅读位置保持 · List WaterFlow) .fontSize(18) .fontWeight(FontWeight.Bold) .alignSelf(ItemAlign.Start) .margin({ left: 12, top: 12, bottom: 8 }) // List 资讯区独立滚动 List({ scroller: this.listScroller }) { LazyForEach(this.listData, (item: ArticleItem) { ListItem() { Column() { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) Text(item.desc) .fontSize(13) .fontColor(#666666) .margin({ top: 4 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .padding(12) .width(100%) } .backgroundColor(#FFFFFF) .borderRadius(8) .margin({ left: 12, right: 12, bottom: 8 }) }, (item: ArticleItem) item.id) } .width(100%) .layoutWeight(2) // 与下方瀑布流按比例分配高度保证内部可独立滚动 .childrenMainSize(new ChildrenMainSize(72)) .onScrollIndex((start: number) { // 恢复期间忽略自动跳顶触发的回写避免覆盖已保存索引 if (this.isRestoring) { return; } this.savedListIndex start; }) Text(猜你喜欢 · 瀑布流) .fontSize(16) .fontWeight(FontWeight.Bold) .alignSelf(ItemAlign.Start) .margin({ left: 12, top: 12, bottom: 8 }) // WaterFlow 商品瀑布流区独立滚动 WaterFlow({ scroller: this.flowScroller, sections: this.sections, layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) { LazyForEach(this.flowData, (item: ProductItem) { FlowItem() { Column() { Text(item.name) .fontSize(14) .fontWeight(FontWeight.Medium) Text(¥${item.price}) .fontSize(14) .fontColor(item.color) .margin({ top: 6 }) } .alignItems(HorizontalAlign.Start) .padding(10) .width(100%) .height(100%) .backgroundColor(#F5F5F5) .borderRadius(8) } .width(100%) }, (item: ProductItem) item.id) } .columnsGap(8) .rowsGap(8) .width(100%) .layoutWeight(1) .backgroundColor(#FAFAFA) .onScrollIndex((start: number) { if (this.isRestoring) { return; } this.savedFlowIndex start; }) } .width(100%) .height(100%) .backgroundColor(#F0F0F0) } }总结折叠屏开合后跳回顶部本质是布局变了旧布局里的滚动位置失效了。List的处理重点是保存首条可见项索引布局稳定后用scrollToIndex恢复。WaterFlow还要额外处理列数变化带来的重排所以案例用了WaterFlowSections描述列布局用splice更新crossCount再用SLIDING_WINDOW降低动态重排的跳变感。整条链路就是onScrollIndex记录索引foldDisplayModeChange锁住恢复流程更新瀑布流列数等待布局稳定最后分别恢复List和WaterFlow。问题不是靠某一个 API 解决的而是靠这几个步骤把“保存位置”和“恢复位置”从屏幕尺寸变化里解耦出来。