# 〖共创稿事节〗HarmonyOS 7 新特性实战(39):平行视界三档吸附改造成应用层无极分栏
平板上的分栏,第一版是照官方 EasyGo 那套配出来的:目录页固定在左边,右栏跟着点换内容,能跑。问题出在中间那条分隔条——它能拖,但只肯停在三个位置。
在 2880 宽(1440vp)的窗口上量过:拖完松手,边界落在 960、1440、1920 三个 x 坐标之一,正好是 1:2、1:1、2:1。想停在 1201,松手它就跳回 1440。比这更难受的是拖的过程——手指在动,边界在动,两栏里的内容整片不上屏,只剩底图一层淡影,像被人抽走了。
后面做的事是把系统分栏整个换掉,改成应用自己分栏:左栏宽度绑一个状态值,中间画一条 14vp 的手柄,拖动改的就是这个值。改完边界能停在任意位置,两栏内容逐帧跟着重排。
一、先分清「配置没写对」和「配置写对了也这样」
EasyGo 是否生效,取决于三个地方逐字对得上:模块配置里指向配置文件、配置文件里声明分栏参数、根Navigation的 id 与配置里的homeNavigationId一致。官方博客给过一份完整示例(EasyGo 实战:用 easy_go.json 配置列表-详情分栏),照抄能跑通。
配好之后要确认它真的生效了,否则后面的判断全是猜。判分栏成立有两条判据,必须同时满足:
- 布局树里存在
NavDestination节点; - 存在一条贯穿全高的竖
Divider(脚本判据:高度大于宽度的 3 倍,且超过窗口高度的一半)。
只看NavBar的右边界小于窗口宽会误判。退出分栏时NavBar和NavDestination两个都铺满全宽,光看宽度像是「分栏还在、两层重叠」;真正能区分的是那条竖线在不在。另外,底部 tab 栏自带的分隔线是横的、高度只有几十像素,不要拿它当分栏证据。
改完配置文件必须卸载重装。覆盖安装不会重新解析兼容配置——同一台平板上,覆盖装完NavBar还是满宽[0,86][2880,1920],卸载重装立刻变成[0,86][1919,1920]。这条在两个不同实例上各复现过一次。
配置确认生效,问题就落到分隔条身上:它不是我们写坏的,是它本来就这么工作。
二、一块纯色 Column 把责任划清了
拖动时内容不上屏,第一反应是自己哪里写错了:是不是底图在跟着变、是不是内容区尺寸变了触发重排、是不是动画把绘制卡住了。
按顺序排掉三条:底图的隐式动画去掉,一样白;窗口尺寸变化时申请窗口属性的那段逻辑关掉,一样白;把手柄挪开,一样白。这些都排掉之后做了一次决定性实验——在 navBar 最上层盖一块纯色Column,跟业务组件树完全无关,然后拖分隔条。
纯色块同样不上屏。
到这一步结论就明确了:拖动期间系统接管绘制,应用内容整片不出图,和组件树无关。应用层改不了这件事,剩下的路只有一条——不用系统的分栏,自己分。
| 对比项 | 系统平行视界(EasyGo) | 应用层自绘分栏 |
|---|---|---|
| 边界能停的位置 | 1:2 / 1:1 / 2:1 三档,松手吸附 | 任意位置,拖动过程中实时跟随 |
| 拖动时内容绘制 | 系统接管,两栏内容整片不上屏 | 应用自己改布局属性,逐帧重排 |
| 分栏由谁决定 | 系统按设备档与窗口形态判断 | 应用按自己的断点判断 |
| 满屏层处理 | 靠fullScreenPages声明 | 靠hideNavBar临时收掉左栏 |
三、开关不能由结果驱动
Navigation的mode就是分栏开关。它默认是Auto,断点 600vp——手机横屏常见宽度 844vp,一横过来就被劈成两栏,而这一版要的是手机始终单栏。
那就显式二选一。但二选一的判据用哪个,有个坑:分栏的结果是靠两栏实测宽度判断的(Navigation的宽减去 navBar 的宽,差值大于 100vp 才算真分栏)。如果拿这个结果去驱动mode,会死锁——Stack态下两栏等宽,判据恒为假,永远进不了Split。
所以拆成两个方法:开关只看窗口宽高,结果只看实测宽度。
/** 开关:只看窗口宽高,不看测量出来的两栏宽度 */ private shouldSplitLayout(): boolean { return ShanhaiResponsive.usesSideNavigation(this.navigationWidthVp, this.screenHeightVp); } /** 结果:Navigation 宽和 navBar 宽差得远,才算真的在分栏 */ private isSplitLayout(): boolean { return this.navigationWidthVp > this.screenWidthVp + 100; }断点直接复用应用自己的宽屏断点(宽 ≥840vp 且高 ≥520vp,也就是已经换成左侧竖排导航的那个宽度),分栏和宽屏版式同进同出。这样手机永远单栏,平板一进来就是分栏。
四、左栏宽度只有一个真源
分栏打开之后,左栏宽度只由一个状态值决定,拖动、窗口变化、首帧恢复全部写同一个值,不出现「拖动写一套、恢复写另一套」的分裂。
.mode(this.shouldSplitLayout() ? NavigationMode.Split : NavigationMode.Stack) .hideNavBar(this.isOverlayLayerVisible() && this.shouldSplitLayout()) .navBarWidth(this.splitNavBarWidth) .navBarWidthRange([SPLIT_MIN_NAV_BAR, SPLIT_MAX_NAV_BAR]) .minContentWidth(SPLIT_MIN_CONTENT) .onAreaChange((_oldValue: Area, newValue: Area) => { const width: number = Math.round(Number(newValue.width)); if (width > 0 && width !== this.navigationWidthVp) { this.navigationWidthVp = width; if (!this.splitWidthInitialized) { this.splitWidthInitialized = true; // 首帧:优先用上次拖出来的宽度;没拖过就按默认比例 const saved: number = savedShanhaiSplitWidth(); this.splitNavBarWidth = saved > 0 ? saved : Math.round(width * SPLIT_DEFAULT_NAV_BAR_RATIO); } this.clampSplitNavBarWidth(); this.ensureMapFloor(); } })首帧恢复放在onAreaChange里而不是页面创建时,是因为此刻手里才有真实窗口宽。恢复完紧接着被夹一次,不会先按过宽的值排一帧再跳。
minContentWidth(360)是硬约束:右栏再窄也不能窄过 360vp,否则正文一行放不下几个字,横向滚动又出不来。
夹取逻辑单独抽出来,因为窗口变化不走拖动那条路:
/** 左栏能拖到的最宽值:不超过上限,同时给右栏留够 360vp */ private splitNavBarUpperBound(): number { const forContent: number = this.navigationWidthVp - SPLIT_MIN_CONTENT; return Math.max(SPLIT_MIN_NAV_BAR, Math.min(SPLIT_MAX_NAV_BAR, forContent)); } /** 把左栏宽度夹回合法区间:旋转、分屏、自由窗口拉边都要走一次 */ private clampSplitNavBarWidth(): void { const clamped: number = Math.min( this.splitNavBarUpperBound(), Math.max(SPLIT_MIN_NAV_BAR, this.splitNavBarWidth) ); if (clamped !== this.splitNavBarWidth) { this.splitNavBarWidth = clamped; } }旋转和分屏不会触发拖动手势,老的宽度可能已经放不下。不夹的话左栏溢出、右栏被挤成负数宽度,表现为右栏整片空白。
五、14vp 的手柄,4vp 的竖条
平台自带的分隔条拖拽在这个环境里不能用:enableDragBar(true)打开后布局树里连DragBar节点都不渲染,在分隔线 x=1440、1435、1445、1450 四个位置起手注入滑动,前后两次读到的 navBar 宽度完全不变。所以手柄自己画。
结构上分两层,命中区和本体分开:外层 14vp 透明区域负责好抓,内层 4×72vp 的圆角竖条负责好看。竖条用中性墨色加半透明,浅色纸面上偏灰、深色底上偏白,两套主题都不抢戏。拖动中本体加粗加长一点,让人知道自己抓住的是它。
Column() { Column() .width(this.splitDragging ? SPLIT_HANDLE_GRIP_WIDTH + 1 : SPLIT_HANDLE_GRIP_WIDTH) .height(this.splitDragging ? SPLIT_HANDLE_GRIP_HEIGHT + 16 : SPLIT_HANDLE_GRIP_HEIGHT) .borderRadius(SPLIT_HANDLE_GRIP_WIDTH / 2) .backgroundColor(this.isDarkMode ? '#8CF2E9D8' : '#663B3229') } .width(SPLIT_HANDLE_WIDTH) .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor('#00000000') .position({ x: this.splitNavBarWidth - SPLIT_HANDLE_WIDTH / 2, y: 0 }) .gesture( PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 1 }) .onActionStart(() => { this.splitDragStartWidth = this.splitNavBarWidth; this.splitDragging = true; }) .onActionUpdate((event: GestureEvent) => { const next: number = this.splitDragStartWidth + event.offsetX; this.splitNavBarWidth = Math.min( this.splitNavBarUpperBound(), Math.max(SPLIT_MIN_NAV_BAR, next) ); }) .onActionEnd(() => { this.splitDragging = false; saveShanhaiSplitWidth(this.splitNavBarWidth); }) )三个参数值得说清:distance: 1让手势在手指刚动时就起来,不用先走一段;onActionUpdate用offsetX加起手时的宽度,而不是累加每帧的增量,这样中间丢帧也不会漂;position的 x 取「左栏宽减半个命中区」,让手柄正骑在边界上。
手柄在满屏层打开时不渲染,否则它会画在遮罩上面。
六、右栏不能白着
分栏之后多了一个单栏时不存在的问题:右栏的内容来自导航栈,栈一空右栏就一片白。
第一版的做法是启动时往栈底压一张地图页。冷启能看见,但第一次 push 别的页时它就被挤掉了——栈底那个槽位会被 navBar 的首页顶替,右栏只剩栈顶。于是「目录 → 返回」pop 完,栈空了,右栏继续白。
改成「栈空就补」,不依赖栈底能不能活下来:
/** 栈空时把地图补成右栏内容;栈里还有页就什么都不做,不抢用户的页面 */ private ensureMapFloor(): void { if (!this.isSplitLayout()) { return; } if (this.pageStack.size() > 0) { return; } this.pageStack.pushPath({ name: MAP_PAGE, param: '' }, READER_PAGE_ANIMATED); } /** pop / clear 之后调用:出栈可能同步也可能跟着动画走,下一拍再判一次栈深 */ private scheduleMapFloorRestore(): void { if (!this.isSplitLayout()) { return; } setTimeout(() => { this.ensureMapFloor(); }, MAP_FLOOR_RESTORE_DELAY_MS); }关键是所有出栈路径统一走同一个方法,pop 完之后隔一拍再判一次栈深——出栈有时同步、有时跟着动画走,立刻判会读到旧值。这一条比补地图本身更容易漏:只要有一条出栈路径没走统一入口,那条路径返回后右栏就是白的。
七、满屏层在分栏态只铺一半
应用里有两个弹层型满屏层:主题外观弹层和端侧超分查看器,都是NavDestinationMode.DIALOG的 destination。
单栏时它们天然铺满整窗。分栏时只铺满右栏:主题弹层的遮罩只盖住右边,左栏照样亮着、照样能点;超分查看器的图被压进右栏,成了一条竖条。
EasyGo 时代靠配置里的fullScreenPages解决,那条声明随着 EasyGo 一起没了。现在用hideNavBar(true):按 Navigation 组件参考 的说法,它的语义是隐藏导航栏(含标题栏、内容区、工具栏),栈里的 destination 提到顶部显示;navBar 一收,内容区拿到整窗宽度,DIALOG 自然满屏。
但必须只在分栏态收。单栏时 DIALOG 本来就满窗,收掉 navBar 只会让弹层背后从「首页或详情页」变成一片空白——在手机上是明显的回归。所以这一行是isOverlayLayerVisible() && shouldSplitLayout(),两个条件缺一不可。
八、手柄挂成 overlay,整屏都点不动
手柄当时是这么挂的:.overlay(this.SplitHandle(), { align: Alignment.TopStart }),挂在Navigation上。装到平板上,分栏正常、边界能拖、内容也跟着走,看起来全对。然后发现整屏点不动——左右两栏点不动,上下滑不动,连底部 tab 栏也失效,一启动就这样。
排查走了一圈:云幕动画只在过渡激活时挂载,不是它;两个 DIALOG 弹层默认关闭,不是它;服务卡片跑在另一个扩展进程里,不是它。全部排掉之后回到 overlay 这一行。
原因在于:分栏态下,挂在Navigation上的.overlay()会变成一层覆盖整个 Navigation 的透明命中层,不是只有手柄那 14vp。手柄本身只有 14vp 宽,所以问题不是「手柄太大」,而是 overlay 这一层整体在吞触摸。
修法是把手柄从 overlay 改成兄弟层:最外层包一层Stack,Navigation当底座,手柄作为兄弟节点叠在上面,只占自己那条命中区,其余区域照常穿透。
Stack() { Navigation(this.pageStack) { // 主壳:沉浸框架 + 首页内容 } .width('100%') .height('100%') .hideTitleBar(true) .hideToolBar(true) .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM]) .mode(this.shouldSplitLayout() ? NavigationMode.Split : NavigationMode.Stack) .navBarWidth(this.splitNavBarWidth) // 兄弟层:只占自己那条 14vp 命中区,其余穿透到下面的 Navigation this.SplitHandle() } .width('100%') .height('100%') .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])包了Stack之后安全区的职责要跟着转移。原来Navigation作为根节点,靠忽略布局安全区铺满整窗(它默认会把 navBar 裁进安全区,折叠屏真机上底部少过 336px);包进Stack后,铺满整窗这件事交给Stack的expandSafeArea,不然底部会重新露出一条底色。
改完在平板上验:分栏态点左栏「图鉴」,左栏切到神兽图鉴列表;列表上滑,滚到应龙、烛龙、精卫。点击和滑动都回来了。
九、宽度要记住,但别记成比例
用户拖完手柄,下次冷启当然还想是那个宽度。
存储用preferences,不用PersistentStorage——后者在本机真机上会静默不落盘,写进去了、读出来是空,排查起来很费时间。启动时把存值读进内存,且在加载页面内容之前读完,避免「首帧先按默认值排版、下一帧跳到存值」。
export async function initShanhaiSplitWidth(context: common.Context): Promise<void> { try { splitStore = await preferences.getPreferences(context, PREFERENCES_NAME); const stored: preferences.ValueType = await splitStore.get(PREFERENCES_WIDTH, 0); // 只认正整数:0 / 负数 / 脏数据一律当「没拖过」 const width: number = typeof stored === 'number' && stored > 0 ? Math.round(stored) : 0; AppStorage.setOrCreate<number>(SHANHAI_SPLIT_WIDTH_KEY, width); } catch (_) { // 读不出来就按「没拖过」处理,不能因为这一项读失败挡住启动 AppStorage.setOrCreate<number>(SHANHAI_SPLIT_WIDTH_KEY, 0); } } export function saveShanhaiSplitWidth(width: number): void { const value: number = Math.round(width); if (value <= 0) { return; } AppStorage.setOrCreate<number>(SHANHAI_SPLIT_WIDTH_KEY, value); if (splitStore === null) { return; } const store: preferences.Preferences = splitStore; store.put(PREFERENCES_WIDTH, value).then(() => store.flush()).catch(() => {}); }只记绝对值(vp),不记比例。理由是两个场景的要求不同:同一台设备窗口宽基本不变,记绝对值就是记用户的原意;窗口一旦变窄(分屏、自由窗口拉边),调用方会拿新窗口宽把它夹回合法区间,而夹完的那个值不写回——窗口恢复原宽时,用户原来的宽度还在。如果记的是比例,窄窗口下夹出来的比例会被当成用户偏好,恢复后就变成另一个宽度。
落盘时机只在手势结束时。拖动过程中每帧写盘既没必要也卡;手势被系统打断时宽度可能已经改过,照样存,存的是屏幕上实际那个值。
没有存值时给一个默认比例0.658。这个数不是拍的:是用户在平板上拖到的位置,1895px / 2880px。它有个连带效果——65.8% 下左栏约 947.5vp,超过了 840vp 的宽屏断点,所以左栏会自动切到左侧竖排导航,而不是底部 tab 栏。
十、验收:两组数字和一张表
拖动无极,看的是分隔线能停在哪些位置。同一次拖动里连着读:1440 → 1272(拖动中)→ 1201 → 900,没有一次吸附回三档。左栏变宽到 1000vp 时,导航自动换成左侧竖排,不用额外判断。
夹取看的是「拖过头会停在哪」:
| 项 | 常量 | 实测(2880 宽窗口) |
|---|---|---|
| 左栏下限 | 320vp | 拖到 300px,分隔线停在 640px |
| 左栏上限 | 1000vp | 拖到 2300px,分隔线停在 2000px |
| 右栏最小宽 | 360vp | 左栏到上限时右栏仍有 880px 可用 |
| 默认比例 | 0.658 | 卸载清数据重装,分隔线 1896px |
| 判据 | 实测结果 |
|---|---|
| 冷启即分栏 | 布局树里有NavDestination与贯穿全高的竖Divider |
| 拖动时内容跟着重排 | 拖动中连续截图,两栏内容都在,画面连续变化 |
| 整屏可点 | 分栏态点左栏「图鉴」,左栏切换成功 |
| 上下可滑 | 左栏列表上滑,滚到列表后段条目 |
| 满屏层满窗 | 主题弹层遮罩盖住整窗,关闭后分栏恢复 |
| 宽度跨重启 | 拖到非默认位置后杀进程冷启,首帧即为该宽度 |
这些「漏一条就失效」的约束,最后都固化成了门禁断言,一共七条,每条都写明破坏它会怎样:EasyGo 保持停用、mode必须由窗口断点驱动且不得用Auto、开关不得读结果、左栏宽度只有一个真源且边界常量齐全、手柄必须是横向 pan 且走夹取函数、窗口变化必须重新夹、宽度持久化必须用preferences且首帧恢复。
反向验过三处:把模块配置改回开 EasyGo、把mode改成固定Stack、把onAreaChange里的夹取删掉,门禁都直接报错。全量 43 个校验脚本复跑 43/43 通过。
还剩什么没验
手机档没上机。手机上窗口宽 390~430vp,走的是Stack,逻辑上不会分栏,但「手机横屏不分栏」这条目前只有代码保证(断点要求 ≥840vp),没有截图。
2in1 和折叠屏方窗在模拟器上本来就不给分栏:自由多窗模式下系统不提供分栏,折叠屏方窗缺对应设备档。这些形态的单栏结果按预期处理,没有当成回归。