# 〖共创稿事节〗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节点都不渲染在分隔线 x1440、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 改成兄弟层最外层包一层StackNavigation当底座手柄作为兄弟节点叠在上面只占自己那条命中区其余区域照常穿透。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): Promisevoid { 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.setOrCreatenumber(SHANHAI_SPLIT_WIDTH_KEY, width); } catch (_) { // 读不出来就按「没拖过」处理不能因为这一项读失败挡住启动 AppStorage.setOrCreatenumber(SHANHAI_SPLIT_WIDTH_KEY, 0); } } export function saveShanhaiSplitWidth(width: number): void { const value: number Math.round(width); if (value 0) { return; } AppStorage.setOrCreatenumber(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 和折叠屏方窗在模拟器上本来就不给分栏自由多窗模式下系统不提供分栏折叠屏方窗缺对应设备档。这些形态的单栏结果按预期处理没有当成回归。