【Jack实战】如何在时光旅记中用 ArkUI WaterFlow 做发现页瀑布流

大家好我是鸿蒙Jack,本期以我的《时光旅记》APP为例,讲一下我是怎么在"发现页我的时光"里接入瀑布流的。

这个功能不是单纯把两列卡片换个容器。《时光旅记》的"小本"会有封面、简介、瞬间数量、场景标签、隐私保护状态,还支持长按整理、拖拽排序、删除、滚动位置恢复和设置页切换布局。用户的小本越多,普通双列卡片就越像一个固定网格,照片感不够强,所以我在发现页加了一套 WaterFlow + FlowItem 的瀑布流模式,让不同小本根据封面、简介和内容数量拉开高度,整体更像照片墙。

这里放一张效果图:

用到的技术栈

用到的是 HarmonyOS Stage 模型下的 ArkTS 和 ArkUI。

页面层主要用 WaterFlowFlowItemFlexColumnStackImageTextButtonToggleSymbolGlyphWaterFlow 是 ArkUI 原生瀑布流容器,负责把不同高度的 FlowItem 分配到多列里。columnsTemplate('1fr 1fr') 决定两列等宽,columnsGaprowsGap 控制列距、行距,Scroller 用来绑定滚动控制器。

状态层用的是 ArkUI 装饰器。@Prop 接收 MainPage 传进来的布局配置,@Link 保存发现页滚动偏移,@State 保存拖拽、整理模式、渲染版本这些页面内部状态。设置页里修改 store 后调用 persistTimeImprintStore()publishTimeImprintStoreChange(this.store),让页面拿到最新配置。

整体架构

#mermaid-svg-L92VhgSD7SIBholc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-L92VhgSD7SIBholc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L92VhgSD7SIBholc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L92VhgSD7SIBholc .error-icon{fill:#552222;}#mermaid-svg-L92VhgSD7SIBholc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L92VhgSD7SIBholc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L92VhgSD7SIBholc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L92VhgSD7SIBholc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L92VhgSD7SIBholc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L92VhgSD7SIBholc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L92VhgSD7SIBholc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L92VhgSD7SIBholc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L92VhgSD7SIBholc .marker.cross{stroke:#333333;}#mermaid-svg-L92VhgSD7SIBholc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L92VhgSD7SIBholc p{margin:0;}#mermaid-svg-L92VhgSD7SIBholc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-L92VhgSD7SIBholc .cluster-label text{fill:#333;}#mermaid-svg-L92VhgSD7SIBholc .cluster-label span{color:#333;}#mermaid-svg-L92VhgSD7SIBholc .cluster-label span p{background-color:transparent;}#mermaid-svg-L92VhgSD7SIBholc .label text,#mermaid-svg-L92VhgSD7SIBholc span{fill:#333;color:#333;}#mermaid-svg-L92VhgSD7SIBholc .node rect,#mermaid-svg-L92VhgSD7SIBholc .node circle,#mermaid-svg-L92VhgSD7SIBholc .node ellipse,#mermaid-svg-L92VhgSD7SIBholc .node polygon,#mermaid-svg-L92VhgSD7SIBholc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-L92VhgSD7SIBholc .rough-node .label text,#mermaid-svg-L92VhgSD7SIBholc .node .label text,#mermaid-svg-L92VhgSD7SIBholc .image-shape .label,#mermaid-svg-L92VhgSD7SIBholc .icon-shape .label{text-anchor:middle;}#mermaid-svg-L92VhgSD7SIBholc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-L92VhgSD7SIBholc .rough-node .label,#mermaid-svg-L92VhgSD7SIBholc .node .label,#mermaid-svg-L92VhgSD7SIBholc .image-shape .label,#mermaid-svg-L92VhgSD7SIBholc .icon-shape .label{text-align:center;}#mermaid-svg-L92VhgSD7SIBholc .node.clickable{cursor:pointer;}#mermaid-svg-L92VhgSD7SIBholc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-L92VhgSD7SIBholc .arrowheadPath{fill:#333333;}#mermaid-svg-L92VhgSD7SIBholc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-L92VhgSD7SIBholc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-L92VhgSD7SIBholc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-L92VhgSD7SIBholc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-L92VhgSD7SIBholc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-L92VhgSD7SIBholc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-L92VhgSD7SIBholc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-L92VhgSD7SIBholc .cluster text{fill:#333;}#mermaid-svg-L92VhgSD7SIBholc .cluster span{color:#333;}#mermaid-svg-L92VhgSD7SIBholc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-L92VhgSD7SIBholc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-L92VhgSD7SIBholc rect.text{fill:none;stroke-width:0;}#mermaid-svg-L92VhgSD7SIBholc .icon-shape,#mermaid-svg-L92VhgSD7SIBholc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-L92VhgSD7SIBholc .icon-shape p,#mermaid-svg-L92VhgSD7SIBholc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-L92VhgSD7SIBholc .icon-shape .label rect,#mermaid-svg-L92VhgSD7SIBholc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-L92VhgSD7SIBholc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-L92VhgSD7SIBholc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-L92VhgSD7SIBholc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GRID
WATERFALL
SettingsPage 设置页
TimeImprintStore
TimeImprintPersistence Preferences
MainPage
NotebookTab
notebookDiscoveryLayoutMode
Flex 双列卡片
WaterFlow 两列瀑布流
FlowItem
buildNotebookDiscoveryTile
buildManagedImage / PlaceholderCover
底部文字或封面蒙层文字
长按整理和拖拽排序

