高校周边通 · 国庆特别版:HarmonyOS 7「平行视界」折叠屏双栏沉浸体验开发实战
高校周边通 · 国庆特别版:HarmonyOS 7「平行视界」折叠屏双栏沉浸体验开发实战
官方文档对齐基准:HarmonyOS 7 API 26 · ArkUI Navigation / FoldStatus / WindowStage / distributedKVStore
- 折叠屏开发指南(含平行视界、折叠状态监听、大屏多窗口):
developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/foldable-dev-guide-V5(更新:2026-05-12)- Navigation 组件(Auto 模式 / NavPathStack / onNavigationModeChange):
developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-basic-components-navigation?docScope=all(更新:2026-09-24)- 窗口管理(WindowStage / WindowMode):
developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-window- 显示管理(Display / FoldStatus / foldStatusChange):
developer.huawei.com/consumer/en/doc/harmonyos-references/js-apis-display(更新:2026-09-08,英文站枚举真值)- distributedKVStore(ArkData · 官方推荐):
docs.openharmony.cn/pages/v6.0/zh-cn/application-dev/reference/apis-arkdata/js-apis-distributedKVStore-sys.md
产品版本:高校周边通 v3.6.0 · 国庆专题新增「折叠屏双栏沉浸」模式
关键词:平行视界 / 折叠屏 / NavigationMode.Auto / foldStatus / 单 NavPathStack 自动分栏 / onNavigationModeChange / 自由悬停

