![# 平板分栏一打开,右侧为什么整片空白:HarmonyOS 7 splitPlaceholder 与默认详情路由实战
同一套菜谱页在手机上没有问题,到了平板或折叠屏展开态却像“少渲染了一半”:左侧分类列表正常,右侧内容区是一整块空白;用户拖动分栏线后,详情区还可能被压得只剩标题和半个按钮。日志没有崩溃,路由表也能找到详情页,问题却稳定复现。
这类故障通常不是 NavDestination 构建失败,而是把三个不同状态混成了一个:Navigation 已进入分栏模式、NavPathStack 仍为空、内容区又没有默认占位。HarmonyOS 当前官方文档提供 splitPlaceholder 处理“分栏 + 空栈”展示,同时用 navBarWidthRange 和 minContentWidth 约束可拖拽范围。需要特别注意,splitPlaceholder 只是 UI 展示页,不属于路由栈,也不能获焦或响应事件。
本文交付一套可直接复用的分栏切片:空栈占位、可操作默认详情、拖拽宽度约束、状态日志和纯函数断言。当前机器只有 API 24 SDK 且没有 HDC 设备,因此下列代码依据当前官方 API 设计,但不声称完成 API 26 编译或真机分栏验证。
先把四个状态跑清楚
不要一上来就改路由表。准备一个只有 6 条菜谱的最小页面,用同一个 NavPathStack 依次执行四条路径。
| 路径 | Navigation 状态 | 路由栈 | 预期画面 |
|---|---|---|---|
| A | 单栏 | 空 | 显示左侧首页内容,不展示 splitPlaceholder |
| B | 分栏 | 空 | 左侧列表 + 右侧静态占位,不出现整片空白 |
| C | 分栏 | 1 个详情 | 左侧列表 + 右侧 NavDestination 详情 |
| D | 分栏拖拽 | 1 个详情 | navBar 与内容区都不小于约束值 |
如果路径 B 为空白,先查 splitPlaceholder;如果 B 正常而 C 空白,再查 navDestination 映射和 pushPathByName;如果 D 才坏,查 navBarWidthRange、minContentWidth 和外层容器实际宽度。三类问题不要用同一个“路由失败”概括。
splitPlaceholder 不是默认详情页
官方分栏指南说明,Navigation 的 mode 可以控制单栏、分栏、自适应和按高宽比自适应。分栏模式下,navBar 固定显示,NavDestination 在内容区展示。栈为空时内容区默认可能留白,splitPlaceholder 用来提供占位画面。
但占位页有两个硬边界:
- 只在分栏模式且路由栈为空时出现。
- 只负责展示,不受路由栈管理,不可获焦,也不能响应事件。
因此,占位页适合放“从左侧选择一道菜”“当前没有选中项”这类说明,不适合放“立即打开默认菜谱”按钮。若产品要求用户一进入平板页就能操作默认详情,应显式 push 一条真实路由,而不是给占位页绑定点击事件。
案例一:菜谱分类页用静态占位消除空白
先定义一个没有交互的占位 Builder,再创建 ComponentContent。占位里只表达当前状态,不放按钮、输入框和焦点控件。
import{ComponentContent}from'@kit.ArkUI';@BuilderfunctionRecipeSplitPlaceholder(){Column({space:18}){SymbolGlyph($r('sys.symbol.rectangle_3_group')).fontSize(52).fontColor([$r('sys.color.font_secondary')])Text('从左侧选择一道菜').fontSize(22).fontWeight(FontWeight.Medium)Text('这里会显示食材、步骤和烹饪提示').fontSize(15).fontColor($r('sys.color.font_secondary'))}.justifyContent(FlexAlign.Center).width('100%').height('100%')}interfaceRecipeRouteParam{recipeId:string;title:string;}页面持有唯一 NavPathStack。左侧列表点击时,才把 RecipeDetail 推进栈。mode 使用 AUTO_WITH_ASPECT_RATIO 交给 Navigation 根据当前形态选择单双栏;拖拽条、导航栏宽度范围和内容区最小宽度作为一组配置出现。
@Entry@Componentstruct RecipeLibraryPage{privatepathStack:NavPathStack=newNavPathStack();privateplaceholder:ComponentContent=newComponentContent(this.getUIContext(),wrapBuilder(RecipeSplitPlaceholder));privaterecipes:RecipeRouteParam[]=[{recipeId:'mapo-tofu',title:'麻婆豆腐'},{recipeId:'steamed-fish',title:'清蒸鲈鱼'},{recipeId:'lotus-soup',title:'莲藕排骨汤'}];@BuilderprivatenavList(){List(){ForEach(this.recipes,(item:RecipeRouteParam)=>{ListItem(){Text(item.title).width('100%').padding(18)}.onClick(()=>{this.pathStack.pushPathByName('RecipeDetail',item);})},(item:RecipeRouteParam)=>item.recipeId)}}@BuilderprivatepageMap(name:string,param:object){if(name==='RecipeDetail'){RecipeDetailPage({param:paramasRecipeRouteParam})}}build(){Navigation(this.pathStack){this.navList()}.mode(NavigationMode.AUTO_WITH_ASPECT_RATIO).navDestination(this.pageMap).enableDragBar(true).navBarWidthRange([280,420]).minContentWidth(420).splitPlaceholder(this.placeholder).hideTitleBar(true).width('100%').height('100%')}}这段代码的关键不是“加了一个占位组件”,而是把空栈状态公开表达出来。用户尚未选择菜谱时看到说明;点击列表后,真实 NavDestination 取代占位;回退到空栈时,占位再次出现。占位本身从不伪装成可操作页面。
怎么验证
- 宽屏启动后,不点击任何列表项,确认右侧出现占位。
- 点击“麻婆豆腐”,记录 pathStack.size() 从 0 变为 1,右侧显示 RecipeDetail。
- 执行 pop,确认栈回到 0,右侧重新显示占位。
- 切为单栏,确认占位不在首页上叠加。
不要只截最终详情页。空栈、入栈和出栈三个时间点都要有状态记录,才能证明不是偶然渲染成功。
案例二:默认就要可操作时,推真实路由
另一个需求是“平板打开后默认显示热门菜谱,用户能立即收藏或加入购物清单”。这时 splitPlaceholder 不合适,因为官方明确说明它不可获焦和响应事件。正确做法是在满足业务条件时只推一次默认路由。
@Entry@Componentstruct FeaturedRecipePage{privatepathStack:NavPathStack=newNavPathStack();@StateprivatedefaultRoutePrepared:boolean=false;aboutToAppear():void{this.openDefaultDetailOnce();}privateopenDefaultDetailOnce():void{if(this.defaultRoutePrepared||this.pathStack.size()>0){return;}this.defaultRoutePrepared=true;constparam:RecipeRouteParam={recipeId:'featured-today',title:'今日推荐'};this.pathStack.pushPathByName('RecipeDetail',param,false);}build(){Navigation(this.pathStack){RecipeCategoryList({pathStack:this.pathStack})}.mode(NavigationMode.AUTO_WITH_ASPECT_RATIO).navDestination(this.pageMap).enableDragBar(true).navBarWidthRange([280,420]).minContentWidth(420)}@BuilderprivatepageMap(name:string,param:object){if(name==='RecipeDetail'){RecipeDetailPage({param:paramasRecipeRouteParam})}}}defaultRoutePrepared 防止 aboutToAppear 再次触发时重复 push。与此同时,pathStack.size() 保护用户已经恢复出的路由栈:如果应用从后台回来时已有详情,不能再把“今日推荐”压到栈顶。
两种产品语义要明确区分:
| 需求 | 实现 | 是否进入路由栈 | 能否交互 |
|---|---|---|---|
| 尚未选择,给出说明 | splitPlaceholder | 否 | 否 |
| 默认展示可收藏详情 | pushPathByName | 是 | 是 |
拖拽分栏线时,宽度约束要一起设计
只开启 enableDragBar 但不定义边界,用户可能把左侧拖得过宽,导致详情区按钮折行、图片被挤压或表格横向溢出。navBarWidthRange 约束导航栏可调范围,minContentWidth 约束右侧内容区最小宽度。它们必须结合页面实际最小可用尺寸设计。
例如外层可用宽度是 980vp,导航栏范围为 280–420vp,内容区最小 420vp,剩余空间可以覆盖分割线和两侧最小值。若外层只有 700vp,却仍要求 420vp 详情区和 420vp 导航栏上限,系统最终表现会受到模式切换与布局约束共同影响。不要把“设置了范围”误写成“任何尺寸都能维持双栏”。
可以在业务侧保留一个纯函数,只做配置预检,不模拟 Navigation 内部算法:
typeResolvedMode='Stack'|'Split';interfaceSplitSnapshot{resolvedMode:ResolvedMode;stackSize:number;hostWidth:number;navWidth:number;minContentWidth:number;dividerWidth:number;}interfaceSplitAssessment{state:'stack-home'|'split-placeholder'|'split-detail'|'split-width-risk';contentWidth:number;reasons:string[];}functionassessSplit(snapshot:SplitSnapshot):SplitAssessment{if(snapshot.resolvedMode==='Stack'){return{state:'stack-home',contentWidth:snapshot.hostWidth,reasons:[]};}constcontentWidth=snapshot.hostWidth-snapshot.navWidth-snapshot.dividerWidth;if(contentWidth<snapshot.minContentWidth){return{state:'split-width-risk',contentWidth,reasons:['content-below-minimum']};}if(snapshot.stackSize===0){return{state:'split-placeholder',contentWidth,reasons:['empty-stack']};}return{state:'split-detail',contentWidth,reasons:[]};}这里的 resolvedMode 应来自实际运行状态或测试场景输入,不能由这段函数替系统猜测 AUTO_WITH_ASPECT_RATIO 的切换阈值。它只验证“已进入分栏后,当前宽度与栈状态应该对应哪种业务画面”。
四组宿主断言
conststackHome=assessSplit({resolvedMode:'Stack',stackSize:0,hostWidth:380,navWidth:0,minContentWidth:420,dividerWidth:0});if(stackHome.state!=='stack-home')thrownewError('单栏首页误判');constemptySplit=assessSplit({resolvedMode:'Split',stackSize:0,hostWidth:980,navWidth:320,minContentWidth:420,dividerWidth:1});if(emptySplit.state!=='split-placeholder')thrownewError('空栈占位误判');constdetailSplit=assessSplit({resolvedMode:'Split',stackSize:1,hostWidth:980,navWidth:360,minContentWidth:420,dividerWidth:1});if(detailSplit.state!=='split-detail')thrownewError('详情状态误判');constwidthRisk=assessSplit({resolvedMode:'Split',stackSize:1,hostWidth:700,navWidth:360,minContentWidth:420,dividerWidth:1});if(widthRisk.state!=='split-width-risk')thrownewError('窄内容区没有被拦截');这些断言能检查团队自己的宽度预算和状态映射,不能证明 Navigation 的高宽比模式切换、拖拽手势、焦点行为或动画已经在设备上通过。
日志只记三个事实
分栏故障最怕日志里只有“页面打开成功”。建议每次模式或栈变化时记录:
NavigationSnapshot resolvedMode=Split stackSize=0 hostWidth=980 navWidth=320 contentWidth=659 expectedState=split-placeholder当用户反馈右侧空白时,这组信息可以快速区分:
- resolvedMode=Stack:不是分栏空栈问题。
- resolvedMode=Split 且 stackSize=0:应检查占位是否注入。
- resolvedMode=Split 且 stackSize>0:应检查 pageMap 与 NavDestination。
- contentWidth 小于业务最小值:应检查拖拽范围和外层宽度。
不要在日志中输出完整菜谱参数、用户输入或账号数据。route name、栈大小和尺寸已经足够定位结构问题。
六个高频错误
1. 把占位页做成可点击卡片
splitPlaceholder 不可获焦也不能响应事件。需要交互就推真实 NavDestination,不要让视觉看起来像按钮却无法点击。
2. 空栈时自动 push,每次前台恢复又 push 一次
默认详情必须具备一次性门禁,并先检查 pathStack.size()。否则生命周期重复触发会制造重复详情和异常返回链路。
3. 只设置 navBarWidthRange,不设置 minContentWidth
左侧看似可以拖动,右侧却可能被压到业务不可用。导航栏范围和内容区最低宽度应同时进入设计验收。
4. 用窗口宽度代替 Navigation 实际宽度
Navigation 可能位于更小的容器、分屏窗口或弹性布局中。验收要记录实际 hostWidth,不要只记设备分辨率。
5. hideNavBar 与 Split 语义冲突
官方指南指出,Split 模式同时 hideNavBar(true) 时,实际会表现为单栏。若产品临时隐藏左侧栏,需要重新核对占位和详情的展示预期。
6. 把占位看成路由恢复结果
占位不在路由栈中。进程恢复、跨设备迁移或状态持久化时,应恢复真实 pathStack 数据;看到占位只能说明当前是分栏空栈,不能说明详情恢复成功。
可复用目录
features/recipe-library/ RecipeLibraryPage.ets # Navigation 根容器和分栏参数 RecipeSplitPlaceholder.ets # 纯展示占位,不含交互 RecipeDetailPage.ets # 可操作 NavDestination RecipeRouteParam.ets # 明确的路由参数模型 SplitLayoutPolicy.ts # 宽度预算与状态判断 split_layout_policy.test.ts # 四组宿主断言 docs/navigation-evidence.md # 模式、栈、宽度和截图记录路由根只维护栈和页面映射;占位只表达空状态;详情页负责收藏、购物清单等交互;宽度策略独立测试。四者拆开后,空白、路由失败和拖拽挤压才不会混成一个问题。
上线前检查表
- Navigation 使用唯一 NavPathStack,未在子页面各建一套互不相干的栈。
- 分栏空栈时配置 splitPlaceholder,不再展示整片空白。
- 占位页没有按钮、输入框、焦点或点击承诺。
- 需要默认交互详情时使用 pushPathByName,并有只执行一次的门禁。
- navDestination 能按 route name 构建真实 NavDestination。
- navBarWidthRange 与 minContentWidth 经过同一宽度预算核对。
- 拖拽到左右边界时,标题、图片、按钮和表格均不被截断。
- 单栏、分栏空栈、分栏详情、pop 回空栈四条路径都已截图。
- 日志记录实际模式、栈大小和容器宽度,不记录用户敏感数据。
- API 26 工程编译、真机/模拟器焦点和拖拽结果单独留证。
验证边界
本文依据当前华为官方 Navigation 分栏指南、空白页 FAQ、组件导航说明和 HarmonyOS 7 升级适配资料整理。宿主纯函数断言只验证宽度预算与业务状态映射,不代替 API 26 编译、AUTO_WITH_ASPECT_RATIO 实际模式选择、ComponentContent 生命周期、设备焦点、拖拽动画或路由恢复测试。
官方资料
- Navigation 分栏开发
- Navigation 分栏模式下页面大量留白 FAQ
- 组件导航和页面路由概述
- Navigation 跨包路由与 NavPathStack
- HarmonyOS 7 / API 26 升级适配指南
结论
平板分栏右侧空白并不等于详情页构建失败。先确认 Navigation 是否处于分栏,再看 NavPathStack 是否为空:空栈只需静态说明时使用 splitPlaceholder,需要默认可操作内容时推真实路由;同时用 navBarWidthRange 和 minContentWidth 守住拖拽边界。把“模式、栈、宽度”三件事分别记录,才有可能一次定位,而不是在路由表、图片加载和布局参数之间来回猜。](https://i-blog.csdnimg.cn/direct/1ac0d3e0781d4c74900973ce69ebdaf3.jpeg#pic_center)