这个结构里,WaterFlow 只负责排列。小本卡片本身不拆两套组件,还是同一个 buildNotebookDiscoveryTile()。我只把宽度和封面高度作为参数传进去:普通双列模式传 48% + 140,瀑布流模式传 100% + getNotebookWaterfallCoverHeight()。这样后面维护卡片点击、长按、拖拽、删除时,不需要在两个布局里改两份。

切换布局的时序

WaterFlow NotebookTab MainPage Preferences TimeImprintStore SettingsPage 用户 WaterFlow NotebookTab MainPage Preferences TimeImprintStore SettingsPage 用户 #mermaid-svg-IThwS5KKOelXIdqz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IThwS5KKOelXIdqz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IThwS5KKOelXIdqz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IThwS5KKOelXIdqz .error-icon{fill:#552222;}#mermaid-svg-IThwS5KKOelXIdqz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IThwS5KKOelXIdqz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IThwS5KKOelXIdqz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IThwS5KKOelXIdqz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IThwS5KKOelXIdqz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IThwS5KKOelXIdqz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IThwS5KKOelXIdqz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IThwS5KKOelXIdqz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IThwS5KKOelXIdqz .marker.cross{stroke:#333333;}#mermaid-svg-IThwS5KKOelXIdqz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IThwS5KKOelXIdqz p{margin:0;}#mermaid-svg-IThwS5KKOelXIdqz .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IThwS5KKOelXIdqz text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-IThwS5KKOelXIdqz .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IThwS5KKOelXIdqz .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-IThwS5KKOelXIdqz .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-IThwS5KKOelXIdqz .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-IThwS5KKOelXIdqz #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-IThwS5KKOelXIdqz .sequenceNumber{fill:white;}#mermaid-svg-IThwS5KKOelXIdqz #sequencenumber{fill:#333;}#mermaid-svg-IThwS5KKOelXIdqz #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-IThwS5KKOelXIdqz .messageText{fill:#333;stroke:none;}#mermaid-svg-IThwS5KKOelXIdqz .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IThwS5KKOelXIdqz .labelText,#mermaid-svg-IThwS5KKOelXIdqz .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-IThwS5KKOelXIdqz .loopText,#mermaid-svg-IThwS5KKOelXIdqz .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-IThwS5KKOelXIdqz .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IThwS5KKOelXIdqz .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IThwS5KKOelXIdqz .noteText,#mermaid-svg-IThwS5KKOelXIdqz .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-IThwS5KKOelXIdqz .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IThwS5KKOelXIdqz .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IThwS5KKOelXIdqz .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IThwS5KKOelXIdqz .actorPopupMenu{position:absolute;}#mermaid-svg-IThwS5KKOelXIdqz .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-IThwS5KKOelXIdqz .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IThwS5KKOelXIdqz .actor-man circle,#mermaid-svg-IThwS5KKOelXIdqz line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-IThwS5KKOelXIdqz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击"瀑布流" applyNotebookDiscoveryLayoutModePreference(WATERFALL) store.notebookDiscoveryLayoutMode = WATERFALL persistTimeImprintStore() publishTimeImprintStoreChange(store) 传入 notebookDiscoveryLayoutMode shouldUseNotebookWaterfallLayout() 构建 FlowItem 列表 按两列和不同 item 高度排布

我这里没有把瀑布流配置做成页面内临时状态,因为这是用户偏好。用户这次选择瀑布流,下次打开 APP 也应该还是瀑布流,所以必须进 store 和 Preferences。

关键实现

瀑布流的入口在 buildNotebookDiscoverySection()。如果没有小本,显示空状态;如果开启瀑布流,使用 WaterFlow;否则使用旧的 Flex 双列卡片。

这里有一个细节:WaterFlow 外层在《时光旅记》发现页里处于一个更大的滚动页面内,所以我给 WaterFlow 设置了明确高度,并关闭了内部滚动交互:

arkts 复制代码
.height(this.getNotebookWaterfallHeight())
.enableScrollInteraction(false)

这样滚动还是交给外层发现页,瀑布流只负责把自己的内容完整排出来。高度由 getNotebookWaterfallHeight() 估算两列排布后的最大列高,避免嵌套滚动抢事件,也避免内容被裁掉。