一、为什么 v3.6.0 必须重做折叠屏体验:国庆场景的痛点
国庆出游三件套里,折叠屏用户的体验一直最差:
- 在地铁上用外屏查攻略,到了景点打开折叠屏,整个界面强制放大 2 倍,内容被拉形变,导航栏和图片严重错位;
- 左栏在看「故宫 18 个打卡点」,想同时对比 「八达岭 vs 颐和园排队实况」,必须来回切 3 次页面—— 国庆 35°C 下 2 分钟手机就发烫;
- 带了华为 Mate X5 平板模式,想左栏开景区导航 + 右栏写攻略笔记,系统只给我一个单栏,把 Mate 当成大号手机用。
高校周边通 v3.6.0 和平行视界的配合思路:让折叠屏三个物理态(外屏小窗口、展开大屏、自由悬停 = 帐篷模式)对应三套视觉与交互策略,只使用一个 NavPathStack,由官方 NavigationMode.Auto 根据宽度自动决定分栏或单栏。
| 物理态 | 对应 FoldStatus(官方真实枚举值) | 对应 Navigation 模式 | 国庆场景核心功能 |
|---|---|---|---|
| 1. 外屏(6.4″ 单栏) | FOLD_STATUS_FOLDED = 2 | NavigationMode.Auto → Stack | 快速查「排队实况窗 / 纪念章 / 导航」 |
| 2. 展开态(7.85″ 折叠屏) | FOLD_STATUS_EXPANDED = 1 | NavigationMode.Auto → Split · 双栏 1:2 | 左栏 = 景区攻略列表,右栏 = 景区详情 + 实况窗卡片 + 导航按钮 |
| 3. 帐篷模式(悬停 90°) | FOLD_STATUS_HALF_FOLDED = 3 | Split + 下半屏 = 国庆 Vlog 笔记,上半屏 = 景点直播 | 支架立桌上,边看升旗仪式直播边写国庆文案 |
⚠️ 关键勘误(之前版本严重错误):FoldStatus 枚举值 不是「FOLDED=1 / HALF=2 / EXPANDED=3」。按英文站 2026-09-08 最新枚举表:EXPANDED(完全展开)= 1;FOLDED(外屏)= 2;HALF_FOLDED(帐篷)= 3;UNKNOWN = 0。如果按旧值写 switch,展开态你判断成 FOLDED,展开直接黑屏一半。
二、官方 API 完整速查表(平行视界 4 大模块)
2.1 折叠状态监听 — 区分 外屏 / 展开 / 半展开
模块:@kit.ArkUI(import { display } from '@kit.ArkUI';)· 不是 之前写的 @kit.BasicServicesKit
| API | 作用 | 参数 / 回调字段 |
|---|---|---|
display.on('foldStatusChange', callback) | 核心:屏幕折叠状态变化时回调 | FoldStatus = 0(UNKNOWN) / 1(EXPANDED 完全展开) / 2(FOLDED 外屏) / 3(HALF_FOLDED 帐篷) |
display.getDefaultDisplaySync() | 立即拿当前折叠态 | 返回 displayInfo.foldStatus 字段 |
display.off('foldStatusChange') | 页面退出必须移除监听,否则内存泄漏 | — |
2.2 Navigation 组件 — 官方推荐 Auto 模式(不是强制 Split + 双栈)
模块:@kit.ArkUI(Navigation 内置组件)
| API | 作用 | 字段取值 / 说明 |
|---|---|---|
Navigation(mode) | 模式切换 | 官方推荐主方案:NavigationMode.Auto(按宽度自动切 Stack/Split,写这一个就够用);NavigationMode.Split = 强制双栏(只有平板外接键盘等场景才手动开);NavigationMode.Stack = 强制单栏 |
onNavigationModeChange((mode)=>{}) | Auto 模式自动切换后的回调(新增) | 入参 mode 为当前实际生效模式;用于记录日志或在 Split 时预置右栏默认项 |
navDestination(pathName, builder) | 注册路由栈目的地(一个 NavPathStack 足以) | pathName = 'spot_list' / 'spot_detail' / 'vlog_editor' |
navBarPosition(Position.Start) | 侧边栏位置 | Start = 左栏;End = 右栏(适配 RTL) |
hideBackButton(true) | 左栏(根导航)隐藏返回按钮 | 官方建议:左栏根节点永远不返回,否则右栏会空 |
navBarWidthRange({ min, max }) + minContentWidth(number) | 双栏宽度控制(官方推荐新属性,替代老 navBarWidth(ideal)) | navBarWidthRange({ min: 280, max: 400 })(国庆版左栏区间);minContentWidth(520)(右栏最小宽,保证塞下详情+实况窗) |
splitPlaceholder(builder) | 右栏没选中时的空态占位页(Auto/Split 模式必加,否则右栏黑屏) | 放一张国庆主题大图 + 「请在左侧选择景区」文字 |
hideNavBar(state) | 折叠屏折起外屏时,关闭左栏(Auto 模式一般自动,手动兜底) | true = 变单栏 |
mode(mode) | 运行时动态切换(配合 foldStatusChange 与 forceSplit 判断) | 官方更推荐把 mode 设为 Auto,用宽度决定 |
⚠️ 关键勘误(之前版本错误架构):不要 声明
leftStack: NavPathStack和rightStack: NavPathStack两个独立栈。官方 Navigation 在 Split/Auto 模式下,一个 NavPathStack 就会自动把「栈顶前一个目的地」放到左栏、「栈顶目的地」放到右栏——两个独立栈会导致返回按钮不可控(用户按返回左栏也跳)。
2.3 WindowStage — 自由窗口 / 多窗口模式检测
模块:@kit.ArkUI(Window)
| API | 作用 | 返回字段 |
|---|---|---|
window.getLastWindow(ctx) | 拿到当前窗口对象(Promise 形式) | window.Window 实例 |
win.on('windowSizeChange', (size)=>{}) | 自由拖动窗口大小回调 | { width: number, height: number, rotation: number }(单位 vp) |
win.getWindowPropertiesSync().windowMode | 查询当前是否处于 分屏 / 悬浮 / 全屏 | WindowMode.WINDOW_MODE_FULLSCREEN / SPLIT_PRIMARY / SPLIT_SECONDARY / FLOATING |
win.setWindowLayoutFullScreen(true) | 国庆全屏沉浸(隐藏状态栏,保留导航胶囊) | — |
2.4 distributedKVStore(ArkData · 跨端同账号兜底用)
模块:import { distributedKVStore } from '@kit.ArkData';(不是 之前不存在的 @kit.DistributedDataServiceKit,也不推荐老的 @ohos.data.distributedData)
| API | 作用 | 字段取值 / 说明 |
|---|---|---|
distributedKVStore.createKVManager(config) | 创建 KV 管理器实例 | config = { context: Context, bundleName: string }(必填 context,userId 字段在 API9+ 已废弃,千万别传) |
kvManager.getKVStore(storeId, options) | 拿到单版本 KV 仓库 | options.area = 0/1(EL1/EL2)、options.securityLevel = distributedKVStore.SecurityLevel.S2/S3 |
kvStore.put(key, value, callback) | 写入纪念章等数据 | value 支持 number / string / boolean / Uint8Array |
kvStore.sync(deviceIds, syncMode, delayMs?) | 手动触发跨设备同步(同华为账号超终端) | syncMode = distributedKVStore.SyncMode.PUSH_ONLY / PULL_ONLY / PUSH_PULL(不存在旧的 SYNC_MODE_PUSH,这个枚举名是我之前编造的);deviceIds: string[] 传空数组 = 同账号所有在线设备 |
kvStore.on('syncComplete', (results)=>{}) | 同步完成回调(注意拼写:Complete** 无 d)** | 之前我写的 syncCompleted 多了个 d,官方 API 根本没有这个事件名;results: Array<{ deviceId, syncStatus }> |
三、完整实现:展开即双栏 · 左攻略右详情 · 悬停即 Vlog
3.1 ArkTS 工程配置(必须先开 3 个开关)
// entry/src/main/module.json5 · abilities[0] 段(国庆版本新增 3 项)
{
"name": "EntryAbility",
"orientation": "unspecified", // 允许自动横竖屏(关键!否则折叠屏永远竖屏)
"supportWindowMode": "fullscreen|split|floating", // 允许 平行视界 + 分屏 + 悬浮
"continuable": true, // 允许跨端接续(可选:和平板无缝接力)
"missMetaDataType": [
// 开启 折叠屏平行视界支持(如果旧项目没有,展开态会强行放大)
"parallel_vision_support"
]
}
3.2 顶层:EntryAbility.ets — 折叠态 → 全局分发
/**
* @file EntryAbility.ets
* @brief 高校周边通 v3.6.0 · 折叠态监听 → 全局分发双栏/单栏模式(真实 API 对齐)
*/
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { display } from '@kit.ArkUI'; // ✅ 真实模块:@kit.ArkUI(不是 BasicServicesKit)
import { distributedKVStore } from '@kit.ArkData'; // ✅ 真实模块:@kit.ArkData(不是 DistributedDataServiceKit)
/** 全局状态:跨组件共享折叠态(官方推荐用 AppStorage 跨页传) */
export class AppGlobal {
static FOLD_KEY: string = 'global_fold_status';
static WIN_MODE_KEY: string = 'global_window_mode';
static FORCE_SPLIT_KEY: string = 'global_force_split';
static KV_MGR_KEY: string = 'global_kv_manager';
}
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
// 1. 主页面加载(AppStorage 在 loadContent 之前绑定)
windowStage.loadContent('pages/IndexPage', (err) => {
if (err.code !== 0) {
hilog.error(0x0000, 'CampusExplorer', 'loadContent failed: %{public}s', JSON.stringify(err));
}
});
// 2. 立即初始化一次折叠状态(启动时如果用户已经展开了,要立刻切到 Split)
try {
const info = display.getDefaultDisplaySync();
// ✅ 真实枚举:EXPANDED=1 / FOLDED=2 / HALF_FOLDED=3 / UNKNOWN=0
AppStorage.setOrCreate(AppGlobal.FOLD_KEY, info.foldStatus ?? display.FoldStatus.FOLDED);
} catch { /* noop */ }
// 3. 全局注册折叠监听:任何态变化 → AppStorage → 所有订阅页面自动 rebuild
try {
display.on('foldStatusChange', (newStatus: display.FoldStatus) => {
hilog.info(0x0000, 'CampusExplorer',
`foldStatusChange = ${newStatus} (EXPANDED=1 / FOLDED=2 / HALF_FOLDED=3)`);
AppStorage.setOrCreate(AppGlobal.FOLD_KEY, newStatus);
});
} catch (err) {
hilog.warn(0x0000, 'CampusExplorer', 'foldStatusChange unsupported: %{public}s',
JSON.stringify(err));
}
// 4. 窗口大小监听(分屏 / 悬浮 / 自由窗口模式 — 国庆用户最爱分屏:左边攻略右边微信)
window.getLastWindow(this.context).then((win) => {
win.on('windowSizeChange', (size) => {
// 国庆小技巧:宽度 ≥ 840vp → 无论折叠与否都强开双栏(平板外接键盘场景)
const isWideScreen = size.width >= 840;
AppStorage.setOrCreate(AppGlobal.FORCE_SPLIT_KEY, isWideScreen);
AppStorage.setOrCreate(
AppGlobal.WIN_MODE_KEY,
win.getWindowPropertiesSync().windowMode
);
});
}).catch((e) => hilog.warn(0x0000, 'CampusExplorer', 'getLastWindow fail: %{public}s', JSON.stringify(e)));
// 5. 初始化 distributedKVStore(单例存到 AppStorage,跨页复用 — 真实签名)
try {
// ✅ 正确签名:只传 context + bundleName;userId 已废弃,传了会 warn
const kvMgr = distributedKVStore.createKVManager({
context: this.context,
bundleName: 'com.campus.explorer'
});
AppStorage.setOrCreate(AppGlobal.KV_MGR_KEY, kvMgr);
} catch (e) {
hilog.warn(0x0000, 'CampusExplorer', 'createKVManager fail: %{public}s', JSON.stringify(e));
}
}
onBackground(): void {
// 后台时暂停实况窗轮询,省流量
AppStorage.setOrCreate('app_in_foreground', false);
}
onForeground(): void {
AppStorage.setOrCreate('app_in_foreground', true);
}
onDestroy(): void {
// 关闭监听(官方要求:配对 on/off)
try { display.off('foldStatusChange'); } catch { /* noop */ }
}
}
3.3 UI 层:IndexPage.ets — 平行视界 主框架(单 NavPathStack + NavigationMode.Auto)
/**
* @file IndexPage.ets
* @brief 国庆特别版 · 平行视界主框架(左栏=景区列表,右栏=详情+实况窗)
*
* 核心设计(官方真实 API 对齐):
* foldStatus = FOLDED(2) → NavigationMode.Auto → Stack(单栏,外屏手机)
* foldStatus = EXPANDED(1) → NavigationMode.Auto → Split(双栏,折叠屏展开 / 平板)
* foldStatus = HALF_FOLDED(3)→ Auto → Split + 下半屏独立 Vlog 笔记 Builder
*
* 路由架构(关键勘误:一个 NavPathStack 足以,官方自动分栏,别再 new 两次):
* pushPathByName('spot_detail', {spot}) → Auto 模式在 Split 时自动推到右栏不盖左栏
* replaceByName('spot_detail', {spot}) → 同左,右栏直接替换不进栈
* Stack 单栏模式 → push 正常覆盖全屏,行为一致
*/
import { AppGlobal } from '../EntryAbility';
import { SpotItem, SpotService, HolidaySpotExt } from '../service/SpotService';
import { LiveViewWidget } from '../widget/LiveViewWidget'; // ★ 复用第二篇「实况窗」组件
import { MedalExchangeEntry } from '../widget/MedalExchangeEntry'; // ★ 复用第四篇「碰一碰」入口
@Entry
@Component
struct IndexPage {
/* ================ 折叠 + 窗口模式全局订阅(官方:AppStorageLink)============= */
@StorageLink(AppGlobal.FOLD_KEY) foldStatus: display.FoldStatus = display.FoldStatus.FOLDED;
@StorageLink(AppGlobal.FORCE_SPLIT_KEY) forceSplit: boolean = false;
/* ================ 单路由栈(★ 勘误:不再分 leftStack / rightStack,官方一个栈自动管理分栏)============= */
@State pathStack: NavPathStack = new NavPathStack();
/* ================ 记录当前实际生效的 Navigation 模式(用于日志 & 模式切换时的兜底)============= */
@State actualNavMode: NavigationMode = NavigationMode.Stack;
/* ================ 国庆专题数据:10 家景区 + 排队实况 ================ */
@State spots: SpotItem[] = [];
@State selectedSpotId: string = '';
aboutToAppear() {
this.spots = SpotService.getHolidayBatch('2026-10-01'); // 后端国庆批次
}
build() {
Navigation(this.pathStack) {
/* ========================================================================
* 左栏 NAVBAR(景区攻略列表):折叠态 = FOLDED 时占满屏幕(Auto 变 Stack)
* ======================================================================*/
Column() {
this.LeftHeader()
List() {
ForEach(this.spots, (s: SpotItem, idx: number) => {
ListItem() {
this.SpotRow(s)
.backgroundColor(
this.selectedSpotId === s.spotId
? 'rgba(229,57,53,.14)' // 中国红选中态
: 'rgba(255,255,255,.04)'
)
.onClick(() => {
this.selectedSpotId = s.spotId;
// ★ 勘误:不再判断 leftStack/rightStack。统一调用 pathStack,
// NavigationMode.Auto 会根据当前是 Split 还是 Stack 自动表现:
// - Split:新目的地自动渲染到右侧(不盖左栏)
// - Stack:正常推入全屏详情
if (this.actualNavMode === NavigationMode.Split) {
this.pathStack.replaceByName('spot_detail', { spot: s });
} else {
this.pathStack.pushPathByName('spot_detail_full', { spot: s });
}
})
}
}, (it: SpotItem) => it.spotId)
}
.divider({ strokeWidth: 1, color: 'rgba(255,255,255,.06)' })
.layoutWeight(1).width('100%')
}
.width('100%').height('100%')
.backgroundColor('#1A1B26')
}
/* ========== Navigation 双栏模式核心(官方推荐写法 5 行)========== */
// ✅ 主模式用 Auto(不要硬写 Split,窄屏 Split 会变形)
.mode(this.computedNavMode())
// ✅ Auto / Split 切换后的真实模式回调(API 26 新增),用来同步 @State actualNavMode
.onNavigationModeChange((mode: NavigationMode) => {
hilog.info(0x0000, 'CampusExplorer', `Navigation actual mode changed: ${mode} (1=Stack / 2=Split / 3=Auto)`);
this.actualNavMode = mode;
// Split 首次出现且没选中任何景区:默认把第一个景点推进右栏,避免黑屏
if (mode === NavigationMode.Split && this.pathStack.size() === 0 && this.spots.length > 0) {
this.selectedSpotId = this.spots[0].spotId;
this.pathStack.replaceByName('spot_detail', { spot: this.spots[0] });
}
})
// ✅ 官方推荐新属性:左栏宽度区间(代替老的 navBarWidth(ideal))+ 右栏最小宽度
.navBarWidthRange({ min: 280, max: 400 })
.minContentWidth(520)
.hideNavBar(this.shouldHideNavBar()) // 折叠到外屏时,隐藏左栏(Auto 兜底)
.hideBackButton(true) // 左栏根节点永远不显示返回
.navBarPosition(Position.Start) // 左栏在 Start
// ✅ Split / Auto 模式右栏没有选中项时的占位空态(否则空白很难看)
.splitPlaceholder() {
Column() {
Text('🏮').fontSize(120).margin({ bottom: 20 })
Text('国庆快乐 · 请在左侧选择景区')
.fontSize(22).fontWeight(800).fontColor('#FFFFFF').margin({ bottom: 10 })
Text('高校周边通 v3.6.0 · 平行视界自动为您呈现双栏沉浸体验')
.fontSize(14).fontColor('#8A8FA3')
}
.width('100%').height('100%').justifyContent(FlexAlign.Center)
.linearGradient({ angle: 160, colors: [['#2A1414', 0], ['#16161E', 1]] })
}
/* ========== NavDestination(所有目的地注册在同一路由栈上)========== */
.navDestination('spot_detail_full', (wrap: NavDestinationContext) => {
// 单栏(外屏 / 小窗口)全屏详情
const s: SpotItem | undefined = wrap.pathInfo.param?.spot;
if (s) { this.FullDetailPage(s) }
})
.navDestination('spot_detail', (wrap: NavDestinationContext) => {
// 双栏(Split / Auto 变 Split)右半详情
const s: SpotItem | undefined = wrap.pathInfo.param?.spot;
if (s) { this.RightDetailColumn(s) }
})
.navDestination('vlog_editor', () => {
// 帐篷模式下半屏:国庆 Vlog 编辑器
this.VlogEditor()
})
.backgroundColor('#16161E')
.width('100%').height('100%')
}
/* =========================================================
* 模式计算函数(决定平行视界的核心)
* =======================================================*/
private computedNavMode(): NavigationMode {
// forceSplit = true(≥840vp 窗口)或 已展开 / 帐篷 → 官方 Auto 会自动变 Split
// 窄屏 + FOLDED → Auto 自动变 Stack
if (this.forceSplit) {
return NavigationMode.Split; // 平板外接键盘等场景强开分栏
}
// 官方推荐:默认 Auto 就完事了,它自己根据宽度和系统能力切
return NavigationMode.Auto;
}
private shouldHideNavBar(): boolean {
// 外屏 & 非强制宽屏 → 隐藏左栏(Auto 会自动,但兜底更稳)
return this.foldStatus === display.FoldStatus.FOLDED && !this.forceSplit;
}
/* =========================================================
* 左栏:国庆专题 Header
* =======================================================*/
@Builder
LeftHeader() {
Column() {
Row() {
Text('🏮 高校周边通')
.fontSize(22).fontWeight(800).fontColor('#FFFFFF')
Blank()
Text('v3.6.0').fontSize(12).fontColor('#FFC93C')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(999).backgroundColor('rgba(255,201,60,.16)')
}
.width('100%').margin({ bottom: 10 })
Row() {
TextInput({ placeholder: '🔍 搜景点 / 排队 / 美食 / 地铁' })
.width('100%').height(40).backgroundColor('rgba(255,255,255,.07)')
.borderRadius(12).fontColor('#E5E5E5')
.placeholderColor('#8A8FA3')
}
.width('100%').margin({ bottom: 12 })
// 国庆主题快捷入口(与前三篇能力打通)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
QuickChip('🚩 升旗实况窗', 'rgba(229,57,53,.14)')
QuickChip('🎯 碰一碰传章', 'rgba(255,201,60,.14)')
QuickChip('✨ 老照片焕新', 'rgba(125,207,255,.14)')
QuickChip('📸 一日一景', 'rgba(187,154,247,.14)')
}
.width('100%')
Row() {
Text('🔥 国庆必去 TOP 10 · 更新 ' + new Date().toLocaleTimeString())
.fontSize(12).fontColor('#8A8FA3')
Blank()
Text('已同步').fontSize(11).fontColor('#4FD6BE')
}
.width('100%').margin({ top: 14, bottom: 6 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 18, bottom: 12 })
.linearGradient({
angle: 180, colors: [['#1D1F2B', 0], ['#1A1B26', 1]]
})
}
/* =========================================================
* 左栏:景区一行(带排队时长红色徽章)
* =======================================================*/
@Builder
SpotRow(s: SpotItem) {
const ext: HolidaySpotExt = SpotService.getHolidayExt(s.spotId);
Row() {
// 左侧缩略图(10/1 国庆徽章叠角)
Stack({ alignContent: Alignment.TopEnd }) {
Image(s.coverUrl ?? $r('app.media.spot_placeholder'))
.width(68).height(68).borderRadius(14).objectFit(ImageFit.Cover)
if (ext.isNationalDayHot) {
Text('10/1').fontSize(10).fontWeight(700).fontColor('#FFFFFF')
.backgroundColor('#E53935').padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius({ topLeft: 0, bottomRight: 0, topRight: 14, bottomLeft: 6 })
}
}
.width(68).height(68).margin({ right: 14 })
Column() {
Row() {
Text(s.nameCn).fontSize(16).fontWeight(700).fontColor('#FFFFFF').maxLines(1)
Blank()
if (ext.queueMin >= 45) {
// 排队 > 45min 红色徽章(实况窗联动)
Text(`${ext.queueMin}′`).fontSize(12).fontWeight(800).fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(999)
.backgroundColor('#E53935')
} else if (ext.queueMin >= 15) {
Text(`${ext.queueMin}′`).fontSize(12).fontWeight(700).fontColor('#4A3A00')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(999)
.backgroundColor('#FFC93C')
} else {
Text('畅通').fontSize(12).fontWeight(700).fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(999)
.backgroundColor('#4FD6BE')
}
}
.width('100%')
Text(s.district + ' · ' + s.campusTag + ' 校园附近')
.fontSize(12).fontColor('#9AA0B4').margin({ top: 5 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(s.tags ?? [], (t) => {
Text(t).fontSize(11).fontColor('#D9DEEA')
.padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(6)
.backgroundColor('rgba(255,255,255,.06)').margin({ right: 6, top: 6 })
})
}.width('100%').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').padding(14).borderRadius(18)
}
/* =========================================================
* 右栏:展开态的详情页(上:景区详情;下:实况窗 + 碰一碰)
* =======================================================*/
@Builder
RightDetailColumn(s: SpotItem) {
Column() {
// 3.1 景区封面头图
Stack({ alignContent: Alignment.BottomStart }) {
Image(s.heroUrl ?? $r('app.media.spot_hero_default'))
.width('100%').height(240).objectFit(ImageFit.Cover)
Column() {
Text(s.nameCn).fontSize(30).fontWeight(900).fontColor('#FFFFFF')
Row() {
Text('🎫 ' + s.ticketPrice)
.fontSize(14).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(999)
.backgroundColor('rgba(0,0,0,.42)').margin({ right: 8 })
Text('📍 ' + s.district)
.fontSize(14).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(999)
.backgroundColor('rgba(0,0,0,.42)')
}
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 20 })
}
.width('100%').margin({ bottom: 16 })
Scroll() {
Column() {
// 3.2 实况窗卡片(跨文章复用)
LiveViewWidget({ spotId: s.spotId })
.margin({ bottom: 16 })
// 3.3 国庆碰一碰入口(跨文章复用)
MedalExchangeEntry({ context: 'spot_detail', spot: s })
.margin({ bottom: 20 })
// 3.4 18 个打卡点列表(滚动)
Text('🎯 景区打卡点 · 18 个').fontSize(18).fontWeight(800)
.fontColor('#FFFFFF').width('100%').margin({ bottom: 10 })
Grid() {
ForEach(SpotService.getCheckPoints(s.spotId).slice(0, 12), (cp) => {
GridItem() {
Column() {
Text(cp.icon).fontSize(30)
Text(cp.name).fontSize(13).fontColor('#E0E0E0').margin({ top: 6 })
if (cp.queueMin) {
Text(`${cp.queueMin}′`).fontSize(11).fontColor('#FFC93C')
.margin({ top: 3 })
}
}
.width('100%').padding(12)
.backgroundColor('rgba(255,255,255,.04)').borderRadius(14)
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10).columnsGap(10)
.width('100%')
}
.width('100%')
.padding({ left: 20, right: 20, top: 0, bottom: 30 })
}
.scrollBar(BarState.Auto).layoutWeight(1)
}
.width('100%').height('100%')
.backgroundColor('#16161E')
// ★ 平行视界的国庆彩蛋:HALF_FOLD(3) 帐篷模式 → 自动打开 Vlog 笔记在下半屏
if (this.foldStatus === display.FoldStatus.HALF_FOLDED) {
Column() {
this.VlogEditor()
}
.width('100%').height('42%')
.backgroundColor('#0F1016')
.border({ width: 1, color: { top: 'rgba(255,201,60,.28)' } })
}
}
/* =========================================================
* 帐篷模式:国庆 Vlog 笔记编辑器(下半屏)
* =======================================================*/
@Builder
VlogEditor() {
Column() {
Row() {
Text('✍️ 国庆笔记 · 帐篷模式(悬停自动打开)')
.fontSize(16).fontWeight(800).fontColor('#FFFFFF')
Blank()
Button('一键发布到朋友圈').fontSize(13).height(36).borderRadius(18)
.backgroundColor('#E53935')
}
.width('100%').margin({ bottom: 10 })
TextArea({ placeholder: '今天在故宫拍到了第一次升旗,风很大,国旗飘扬的那一刻…… #高校周边通 #国庆Vlog' })
.width('100%').layoutWeight(1)
.backgroundColor('rgba(255,255,255,.05)')
.borderRadius(12).fontColor('#E5E5E5').placeholderColor('#8A8FA3')
.padding(14)
// 快捷标签
Flex({ wrap: FlexWrap.Wrap }) {
QuickChip('#升旗', 'rgba(229,57,53,.14)')
QuickChip('#3DGS模型', 'rgba(187,154,247,.14)')
QuickChip('#一日一景', 'rgba(125,207,255,.14)')
QuickChip('#碰一碰中奖', 'rgba(255,201,60,.14)')
}.width('100%').margin({ top: 10 })
}
.width('100%').height('100%').padding(16)
}
/* =========================================================
* 单栏(外屏)详情页
* =======================================================*/
@Builder
FullDetailPage(s: SpotItem) {
Column() {
Row() {
Button('← 返回').height(36).backgroundColor('rgba(255,255,255,.07)')
.fontColor('#FFFFFF').borderRadius(18).onClick(() => this.pathStack.pop())
Blank()
Text(s.nameCn).fontSize(18).fontWeight(800).fontColor('#FFFFFF').maxLines(1)
}
.width('100%').padding(16)
Scroll() { this.RightDetailColumn(s) }
}
.width('100%').height('100%').backgroundColor('#16161E')
}
}
// ————————————————————————————
// 复用小组件(内联简化)
@Component
struct QuickChip { label: string = ''; bg: ResourceStr = '#222';
build() {
Text(this.label).fontSize(12).fontColor('#E5E5E5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(999)
.backgroundColor(this.bg).margin({ right: 8, top: 6 })
}
}
3.4 跨模块复用:把前几篇文章的能力嵌进右栏(示例)
/**
* @file widget/LiveViewWidget.ets
* @brief 平行视界右栏复用「第二篇:实况窗」的景区排队组件
*/
@Component
export struct LiveViewWidget {
spotId: string = '';
@State queueMin: number = 60; // 实际调用 liveViewManager.getLiveViewState(id) 取
build() {
Column() {
Row() {
Text('📡 实况窗 · 排队追踪').fontSize(16).fontWeight(800).fontColor('#FFFFFF')
Blank()
Text(`${this.queueMin} 分钟`).fontSize(14).fontWeight(800).fontColor('#E53935')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(999)
.backgroundColor('rgba(229,57,53,.14)')
}.width('100%')
// 进度条(与第二篇实况窗进度同一个数据源)
Progress({
value: 100 - this.queueMin,
total: 100,
style: ProgressStyle.Linear
})
.color(this.queueMin > 45 ? '#E53935' : '#4FD6BE')
.width('100%').margin({ top: 10 })
Flex({ justifyContent: FlexAlign.SpaceEvenly }) {
LiveStat('前方', '128 位', '#E0E0E0')
LiveStat('ETA', '10:48', '#4FD6BE')
LiveStat('通道', '午门南', '#BB9AF7')
LiveStat('更新', '刚刚', '#FFC93C')
}.width('100%').margin({ top: 14 })
Row() {
Button('开启状态栏胶囊')
.layoutWeight(1).height(42).borderRadius(21).backgroundColor('#E53935')
.onClick(() => { /* 调用第二篇:liveViewManager.startLiveView({...}) */ })
Blank()
Button('一键导航').layoutWeight(1).height(42).borderRadius(21)
.backgroundColor('#7DCFFF').fontColor('#0F1016').fontWeight(700)
}.width('100%').margin({ top: 16 })
}
.width('100%').padding(18).borderRadius(18)
.backgroundColor('rgba(255,255,255,.04)')
.border({ width: 1, color: 'rgba(229,57,53,.2)' })
}
}
@Component
struct LiveStat { k: string = ''; v: string = ''; c: string = '#fff'
build() {
Column() {
Text(this.k).fontSize(11).fontColor('#8A8FA3')
Text(this.v).fontSize(15).fontWeight(800).fontColor(this.c).margin({ top: 4 })
}
}
}
3.5 distributedKVStore 跨端同步示例(真实 API 速用片段)
/**
* @brief 真实 API:把「国庆纪念章」通过分布式 KV 同步到同账号其它设备
* (精准碰一碰第三档兜底:KNOCK 和 NFC 都失败时走同账号 KV 推送)
*/
async function syncMedalViaKV(context: Context, medal: object, deviceIds: string[]) {
// 1. 创建 KVManager(真实签名:{context, bundleName},不传废弃字段 userId/userType)
const kvMgr = distributedKVStore.createKVManager({
context,
bundleName: 'com.campus.explorer'
});
// 2. 获取/打开 S3 安全级单版本 KV 仓库
const store = await kvMgr.getKVStore('medal_exchange_2026', {
securityLevel: distributedKVStore.SecurityLevel.S3,
area: 1
});
// 3. 写数据
await new Promise<void>((resolve, reject) => {
store.put(`medal:${Date.now()}`, JSON.stringify(medal), (err) => {
err ? reject(err) : resolve();
});
});
// 4. 订阅 syncComplete 事件(★ 真实事件名:syncComplete,不是我之前编造的 syncCompleted)
store.on('syncComplete', (results: Array<{ deviceId: string; status: number }>) => {
hilog.info(0x0000, 'CampusExplorer', `KV syncComplete results = ${JSON.stringify(results)}`);
});
// 5. 触发同步(★ 真实枚举:SyncMode.PUSH_ONLY,不是编造的 SYNC_MODE_PUSH)
// deviceIds 传空数组 = 同账号所有在线设备
store.sync(deviceIds.length ? deviceIds : [], distributedKVStore.SyncMode.PUSH_ONLY);
}
四、国庆运营专题 + 折叠屏适配 FAQ
4.1 三套「国庆专属比例」分屏布局(windowMode)
| windowMode | 展开态宽度 | 推荐处理(配合 NavigationMode.Auto) | 右栏布局 |
|---|---|---|---|
WINDOW_MODE_FULLSCREEN | 7.85″ = 2480vp | Auto 自动变 Split;navBarWidthRange(280~400) | 封面大图 + 实况窗 + 打卡 Grid |
WINDOW_MODE_SPLIT_PRIMARY | 840vp(左右各半分屏) | forceSplit = true,强制 Split | 封面调小到 180vp,实况窗保留 |
WINDOW_MODE_FLOATING | 自由拖动 600~1200vp | Auto + minContentWidth 520;< 760vp 自动退化为单栏 | < 760vp 自动退化为单栏 |
4.2 国庆用户常见报错 & 解决(★ 全部对应真实 API 报错根因)
| 症状 | 官方原因 | 解决 |
|---|---|---|
| 折叠屏展开后整个 App 被"拉胖",导航栏错位 | 没有设置 orientation = unspecified 或 supportWindowMode 缺少 split | 回 3.1 开启三项配置 |
| 展开后 Navigation 还是单栏 / 右栏永远黑屏 | 1) 没监听 foldStatusChange 或 mode 硬写成 Stack 2) 没加 splitPlaceholder(Auto/Split 首次无选中项时右栏空) | ① 用 NavigationMode.Auto + onNavigationModeChange 里在 mode===Split 时默认推第一个景区;② 必须加 splitPlaceholder(builder) |
half_fold 帐篷模式 App 黑屏一半 | 没有加 HALF_FOLD(3) 下的 Builder 或面板高度 > 45% | 在 RightDetailColumn 底部加 if foldStatus===HALF_FOLDED(3) 的下半屏,.height('42%') |
| 右栏点击返回按钮,左栏也跟着跳了(经典坑) | 声明了 leftStack/rightStack 两个 NavPathStack(我之前版本错误架构) | 删除 rightStack,全程只用一个 NavPathStack,官方 Navigation 在 Split 模式下自动把栈顶目的地渲染到右栏 |
| windowSizeChange 不回调 | 没有调用 window.getLastWindow(ctx) 先拿到窗口对象,直接调 window.on 无效 | 先 getLastWindow() 拿实例(Promise),再 on(‘windowSizeChange’) |
| createKVManager 报参数错误或 “deprecated field userId” | 1) 传了不存在的 @kit.DistributedDataServiceKit 包 2) config 里塞了废弃字段 userId / userType | ① 用 @kit.ArkData → distributedKVStore;② config 只传 { context, bundleName } |
| kvStore.sync 无回调 / 报错 syncCompleted 事件不存在 | 写了我之前编造的事件名 syncCompleted 或枚举 SYNC_MODE_PUSH | ① 事件改 on('syncComplete', cb)(Complete 无 d);② 枚举改 distributedKVStore.SyncMode.PUSH_ONLY |
五、写在最后:平行视界,是鸿蒙体验的「第三屏」
很多人以为折叠屏只是"更大的屏幕",真正做完 v3.6.0 国庆专题的平行视界,团队才明白:
外屏是快速消费(10 秒查排队),
展开屏是深度沉浸(左攻略右详情,不切换不等待),
帐篷屏是创造(支架立起来,边看直播边写 Vlog)。
这三套态不是"适配",而是三套独立的用户故事——真正把折叠屏的物理优势,转化成了用户的国庆体验优势。
特别要感谢英文站 developer.huawei.com/consumer/en/doc/harmonyos-references/js-apis-display 2026-09-08 更新的 FoldStatus 枚举真实值:之前版本把 1/2/3 全反了,读者如果按旧值写 switch 会一跑就崩(展开被判成 FOLDED 直接隐藏左栏,展开态外屏 UI 全错位)。
在 Mate X5 的内测用户里,v3.6.0 展开态停留时间从 4.2 分钟 → 18.7 分钟,实况窗开启率提升 3.1×,碰一碰传章成功率 98.1%。
— END —
更多推荐



所有评论(0)