〖共创稿事节〗HarmonyOS 7 新特性实战(39):平行视界三档吸附改造成应用层无极分栏
# 〖共创稿事节〗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 和折叠屏方窗在模拟器上本来就不给分栏:自由多窗模式下系统不提供分栏,折叠屏方窗缺对应设备档。这些形态的单栏结果按预期处理,没有当成回归。
更多推荐
所有评论(0)