高度计算不是随便写几个随机数。我用了小本的真实业务信息参与计算:有没有封面、有没有简介、瞬间数量是否较多,再叠加 index 的轻微差异,最后限制在 132230 之间。这样既有错落感,又不会出现某个卡片高得失控。
#mermaid-svg-KrPuR9WyN5CyrIHN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KrPuR9WyN5CyrIHN .error-icon{fill:#552222;}#mermaid-svg-KrPuR9WyN5CyrIHN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KrPuR9WyN5CyrIHN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KrPuR9WyN5CyrIHN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KrPuR9WyN5CyrIHN .marker.cross{stroke:#333333;}#mermaid-svg-KrPuR9WyN5CyrIHN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KrPuR9WyN5CyrIHN p{margin:0;}#mermaid-svg-KrPuR9WyN5CyrIHN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster-label text{fill:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster-label span{color:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster-label span p{background-color:transparent;}#mermaid-svg-KrPuR9WyN5CyrIHN .label text,#mermaid-svg-KrPuR9WyN5CyrIHN span{fill:#333;color:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN .node rect,#mermaid-svg-KrPuR9WyN5CyrIHN .node circle,#mermaid-svg-KrPuR9WyN5CyrIHN .node ellipse,#mermaid-svg-KrPuR9WyN5CyrIHN .node polygon,#mermaid-svg-KrPuR9WyN5CyrIHN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KrPuR9WyN5CyrIHN .rough-node .label text,#mermaid-svg-KrPuR9WyN5CyrIHN .node .label text,#mermaid-svg-KrPuR9WyN5CyrIHN .image-shape .label,#mermaid-svg-KrPuR9WyN5CyrIHN .icon-shape .label{text-anchor:middle;}#mermaid-svg-KrPuR9WyN5CyrIHN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KrPuR9WyN5CyrIHN .rough-node .label,#mermaid-svg-KrPuR9WyN5CyrIHN .node .label,#mermaid-svg-KrPuR9WyN5CyrIHN .image-shape .label,#mermaid-svg-KrPuR9WyN5CyrIHN .icon-shape .label{text-align:center;}#mermaid-svg-KrPuR9WyN5CyrIHN .node.clickable{cursor:pointer;}#mermaid-svg-KrPuR9WyN5CyrIHN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KrPuR9WyN5CyrIHN .arrowheadPath{fill:#333333;}#mermaid-svg-KrPuR9WyN5CyrIHN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KrPuR9WyN5CyrIHN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KrPuR9WyN5CyrIHN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KrPuR9WyN5CyrIHN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KrPuR9WyN5CyrIHN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KrPuR9WyN5CyrIHN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster text{fill:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN .cluster span{color:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KrPuR9WyN5CyrIHN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KrPuR9WyN5CyrIHN rect.text{fill:none;stroke-width:0;}#mermaid-svg-KrPuR9WyN5CyrIHN .icon-shape,#mermaid-svg-KrPuR9WyN5CyrIHN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KrPuR9WyN5CyrIHN .icon-shape p,#mermaid-svg-KrPuR9WyN5CyrIHN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KrPuR9WyN5CyrIHN .icon-shape .label rect,#mermaid-svg-KrPuR9WyN5CyrIHN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KrPuR9WyN5CyrIHN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KrPuR9WyN5CyrIHN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KrPuR9WyN5CyrIHN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} NotebookRecord
有封面
有简介
瞬间数 >= 8
封面高度
限制 132 到 230
卡片总高度
估算左右列高度
WaterFlow 容器高度

完整代码

下面这套代码是从《时光旅记》当前实现整理出来的完整接入版本。真实项目里这些代码分散在模型、设置页、主页面、发现页和持久化工具里,文章里我按接入顺序放出来。

1. 模型里增加布局枚举和 store 字段

文件:entry/src/main/ets/model/TimeImprintModels.ets

arkts 复制代码
export enum NotebookDiscoveryLayoutMode {
  GRID = 0,
  WATERFALL = 1
}

export class TimeImprintStore {
  // 这里只展示和本能力相关的字段,其他业务字段保持原样。
  notebookDiscoveryLayoutMode: number = NotebookDiscoveryLayoutMode.GRID;
  notebookDiscoveryTextOverlayEnabled: boolean = false;
}

2. 设置页提供"双列卡片 / 瀑布流"切换

文件:entry/src/main/ets/pages/settings/SettingsPage.ets

arkts 复制代码
@State notebookDiscoveryLayoutMode: NotebookDiscoveryLayoutMode = NotebookDiscoveryLayoutMode.GRID;
@State notebookDiscoveryTextOverlayEnabled: boolean = false;
@State settingsRefreshVersion: number = 0;

private getSavedNotebookDiscoveryLayoutMode(): NotebookDiscoveryLayoutMode {
  if (this.store.notebookDiscoveryLayoutMode === NotebookDiscoveryLayoutMode.WATERFALL) {
    return NotebookDiscoveryLayoutMode.WATERFALL;
  }
  return NotebookDiscoveryLayoutMode.GRID;
}

private getNotebookDiscoveryLayoutModeLabel(): string {
  if (this.notebookDiscoveryLayoutMode === NotebookDiscoveryLayoutMode.WATERFALL) {
    return '瀑布流';
  }
  return '双列卡片';
}

@Builder
private buildNotebookDiscoverySettingCard(): void {
  Column({ space: 14 }) {
    Row({ space: 12 }) {
      Column() {
        SymbolGlyph($r('sys.symbol.rectangle_grid_2x2_fill'))
          .fontSize(20)
          .fontColor([$r('app.color.text_inverse')])
      }
      .width(40)
      .height(40)
      .borderRadius(14)
      .justifyContent(FlexAlign.Center)
      .linearGradient({
        angle: 135,
        colors: [[ThemePalette.accentPrimary(), 0.0], [ThemePalette.accentGradientEnd(), 1.0]]
      })

      Column({ space: 2 }) {
        Text('发现页我的时光')
          .fontSize(18)
          .fontWeight(500)
          .fontColor(ThemePalette.textPrimary())
        Text(this.getNotebookDiscoveryLayoutModeLabel())
          .fontSize(12)
          .fontColor(ThemePalette.textTertiary())
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row({ space: 6 }) {
      this.buildNotebookDiscoveryLayoutButton(NotebookDiscoveryLayoutMode.GRID, '双列卡片')
      this.buildNotebookDiscoveryLayoutButton(NotebookDiscoveryLayoutMode.WATERFALL, '瀑布流')
    }
    .width('100%')

    Row({ space: 12 }) {
      Column({ space: 2 }) {
        Text('文字叠放到封面')
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor(ThemePalette.textPrimary())
        Text(this.notebookDiscoveryTextOverlayEnabled ? '标题和摘要显示在图片左下角' : '标题和摘要显示在卡片底部')
          .fontSize(12)
          .fontColor(ThemePalette.textTertiary())
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Toggle({ isOn: this.notebookDiscoveryTextOverlayEnabled, type: ToggleType.Switch })
        .onChange((value: boolean) => {
          this.handleNotebookDiscoveryTextOverlayToggle(value);
        })
    }
    .width('100%')
    .padding(14)
    .backgroundColor(ThemePalette.surfaceSecondary())
    .borderRadius(18)
    .alignItems(VerticalAlign.Center)

    Text('瀑布流会按每个时光的封面、简介和瞬间数量自动拉开卡片高度;开启叠放后,文字会进入封面左下角蒙层,整体更像照片流。长按仍可进入整理模式。')
      .fontSize(13)
      .lineHeight(20)
      .fontColor(ThemePalette.textSecondary())
  }
  .width('100%')
  .padding(18)
  .backgroundColor(ThemePalette.surfacePrimary())
  .borderRadius(24)
  .border({ width: 1, color: ThemePalette.dividerSubtle() })
  .shadow({ radius: 3, color: $r('app.color.shadow_medium'), offsetY: 1 })
}

@Builder
private buildNotebookDiscoveryLayoutButton(mode: NotebookDiscoveryLayoutMode, title: string): void {
  Button(title, { type: ButtonType.Normal })
    .layoutWeight(1)
    .height(42)
    .fontSize(14)
    .fontWeight(500)
    .fontColor(this.notebookDiscoveryLayoutMode === mode ? $r('app.color.text_inverse') : ThemePalette.textSecondary())
    .backgroundColor(this.notebookDiscoveryLayoutMode === mode ? Color.Transparent : ThemePalette.surfaceTertiary())
    .borderRadius(14)
    .linearGradient(this.notebookDiscoveryLayoutMode === mode ? {
      angle: 90,
      colors: [[ThemePalette.accentPrimary(), 0.0], [ThemePalette.accentGradientEnd(), 1.0]]
    } : null)
    .shadow(this.notebookDiscoveryLayoutMode === mode ? { radius: 6, color: $r('app.color.shadow_medium'), offsetY: 4 } : null)
    .scale({
      x: this.notebookDiscoveryLayoutMode === mode ? 1 : 0.97,
      y: this.notebookDiscoveryLayoutMode === mode ? 1 : 0.97
    })
    .animation({ duration: 320, curve: Curve.FastOutSlowIn })
    .onClick(() => {
      this.applyNotebookDiscoveryLayoutModePreference(mode);
    })
}

private applyNotebookDiscoveryLayoutModePreference(mode: NotebookDiscoveryLayoutMode): void {
  const nextMode: NotebookDiscoveryLayoutMode = mode === NotebookDiscoveryLayoutMode.WATERFALL
    ? NotebookDiscoveryLayoutMode.WATERFALL
    : NotebookDiscoveryLayoutMode.GRID;
  this.notebookDiscoveryLayoutMode = nextMode;
  this.store.notebookDiscoveryLayoutMode = nextMode;
  this.settingsRefreshVersion += 1;
  persistTimeImprintStore();
  publishTimeImprintStoreChange(this.store);
  promptAction.showToast({
    message: nextMode === NotebookDiscoveryLayoutMode.WATERFALL
      ? '我的时光将使用瀑布流'
      : '我的时光将使用双列卡片',
    duration: 1500
  });
}

private handleNotebookDiscoveryTextOverlayToggle(value: boolean): void {
  this.notebookDiscoveryTextOverlayEnabled = value;
  this.store.notebookDiscoveryTextOverlayEnabled = value;
  this.settingsRefreshVersion += 1;
  persistTimeImprintStore();
  publishTimeImprintStoreChange(this.store);
}

3. 主页面把配置传给发现页

文件:entry/src/main/ets/pages/shell/MainPage.ets

arkts 复制代码
NotebookTab({
  tabScroller: this.notebookTabScroller,
  selectedTabIndexes: $notebookTabSelectedIndexes,
  notebookSearchInput: $notebookSearchInput,
  notebookSceneFilterInput: $notebookSceneFilterInput,
  notebookTabScrollY: $notebookTabScrollY,
  floatingActionOnLeft: this.floatingActionOnLeft,
  floatingActionSwapDirection: this.floatingActionSwapDirection,
  showNotebookTypeChooser: this.showNotebookTypeChooser,
  refreshVersion: this.refreshVersion,
  currentColorModeSignal: this.currentColorMode,
  notebookDiscoveryLayoutMode: this.store.notebookDiscoveryLayoutMode,
  notebookDiscoveryTextOverlayEnabled: this.store.notebookDiscoveryTextOverlayEnabled,
  insightOrderIds: this.store.allToolsInsightOrderIds,
  onGetTabContentBottomPadding: () => this.getTabContentBottomPadding(),
  onGetNotebookDiscoveryNotebooks: (limit: number) => this.getNotebookDiscoveryNotebooks(limit),
  onOpenNotebook: (notebookId: string) => this.openNotebook(notebookId),
  onMoveNotebook: (draggedNotebookId: string, targetNotebookId: string) => this.moveNotebook(draggedNotebookId, targetNotebookId),
  onConfirmDeleteNotebook: (notebookId: string) => this.confirmDeleteNotebook(notebookId)
})

上面只列出和瀑布流最相关的参数。真实项目里 NotebookTab 还接了搜索、场景筛选、功能中心、同步入口等回调,不影响瀑布流能力本身。

4. 发现页用 WaterFlow 渲染小本卡片

文件:entry/src/main/ets/pages/shell/tabs/NotebookTab.ets

arkts 复制代码
import { LengthMetrics, curves, display, router } from '@kit.ArkUI';
import { formatMomentTime, getNotebookSceneLabel, isNotebookPrivacyProtected, normalizeNotebookScene } from '../../../utils/TimeImprintService';
import {
  MomentRecord,
  NotebookDiscoveryLayoutMode,
  NotebookKind,
  NotebookRecord
} from '../../../model/TimeImprintModels';
import { ThemePalette } from '../../../utils/ThemePalette';

@Component
export struct NotebookTab {
  tabScroller: Scroller = new Scroller();
  @Link selectedTabIndexes: number[];
  @Link notebookSearchInput: string;
  @Link notebookSceneFilterInput: string;
  @Link notebookTabScrollY: number;
  @Prop notebookDiscoveryLayoutMode: number = NotebookDiscoveryLayoutMode.GRID;
  @Prop notebookDiscoveryTextOverlayEnabled: boolean = false;
  @State draggingNotebookId: string = '';
  @State dragTargetNotebookId: string = '';
  @State notebookEditMode: boolean = false;
  @State notebookEditShakePhase: boolean = false;
  @State visibleNotebookOrderIds: Array<string> = [];
  @State notebookOrderRenderVersion: number = 0;
  @State notebookFilterRenderVersion: number = 0;
  @State isVisible: boolean = false;
  private notebookWaterfallScroller: Scroller = new Scroller();
  private notebookEditShakeTimer: number = -1;

  onGetNotebookDiscoveryNotebooks: (limit: number) => Array<NotebookRecord> = () => [];
  onOpenNotebook: (notebookId: string) => void = () => {};
  onMoveNotebook: (draggedNotebookId: string, targetNotebookId: string) => void = () => {};
  onConfirmDeleteNotebook: (notebookId: string) => void = () => {};

  @BuilderParam buildManagedImage: (
    uri: string,
    width: Length,
    height: Length,
    radius: number,
    fit: ImageFit,
    opacity: number
  ) => void = this.buildEmptyManagedImage;

  @Builder
  private buildEmptyManagedImage(uri: string, width: Length, height: Length, radius: number, fit: ImageFit, opacity: number): void {
    Image(uri)
      .width(width)
      .height(height)
      .borderRadius(radius)
      .objectFit(fit)
      .opacity(opacity)
  }

  @Builder
  private buildNotebookDiscoverySection(): void {
    if (this.getVisibleNotebooks().length === 0) {
      this.buildNotebookEmptyState()
    } else if (this.shouldUseNotebookWaterfallLayout()) {
      WaterFlow({ scroller: this.notebookWaterfallScroller }) {
        ForEach(this.getVisibleNotebooks(), (notebook: NotebookRecord, index: number) => {
          FlowItem() {
            this.buildNotebookDiscoveryTile(notebook, index, '100%', this.getNotebookWaterfallCoverHeight(notebook, index))
          }
          .width('100%')
        }, (notebook: NotebookRecord) => notebook.id)
      }
      .key('notebook-waterfall-' + this.notebookDiscoveryLayoutMode.toString() + '-' +
        this.notebookOrderRenderVersion.toString() + '-' + this.notebookFilterRenderVersion.toString())
      .columnsTemplate('1fr 1fr')
      .columnsGap(12)
      .rowsGap(14)
      .width('100%')
      .height(this.getNotebookWaterfallHeight())
      .scrollBar(BarState.Off)
      .enableScrollInteraction(false)
      .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: false })
    } else {
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Start }) {
        ForEach(this.getVisibleNotebooks(), (notebook: NotebookRecord, index: number) => {
          this.buildNotebookDiscoveryTile(notebook, index, '48%', 140)
        }, (notebook: NotebookRecord) => notebook.id)
        if (this.getVisibleNotebooks().length === 1) {
          Column().width('48%')
        }
      }
      .width('100%')
    }
  }

  @Builder
  private buildNotebookEmptyState(): void {
    Column({ space: 12 }) {
      Text('暂无时光')
        .fontSize(16)
        .fontColor(ThemePalette.textTertiary())
    }
    .width('100%')
    .padding({ top: 40 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  private buildNotebookDiscoveryTile(notebook: NotebookRecord, index: number, tileWidth: Length, coverHeight: number): void {
    Column() {
      Stack({ alignContent: Alignment.TopStart }) {
        if (!this.isMaskedNotebook(notebook) && notebook.coverUri && notebook.coverUri.length > 0) {
          this.buildManagedImage(notebook.coverUri, '100%', coverHeight, 0, ImageFit.Cover, 1)
        } else {
          this.buildNotebookPlaceholderCover(notebook, coverHeight, !this.shouldOverlayNotebookText())
        }

        if (this.shouldOverlayNotebookText()) {
          this.buildNotebookCoverTextOverlay(notebook)
        }

        Text(this.getNotebookTypeLabel(notebook))
          .fontSize(11)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.shouldOverlayNotebookText() ? $r('app.color.text_inverse') : ThemePalette.accentPrimary())
          .padding({ left: 10, right: 10, top: 6, bottom: 6 })
          .backgroundColor(this.shouldOverlayNotebookText() ? 'rgba(0,0,0,0.30)' : $r('app.color.home_glass'))
          .borderRadius(14)
          .margin({ left: 10, top: 10 })
          .zIndex(2)

        if (this.notebookEditMode) {
          Button() {
            SymbolGlyph($r('sys.symbol.xmark'))
              .fontSize(15)
              .fontColor([$r('app.color.text_inverse')])
          }
          .width(32)
          .height(32)
          .position({ right: 8, top: 8 })
          .backgroundColor('#C93C32')
          .borderRadius(16)
          .shadow({ radius: 8, color: 'rgba(201,60,50,0.28)', offsetY: 3 })
          .zIndex(3)
          .accessibilityText('删除时光')
          .accessibilityDescription('双击确认删除或移入回收站')
          .onClick(() => {
            this.onConfirmDeleteNotebook(notebook.id)
          })
        }
      }
      .width('100%')
      .height(coverHeight)

      if (!this.shouldOverlayNotebookText()) {
        this.buildNotebookBottomTextBlock(notebook)
      }
    }
    .width(tileWidth)
    .margin({ bottom: this.shouldUseNotebookWaterfallLayout() ? 0 : 16 })
    .opacity(this.draggingNotebookId === notebook.id ? 0.72 : 1)
    .translate({ x: this.getNotebookEditJitterOffset(index), y: this.isVisible ? 0 : 28 })
    .scale({ x: this.isVisible ? 1 : 0.95, y: this.isVisible ? 1 : 0.95 })
    .rotate({ angle: this.getNotebookEditRotationAngle(index) })
    .borderRadius(24)
    .border({
      width: this.dragTargetNotebookId === notebook.id ? 2 : 0,
      color: this.dragTargetNotebookId === notebook.id ? ThemePalette.accentPrimary() : 'rgba(0,0,0,0)'
    })
    .clip(true)
    .shadow({ radius: 8, color: $r('app.color.shadow_soft'), offsetY: 2 })
    .animation({
      duration: this.notebookEditMode ? 120 : 440,
      delay: this.notebookEditMode ? 0 : Math.min(index, 5) * 70,
      curve: this.notebookEditMode ? Curve.EaseInOut : curves.springMotion()
    })
    .gesture(
      LongPressGesture({ fingers: 1, repeat: false, duration: this.getNotebookLongPressDuration() })
        .onAction(() => {
          this.enterNotebookEditMode()
        })
    )
    .draggable(this.canStartNotebookDrag())
    .onDragStart(() => {
      if (!this.canStartNotebookDrag()) {
        return;
      }
      if (!this.notebookEditMode) {
        this.enterNotebookEditMode();
      }
      this.draggingNotebookId = notebook.id
      this.dragTargetNotebookId = ''
      return { extraInfo: notebook.id }
    })
    .onDragEnter(() => {
      if (this.draggingNotebookId.length === 0 || this.draggingNotebookId === notebook.id) {
        return;
      }
      this.dragTargetNotebookId = notebook.id
    })
    .onDragMove((event?: DragEvent) => {
      if (event === undefined) {
        return;
      }
      if (this.draggingNotebookId.length === 0 || this.draggingNotebookId === notebook.id) {
        event.setResult(DragResult.DROP_DISABLED)
        return;
      }
      this.dragTargetNotebookId = notebook.id
      event.setResult(DragResult.DROP_ENABLED)
      event.dragBehavior = DragBehavior.MOVE
    })
    .onDragLeave(() => {
      if (this.dragTargetNotebookId === notebook.id) {
        this.dragTargetNotebookId = ''
      }
    })
    .onDrop((event?: DragEvent) => {
      if (event !== undefined) {
        if (this.draggingNotebookId.length > 0 && this.draggingNotebookId !== notebook.id) {
          this.applyLocalNotebookDropOrder(this.draggingNotebookId, notebook.id);
          this.onMoveNotebook(this.draggingNotebookId, notebook.id)
          event.setResult(DragResult.DRAG_SUCCESSFUL)
        } else {
          event.setResult(DragResult.DRAG_CANCELED)
        }
      }
      this.clearNotebookDragState()
    })
    .onDragEnd(() => {
      this.clearNotebookDragState()
    })
    .onClick(() => {
      if (this.notebookEditMode) {
        return;
      }
      this.persistNotebookTabScrollOffset();
      this.onOpenNotebook(notebook.id)
    })
  }

  @Builder
  private buildNotebookBottomTextBlock(notebook: NotebookRecord): void {
    Column({ space: 4 }) {
      Text(notebook.name || '未命名时光')
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .fontColor(ThemePalette.textPrimary())
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      Text(this.getNotebookSummaryLabel(notebook))
        .fontSize(12)
        .fontColor(ThemePalette.textTertiary())

      if (this.shouldUseNotebookWaterfallLayout() && this.getNotebookWaterfallIntro(notebook).length > 0) {
        Text(this.getNotebookWaterfallIntro(notebook))
          .fontSize(12)
          .lineHeight(18)
          .fontColor(ThemePalette.textSecondary())
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 20 })
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(ThemePalette.surfacePrimary())
  }

  @Builder
  private buildNotebookCoverTextOverlay(notebook: NotebookRecord): void {
    Stack({ alignContent: Alignment.BottomStart }) {
      Column()
        .width('100%')
        .height('100%')
        .linearGradient({
          angle: 180,
          colors: [
            ['rgba(0,0,0,0.00)', 0.0],
            ['rgba(0,0,0,0.18)', 0.45],
            ['rgba(0,0,0,0.66)', 1.0]
          ]
        })

      Column({ space: 4 }) {
        Text(notebook.name || '未命名时光')
          .fontSize(this.shouldUseNotebookWaterfallLayout() ? 17 : 16)
          .fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.text_inverse'))
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .textShadow({ radius: 6, color: 'rgba(0,0,0,0.36)', offsetX: 0, offsetY: 2 })

        Text(this.getNotebookSummaryLabel(notebook))
          .fontSize(12)
          .fontWeight(FontWeight.Medium)
          .fontColor('rgba(255,255,255,0.82)')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        if (this.shouldUseNotebookWaterfallLayout() && this.getNotebookWaterfallIntro(notebook).length > 0) {
          Text(this.getNotebookWaterfallIntro(notebook))
            .fontSize(11)
            .lineHeight(16)
            .fontColor('rgba(255,255,255,0.76)')
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }
      }
      .width('100%')
      .padding({ left: 14, right: 14, bottom: 14 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  private buildNotebookPlaceholderCover(notebook: NotebookRecord, height: number, showText: boolean = true): void {
    Column({ space: 8 }) {
      if (showText) {
        Text(notebook.name || '未命名时光')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(ThemePalette.textPrimary())
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.getNotebookPlaceholderDescription(notebook))
          .fontSize(12)
          .lineHeight(18)
          .fontColor(ThemePalette.textSecondary())
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .width('100%')
    .height(height)
    .padding(16)
    .alignItems(HorizontalAlign.Start)
    .justifyContent(FlexAlign.End)
    .linearGradient({
      angle: 135,
      colors: [[ThemePalette.accentSurface(), 0.0], [$r('app.color.warning_surface'), 1.0]]
    })
  }

  private getVisibleNotebooks(): Array<NotebookRecord> {
    const notebooks: Array<NotebookRecord> = this.onGetNotebookDiscoveryNotebooks(0);
    if (this.visibleNotebookOrderIds.length === 0) {
      return notebooks;
    }
    let ordered: Array<NotebookRecord> = [];
    for (let i: number = 0; i < this.visibleNotebookOrderIds.length; i++) {
      const id: string = this.visibleNotebookOrderIds[i];
      const matched: NotebookRecord | undefined = notebooks.find((notebook: NotebookRecord) => notebook.id === id);
      if (matched !== undefined) {
        ordered.push(matched);
      }
    }
    for (let i: number = 0; i < notebooks.length; i++) {
      if (this.visibleNotebookOrderIds.indexOf(notebooks[i].id) < 0) {
        ordered.push(notebooks[i]);
      }
    }
    return ordered;
  }

  private persistNotebookTabScrollOffset(): void {
    const yOffset: number = this.tabScroller.currentOffset().yOffset;
    this.notebookTabScrollY = Math.max(0, yOffset);
  }

  private shouldUseNotebookWaterfallLayout(): boolean {
    return this.notebookDiscoveryLayoutMode === NotebookDiscoveryLayoutMode.WATERFALL;
  }

  private shouldOverlayNotebookText(): boolean {
    return this.notebookDiscoveryTextOverlayEnabled === true;
  }

  private getNotebookWaterfallHeight(): number {
    const notebooks: Array<NotebookRecord> = this.getVisibleNotebooks();
    if (notebooks.length <= 2) {
      return 300;
    }
    let leftColumnHeight: number = 0;
    let rightColumnHeight: number = 0;
    for (let i: number = 0; i < notebooks.length; i++) {
      const tileHeight: number = this.getNotebookWaterfallTileHeight(notebooks[i], i) + 14;
      if (leftColumnHeight <= rightColumnHeight) {
        leftColumnHeight += tileHeight;
      } else {
        rightColumnHeight += tileHeight;
      }
    }
    return Math.max(420, Math.max(leftColumnHeight, rightColumnHeight) + 4);
  }

  private getNotebookWaterfallCoverHeight(notebook: NotebookRecord, index: number): number {
    let height: number = 132 + (index % 3) * 18;
    if (!this.isMaskedNotebook(notebook) && notebook.coverUri.length > 0) {
      height += 28;
    }
    if (this.getNotebookWaterfallIntro(notebook).length > 0) {
      height += 18;
    }
    if (notebook.moments.length >= 8) {
      height += 20;
    }
    return Math.min(230, Math.max(132, height));
  }

  private getNotebookWaterfallTileHeight(notebook: NotebookRecord, index: number): number {
    if (this.shouldOverlayNotebookText()) {
      return this.getNotebookWaterfallCoverHeight(notebook, index);
    }
    const introHeight: number = this.getNotebookWaterfallIntro(notebook).length > 0 ? 42 : 0;
    return this.getNotebookWaterfallCoverHeight(notebook, index) + 76 + introHeight;
  }

  private getNotebookWaterfallIntro(notebook: NotebookRecord): string {
    if (this.isMaskedNotebook(notebook)) {
      return '';
    }
    return notebook.intro.trim();
  }

  private isMaskedNotebook(notebook: NotebookRecord): boolean {
    return isNotebookPrivacyProtected(notebook) && notebook.intro === '__protected_masked__';
  }

  private getNotebookTypeLabel(notebook: NotebookRecord): string {
    let sceneLabel: string = getNotebookSceneLabel(notebook.scene);
    if (isNotebookPrivacyProtected(notebook)) {
      return sceneLabel + ' · 已保护';
    }
    return sceneLabel;
  }

  private getNotebookSummaryLabel(notebook: NotebookRecord): string {
    if (this.isMaskedNotebook(notebook)) {
      return '需要验证后查看内容';
    }
    return `${notebook.moments.length} 条瞬间`;
  }

  private getNotebookPlaceholderDescription(notebook: NotebookRecord): string {
    if (this.isMaskedNotebook(notebook)) {
      return '已开启隐私保护,验证后查看封面和内容。';
    }
    if (notebook.intro.length > 0) {
      return notebook.intro;
    }
    return '默认从照片生成封面';
  }

  private canStartNotebookDrag(): boolean {
    return this.getVisibleNotebooks().length > 1;
  }

  private applyLocalNotebookDropOrder(draggedNotebookId: string, targetNotebookId: string): void {
    let ids: Array<string> = this.visibleNotebookOrderIds.length > 0
      ? this.visibleNotebookOrderIds.slice(0)
      : this.getVisibleNotebooks().map((notebook: NotebookRecord): string => notebook.id);
    let sourceIndex: number = ids.indexOf(draggedNotebookId);
    let targetIndex: number = ids.indexOf(targetNotebookId);
    if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
      return;
    }
    ids.splice(sourceIndex, 1);
    ids.splice(targetIndex, 0, draggedNotebookId);
    this.visibleNotebookOrderIds = ids;
    this.notebookOrderRenderVersion = this.notebookOrderRenderVersion + 1;
  }

  private getNotebookLongPressDuration(): number {
    return this.notebookEditMode ? 700 : 360;
  }

  private clearNotebookDragState(): void {
    this.draggingNotebookId = ''
    this.dragTargetNotebookId = ''
  }

  private getNotebookEditRotationAngle(index: number): number {
    if (!this.notebookEditMode || this.draggingNotebookId.length > 0) {
      return 0;
    }
    let direction: number = index % 2 === 0 ? 1 : -1;
    return direction * (this.notebookEditShakePhase ? 1.2 : -1.2);
  }

  private getNotebookEditJitterOffset(index: number): number {
    if (!this.notebookEditMode || this.draggingNotebookId.length > 0) {
      return 0;
    }
    let direction: number = index % 2 === 0 ? 1 : -1;
    return direction * (this.notebookEditShakePhase ? 1.5 : -1.5);
  }

  private enterNotebookEditMode(): void {
    if (this.notebookEditMode || this.getVisibleNotebooks().length === 0) {
      return;
    }
    this.notebookEditMode = true;
    this.clearNotebookDragState();
    this.startNotebookEditShake();
  }

  private startNotebookEditShake(): void {
    this.stopNotebookEditShake();
    this.notebookEditShakePhase = false;
    this.notebookEditShakeTimer = setInterval(() => {
      this.notebookEditShakePhase = !this.notebookEditShakePhase;
    }, 140);
  }

  private stopNotebookEditShake(): void {
    if (this.notebookEditShakeTimer >= 0) {
      clearInterval(this.notebookEditShakeTimer);
      this.notebookEditShakeTimer = -1;
    }
    this.notebookEditShakePhase = false;
  }
}

5. 持久化读写布局偏好

文件:entry/src/main/ets/utils/TimeImprintPersistence.ets

arkts 复制代码
function sanitizeNotebookDiscoveryLayoutMode(value: number | undefined): number {
  return value === NotebookDiscoveryLayoutMode.WATERFALL
    ? NotebookDiscoveryLayoutMode.WATERFALL
    : NotebookDiscoveryLayoutMode.GRID;
}

function readTimeImprintSnapshotFromPreferences(prefs: preferences.Preferences, defaults: TimeImprintStore): TimeImprintStore {
  let snapshot: TimeImprintStore = new TimeImprintStore();
  snapshot.notebookDiscoveryLayoutMode = sanitizeNotebookDiscoveryLayoutMode(readPreferenceNumber(
    prefs,
    'notebookDiscoveryLayoutMode',
    defaults.notebookDiscoveryLayoutMode
  ));
  snapshot.notebookDiscoveryTextOverlayEnabled = readPreferenceBoolean(
    prefs,
    'notebookDiscoveryTextOverlayEnabled',
    defaults.notebookDiscoveryTextOverlayEnabled
  );
  return snapshot;
}

function writeTimeImprintSnapshotToPreferences(prefs: preferences.Preferences, snapshot: TimeImprintStore): void {
  prefs.putSync('notebookDiscoveryLayoutMode', sanitizeNotebookDiscoveryLayoutMode(snapshot.notebookDiscoveryLayoutMode));
  prefs.putSync('notebookDiscoveryTextOverlayEnabled', snapshot.notebookDiscoveryTextOverlayEnabled);
}

真实项目的持久化函数里还有大量其他字段,这里只抽出和瀑布流相关的读写。关键点是读写都走 sanitizeNotebookDiscoveryLayoutMode(),外部存了非法值也会回到 GRID,不会让页面进入未知布局。

我在项目里踩过的几个点

第一个点是 WaterFlow 的高度。这个页面不是单独一个全屏瀑布流,而是发现页里的一个区域。如果让 WaterFlow 自己滚,用户会遇到内外两层滚动。所以我让外层页面滚动,WaterFlow 只铺开内容,内部关闭滚动交互。

第二个点是卡片复用。瀑布流和双列模式不要写两套卡片 UI,否则后面加隐私保护、整理模式、拖拽排序时很容易漏一边。《时光旅记》里只保留一个 buildNotebookDiscoveryTile(),通过参数控制宽度和封面高度。

第三个点是刷新 key。拖拽排序、本地筛选、布局切换都会改变排列结果,所以 WaterFlow 上的 .key() 拼了布局模式、排序版本、筛选版本。这个做法比较直接,但在这种区域列表里很稳。

第四个点是高度不要完全随机。瀑布流需要错落感,但随机高度会导致用户每次进入页面看到的排列都变,体验不稳定。我这里用 index 和小本真实内容共同决定高度,同一批数据的视觉结构是稳定的。

什么时候该用 LazyForEach

当前发现页小本数量通常不会像时间轴那样增长到特别大,所以项目里这里还是 ForEach + WaterFlow。如果你的瀑布流是公开广场、图片流、商品流,数据会分页增长,那就应该把 ForEach 换成 LazyForEach + IDataSource,因为 WaterFlow 本身支持作为懒加载容器。

《时光旅记》里时间轴已经用了 LazyForEach + IDataSource,实现思路可以迁移过来:把小本数组转成数据源,实现 totalCount()getData()registerDataChangeListener()unregisterDataChangeListener(),数据变化后通知 onDataReloaded()。但不要为了"看起来高级"提前上复杂数据源,小数据量下普通 ForEach 更容易维护。

参考资料

华为官方文档:

https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-waterflow

最后总结一下:这次接入的重点不是 WaterFlow 这一行 API,而是把它放进真实 APP 的状态、设置、持久化、主题、图片加载和交互链路里。对《时光旅记》这种以照片和回忆为核心的应用来说,瀑布流让"小本"更像一本本被翻开的相册,而不是一组固定高度的功能卡片。