【天体运行模拟|03】HarmonyOS ArkTS 场景选择实战:组织行星、卫星与天文现象入口

场景选择页很容易被写成一组"点亮后看起来选中了"的卡片,但真正进入工程后,至少有四件事必须同时成立:列表数据能描述业务场景,选中状态只有一个可信来源,确认动作能把选择带到下游页面,图标与辅助参数能让用户在进入模拟前理解差异。

"天体运行模拟"的源码里已经有 SceneSelectorPage.etsScene.ets,但当前默认数据仍是"教学楼、月球表面、高塔、自定义场景",更接近自由落体教学模板;页面底部的"确认选择"按钮也尚未绑定跳转或回传动作。与此同时,真正的模拟页已经支持恒星、行星、卫星、小行星、黑洞,以及稳定双体、三体扰动、双星、星系碰撞等天文实验。

这正好提供了一个真实的工程问题:如何保留现有 ArkUI 列表与选择交互,把模型从通用重力场景升级为天文入口,并且不把"视觉选中"误当成"业务已生效"。本文会先复核现有代码,再给出可迁移的类型、路由和验证方案。所有增强代码都会明确标注,避免把规划中的能力写成已经上线的事实。

唯一复核标记:SCENE-ONE13-SELECT-CONTRACT-20260726:场景数据定义入口,selectedId 定义当前选择,确认动作必须显式传递场景标识。

一、先看真实现状:页面能选中,但还没有完成业务闭环

页面状态非常简洁:

ts 复制代码
@State scenes: Scene[] = getDefaultScenes()
@State selectedId: string = 'building'

列表通过 ForEach 渲染,点击某个卡片时更新 selectedId

ts 复制代码
.onClick(() => {
  this.selectedId = scene.id
})

选中项会改变背景、边框并显示对勾:

ts 复制代码
if (this.selectedId === scene.id) {
  Text(' ✓')
    .fontSize(16)
    .fontColor(AppColors.ACCENT_GREEN)
}

这些代码证明"本地视觉选择"已经存在。但底部按钮只有样式:

ts 复制代码
Button('确认选择')
  .fontSize(15)
  .fontColor(AppColors.TEXT_WHITE)
  .backgroundColor(AppColors.PRIMARY)
  .borderRadius(24)
  .height(48)
  .width('60%')
  .margin({ bottom: 24, top: 12 })

它没有 .onClick(),没有 router.pushUrl(),也没有返回参数。因此当前源码不能被描述为"选中后已经切换天文模拟场景"。准确说法是:页面实现了列表、单选视觉状态和确认按钮外观,业务提交尚未接通。

二、当前 Scene 模型混合了身份、展示、物理参数与状态

真实模型如下:

ts 复制代码
export interface Scene {
  id: string
  name: string
  description: string
  height: number
  gravity: number
  icon: Resource
  isSelected: boolean
  isCustom: boolean
}

可以把字段分成四类:

类型 字段 当前职责
身份 id 列表判断与未来路由参数
展示 namedescriptionicon 卡片标题、说明和图片
模拟参数 heightgravity 自由落体类场景参数
UI 状态 isSelectedisCustom 默认选择与自定义标识

问题不在字段多,而在"谁是真正状态源"不够明确。页面使用 selectedId 判断选中状态,却没有读取或更新 scene.isSelected。默认数据中 building.isSelectedtrue,恰好与页面默认值 'building' 一致,所以暂时没有冲突;如果将默认选中项改成月球,只改模型或只改页面都会造成状态分叉。

更稳的原则是:

  • 场景数据描述"它是什么"。
  • 页面状态描述"用户现在选了谁"。
  • 不在每个列表项里保存可推导的 isSelected

如果必须从数据层指定默认项,可以新增 isDefault,页面首次加载时只读取一次,然后仍以 selectedId 作为运行期唯一状态。

三、默认数据与天文产品定位存在明确错位

getDefaultScenes() 当前返回四项:

ts 复制代码
{
  id: 'building',
  name: '教学楼',
  description: '标准场景,适合基础模拟',
  height: 10,
  gravity: 9.8,
  icon: $r('app.media.ic_exp_freefall'),
  isSelected: true,
  isCustom: false
}
ts 复制代码
{
  id: 'moon',
  name: '月球表面',
  description: '低重力环境',
  height: 10,
  gravity: 1.62,
  icon: $r('app.media.ic_exp_freefall'),
  isSelected: false,
  isCustom: false
}

另外两项是"高塔"和"自定义场景"。除了月球外,它们并不是行星、卫星或天文现象入口;四项还共用同一个 ic_exp_freefall 图标。

这不是可以靠改标题掩盖的小问题。若产品页面标题是"天体运行模拟",场景卡片却出现教学楼和高塔,用户会怀疑自己是否进入了错误模块。发布前应在两条路线中明确选择:

  1. 如果此页服务于自由落体实验,保留当前数据,但调整页面归属、命名和跳转目标。
  2. 如果此页服务于天体模拟,重构场景模型和默认数据,改为与模拟页已有 expId 一致的入口。

本文后续采用第二条作为演进方案,但不会声称当前源码已经完成重构。

四、场景入口应围绕模拟页已经支持的 expId

真实模拟页根据 expId 选择场景:

expId 模拟页场景
stable_orbit 稳定双体系统
black_hole 黑洞吞噬行星
three_body 三体扰动实验
binary_star 自定义双星系统
galaxy_collision 星系碰撞预演
elliptic_escape 椭圆与逃逸轨道
sandbox 自由宇宙

因此,新的场景入口不需要发明另一套 scene code。最重要的契约是:选择页输出的 ID 必须能被 ExperimentSimPage.resetSystem() 识别。

可以定义面向天文场景的类型:

ts 复制代码
export type CelestialSceneId =
  | 'stable_orbit'
  | 'black_hole'
  | 'three_body'
  | 'binary_star'
  | 'galaxy_collision'
  | 'elliptic_escape'
  | 'sandbox'

export type SceneCategory =
  | '轨道'
  | '多体'
  | '极端天体'
  | '星系'
  | '自由创建'

这段是建议的重构代码。它把字符串集合收紧为联合类型,能够在 ArkTS 编译阶段发现拼写错误,避免选择页传 stable-oribt 后模拟页悄悄落回默认分支。

五、重新设计场景模型:保留展示信息,移除自由落体专属字段

面向当前产品,更贴切的模型可以是:

ts 复制代码
export interface CelestialScene {
  id: CelestialSceneId
  name: string
  description: string
  category: SceneCategory
  icon: Resource
  bodyTypes: string[]
  difficulty: '入门' | '进阶' | '挑战'
  isCustom: boolean
}

这里没有 height 和固定 gravity,因为 N 体模拟中的引力来自天体质量与实时距离,不是场景级常量。新增字段各有明确用途:

  • category 用于按轨道、多体、极端天体等分类。
  • bodyTypes 提示场景中会出现恒星、行星、黑洞等对象。
  • difficulty 帮助学习者选择合适入口。
  • isCustom 区分预置场景与自由宇宙。

默认数据可以复用模拟页已存在的能力:

ts 复制代码
export function getCelestialScenes(): CelestialScene[] {
  return [
    {
      id: 'stable_orbit',
      name: '稳定双体系统',
      description: '观察恒星与行星的近圆轨道',
      category: '轨道',
      icon: $r('app.media.ic_exp_stable_orbit'),
      bodyTypes: ['恒星', '行星'],
      difficulty: '入门',
      isCustom: false
    },
    {
      id: 'three_body',
      name: '三体扰动实验',
      description: '观察初值扰动如何改变多体轨迹',
      category: '多体',
      icon: $r('app.media.ic_exp_three_body'),
      bodyTypes: ['恒星', '行星'],
      difficulty: '挑战',
      isCustom: false
    },
    {
      id: 'sandbox',
      name: '自由宇宙',
      description: '自行放置恒星、行星与卫星',
      category: '自由创建',
      icon: $r('app.media.ic_exp_sandbox'),
      bodyTypes: ['恒星', '行星', '卫星'],
      difficulty: '进阶',
      isCustom: true
    }
  ]
}

资源名只是建议,必须在实际资源目录存在后才能引用。当前源码所有旧场景共用 ic_exp_freefall,不能直接声称已经有这些独立图标。

六、单选状态只保留 selectedId

页面现在已经使用这个模式:

ts 复制代码
@State selectedId: string = 'building'

升级后可以把类型收紧,并让默认值与第一项天文场景一致:

ts 复制代码
@State selectedId: CelestialSceneId = 'stable_orbit'

点击逻辑仍然简单:

ts 复制代码
.onClick(() => {
  this.selectedId = scene.id
})

所有视觉状态都从同一个表达式派生:

ts 复制代码
const selected = this.selectedId === scene.id

ArkUI 的声明式渲染适合这种"一个状态,多处消费"的结构。背景、边框、对勾、辅助文案都不需要分别维护布尔值。

如果把 isSelected 留在每个数据项里,每次点击就要遍历数组、清除旧项、设置新项,再创建新数组触发刷新。对于单选列表,这种复杂度没有收益。

七、确认按钮必须完成路由契约

当前按钮没有行为,这是闭环中最需要补齐的一步。若确认后直接进入模拟页,可以这样组织:

ts 复制代码
private confirmScene(): void {
  const selected = this.scenes.find(
    (scene: CelestialScene) =>
      scene.id === this.selectedId
  )
  if (!selected) {
    return
  }

  router.pushUrl({
    url: 'views/experiment/ExperimentSimPage',
    params: {
      expId: selected.id,
      expName: selected.name
    }
  })
}

按钮绑定:

ts 复制代码
Button('确认选择')
  .onClick(() => {
    this.confirmScene()
  })

模拟页已经真实读取这两个参数:

ts 复制代码
const params =
  router.getParams() as SimRouterParams | undefined

if (params?.expId) {
  this.expId = params.expId
}
if (params?.expName) {
  this.title = params.expName
}

这说明路由契约具备现成接收端。增强重点不在模拟页,而在选择页必须传出与其分支一致的 expId

八、确认前要重新查找对象,不能只信任字符串

即使 selectedId 有类型约束,确认时仍建议从当前列表查找一次。原因包括:

  • 列表可能经过分类过滤或远期配置迁移。
  • 默认值可能指向已删除场景。
  • 恢复的历史选择可能不再受支持。
  • 页面初始化和数据加载顺序可能发生变化。

找不到时不应直接跳转到默认模拟。否则用户选择失效却没有提示,排查起来会像模拟页错误。可以维护一个页面错误状态:

ts 复制代码
@State selectionError: string = ''

private selectedScene(): CelestialScene | undefined {
  return this.scenes.find(
    (scene: CelestialScene) =>
      scene.id === this.selectedId
  )
}
ts 复制代码
const selected = this.selectedScene()
if (!selected) {
  this.selectionError = '当前场景不可用,请重新选择'
  return
}

这是增强建议,当前页面没有错误提示状态。它体现了一个重要原则:路由参数是跨页面协议,不应仅靠视觉选中保证正确。

九、卡片展示应回答"进入后会看到什么"

当前卡片显示名称、描述、高度和重力加速度:

ts 复制代码
if (scene.height > 0) {
  Text(`高度:${scene.height} m`)
}
if (scene.gravity !== 9.8) {
  Text(`重力加速度:${scene.gravity} m/s²`)
}

对天文模拟,这些字段不再合适。更有价值的是:

  • 天体构成:恒星 + 行星、双星 + 外侧行星、黑洞 + 行星。
  • 观察目标:稳定轨道、逃逸、扰动、碰撞合并。
  • 难度:入门、进阶、挑战。
  • 可编辑性:预置场景或自由创建。

ArkUI 卡片可以继续保持现有结构,只替换辅助行:

ts 复制代码
Row({ space: 8 }) {
  Text(scene.category)
  Text(scene.difficulty)
  Text(scene.bodyTypes.join(' / '))
}

长文本要设置 maxLinestextOverflow,尤其是 phone 小窗口:

ts 复制代码
Text(scene.description)
  .maxLines(2)
  .textOverflow({
    overflow: TextOverflow.Ellipsis
  })

当前源码描述文本没有设置最大行数。默认数据很短,因此未必溢出;升级为更完整的天文说明后,这个约束会变得必要。

十、分类入口不要把页面变成无限长列表

当场景从 4 个增加到 7 个以上,仍然全部平铺并非不能用,但用户很难快速区分"轨道"和"极端天体"。可以像公式速查页一样增加横向分类:

ts 复制代码
@State selectedCategory: SceneCategory | '全部' = '全部'

private visibleScenes(): CelestialScene[] {
  if (this.selectedCategory === '全部') {
    return this.scenes
  }
  return this.scenes.filter(
    (scene: CelestialScene) =>
      scene.category === this.selectedCategory
  )
}

这里有一个容易忽略的状态问题:切换分类后,当前选中场景可能不在可见列表中。产品需要明确策略:

策略 行为 适用场景
保留选择 分类切换不改变 selectedId 用户可能只是浏览
清空选择 当前项不可见时要求重选 确认必须针对可见项
自动选首项 分类切换后选中第一项 快速操作但可能误触

对于教学入口,保留选择并在确认区显示当前场景通常最稳,避免用户只是查看别的分类就丢失选择。

十一、自定义场景应与预置场景走不同动作

当前模型有 isCustom,卡片右侧显示齿轮:

ts 复制代码
if (scene.isCustom) {
  Text('⚙')
    .fontSize(20)
    .fontColor(AppColors.TEXT_SECONDARY)
}

但点击自定义项仍然只是选中,没有打开配置页。天文版本可以定义两条路径:

  1. 预置场景:确认后直接进入 ExperimentSimPage
  2. 自由宇宙:确认后进入模拟页的 sandbox 分支,再由页面中的天体库和参数 Slider 完成创建。

真实模拟页已经支持 sandbox,初始 bodies 为空,并提供恒星、行星、卫星、小行星、黑洞的放置入口。因此这里不一定要新增"自定义配置页";用现有自由宇宙就能形成最小闭环。

齿轮图标若没有独立操作,应避免让用户误以为可以在卡片内配置。可以改为"自由创建"文本徽标,或真正绑定一个编辑动作。

十二、图标资源必须与每个场景一一对应

当前四个默认场景都引用:

ts 复制代码
icon: $r('app.media.ic_exp_freefall')

这在功能模板阶段可以占位,但正式的天文选择页至少应让稳定轨道、黑洞、三体、双星和星系碰撞具有可区分图标。否则用户主要依赖文字,列表扫描效率很低。

图标准备需要遵守三个边界:

  • 资源必须真实存在,ArkTS $r() 名称与文件一致。
  • 图标只表达场景,不伪造实际模拟画面。
  • 亮色、深色背景和选中背景下都保持足够对比度。

若暂时没有独立资源,宁可使用统一的类型图标加清晰文字,也不要在代码里引用不存在的媒体名。

十三、列表布局已经具备基础自适应,但还需补足文本约束

现有页面使用:

ts 复制代码
List({ space: 12 }) {
  ForEach(this.scenes, (scene: Scene) => {
    ListItem() {
      Row() {
        // icon + information + custom marker
      }
    }
  })
}
.width('100%')
.layoutWeight(1)

信息列通过 .layoutWeight(1) 获取剩余空间,图标固定为 64×64。这个结构在 phone 上很常见,在 tablet 和 2in1 上也能拉伸。

但多设备验证要关注:

  • 超长场景名是否挤压对勾与自定义标识。
  • 两行描述是否把卡片高度撑得不一致。
  • 2in1 宽窗口是否需要双列布局,而不是单列无限拉宽。
  • 底部按钮与系统导航区域是否保留安全距离。
  • 横屏小窗口中最后一个列表项能否滚动到按钮上方。

当前按钮底部 margin 为 24,而全局页面还使用 bottomBarHeight。在手势导航设备上,应验证两者组合后不被系统区域遮挡。

十四、路由方式要区分"进入"与"返回选择结果"

如果选择页由首页打开,并希望进入新模拟页,router.pushUrl() 合适。如果选择页是从模拟页的"切换场景"进入,则继续 push 可能形成:

模拟页 A -> 选择页 -> 模拟页 B

用户按返回会回到旧模拟页 A,体验不一定符合预期。此时可以考虑:

  • 返回参数给上一页,由上一页重置当前场景。
  • 使用替换式路由,避免保留旧模拟页。
  • 在进入选择页前明确退出当前模拟。

当前源码没有确认动作,也没有定义这项导航策略。实现前应先确定页面入口。路由 API 的选择是用户返回路径的一部分,不是按钮点击后的随意细节。

十五、选择结果如果需要持久化,存 ID 而不是整份对象

若产品希望下次进入时恢复上次场景,建议只存:

ts 复制代码
interface ScenePreference {
  selectedSceneId: CelestialSceneId
}

不要把名称、描述、图标资源和难度整份序列化。应用升级后文案或图标可能变化,恢复旧对象会造成展示与当前版本不一致。读取 ID 后再从当前 getCelestialScenes() 查找,找不到就回退到 stable_orbit

当前源文件没有场景持久化逻辑,本文不声称它会记住选择。这是未来接入 Preferences 时应遵守的数据边界。

十六、场景模型与模拟分支必须做一致性检查

最容易发生的回归是:选择页新增了一个场景 ID,模拟页没有对应分支,最终落到默认稳定双体。可以写一个轻量测试或构建期检查:

ts 复制代码
const supportedIds: CelestialSceneId[] = [
  'stable_orbit',
  'black_hole',
  'three_body',
  'binary_star',
  'galaxy_collision',
  'elliptic_escape',
  'sandbox'
]

const invalid = getCelestialScenes().filter(
  (scene: CelestialScene) =>
    !supportedIds.includes(scene.id)
)

更理想的是把"场景 ID + 初始化函数"集中到一个注册表,选择页与模拟页共同读取,而不是分别维护字符串。但当前代码规模较小,先用联合类型和检查数组就能显著降低风险,不必立即引入复杂框架。

十七、最小验收用例

默认进入

  1. 打开场景选择页。
  2. 确认默认场景有清晰边框和对勾。
  3. 确认只有一个项目选中。

切换选择

  1. 依次点击三个场景。
  2. 每次仅最新项显示选中状态。
  3. 列表滚动后,选中状态仍保持。

确认契约

  1. 选中 three_body
  2. 点击确认。
  3. 回读模拟页标题与 expId
  4. 确认创建的是两颗恒星与一颗行星,而不是默认双体。

自由宇宙

  1. 选择 sandbox
  2. 进入后确认初始画布没有预置天体。
  3. 使用天体库放置恒星与卫星。

返回路径

  1. 从模拟页进入选择页并切换场景。
  2. 新场景启动后按系统返回。
  3. 确认不会意外回到仍在运行的旧模拟。

多设备

  1. phone 竖屏、横屏分别检查文字截断。
  2. tablet 检查卡片宽度与内容密度。
  3. 2in1 检查鼠标选中、键盘焦点和窗口缩放。

十八、常见问题与修复方向

现象 原因 修复
卡片有对勾,确认后没反应 按钮未绑定 .onClick() 建立显式确认方法
选中月球但仍进入默认场景 ID 未传递或模拟页不识别 对齐 expId 联合类型
模型写已选中,页面却显示另一项 isSelectedselectedId 双状态 保留唯一 selectedId
页面出现教学楼和高塔 仍使用自由落体默认数据 重构为天文场景模型
所有卡片图标相同 共用占位资源 准备真实且可区分的图标
自由场景齿轮不能点击 图标仅装饰 改徽标或绑定真实动作
描述变长后卡片错位 没有行数与溢出约束 设置 maxLines
切换分类后确认旧场景 选择与可见列表策略不明确 显示当前选择或要求重选
返回后看到旧模拟仍运行 路由栈保留旧页面 明确替换或回传策略

十九、发布前检查清单

  • 页面显示的场景与产品天文定位一致。
  • Scene 模型不再保留无关的自由落体字段。
  • 场景 ID 与模拟页分支完全一致。
  • 选中状态只有一个可信来源。
  • 确认按钮有真实动作和失败兜底。
  • 自定义入口与预置入口行为明确。
  • 图标资源真实存在且一一对应。
  • 长标题和描述在小窗口不溢出。
  • phone、tablet、2in1 的滚动与点击可用。
  • 返回路径不会留下后台运行的旧模拟。
  • 若持久化,只保存稳定 ID,并处理版本迁移。
  • 不把尚未接通的确认动作描述为已实现功能。

二十、总结:场景页的交付物不是卡片,而是一个可靠入口协议

现有 SceneSelectorPage 已经提供了可复用的 ArkUI 骨架:顶部返回、List 列表、图标信息卡、selectedId 单选状态和底部确认按钮。真正需要修正的是业务契约:默认数据仍属于自由落体模板,isSelected 与页面状态重复,确认动作尚未传递选择结果。

把它升级为天文入口时,最稳的做法是以模拟页真实支持的 expId 为核心,建立收紧类型的场景模型;用 selectedId 管理唯一选择;确认时查找当前对象并显式传递 expIdexpName;再根据轨道、多体、极端天体、星系和自由创建组织卡片。

这样,行星、卫星和天文现象不只是列表上的名词,而会成为能够被路由验证、被模拟页识别、被返回路径正确管理的 HarmonyOS 功能入口。

相关推荐
加农炮手Jinx10 小时前
Flutter for OpenHarmony 实战:flutter_animate 声明式动画让 UI 灵动如原生
flutter·ui·华为·harmonyos·鸿蒙
里欧跑得慢10 小时前
Flutter 三方库 function_tree — 鸿蒙应用开发中的动态数学公式解析与计算神器,实现鸿蒙深度适配下的复杂函数逻辑运行时求值实战(适配鸿蒙 HarmonyOS Next ohos)
android·前端·安全·flutter·华为·harmonyos
2501_9197490312 小时前
华为鸿蒙免费制定规则软件—小羊规则
华为·harmonyos·鸿蒙
lilian23313 小时前
HarmonyOS 7 新特性(四)|沉浸光感:空间材质、性能分级与降级策略
前端·pytorch·华为·harmonyos·材质
大雷神14 小时前
HarmonyOS AR Engine 物体形状实战:把实体书识别为 RECTANGLE,再放进地面 AR 盒子
ar·restful·harmonyos
OH_TPC17 小时前
HarmonyOS APP开发---"新鲜事"社交动态App,需要用到这个库
harmonyos
not coder18 小时前
我给鸿蒙原生 OFD 阅读器加了手写签批:手写笔防误触、矢量笔迹、批注追溯
华为·harmonyos·arkts·ofd
Magic-ZYJ19 小时前
HarmonyOS 页面生命周期完整梳理:aboutToAppear、onPageShow、onPageHide 别再混着用
华为·ts·harmonyos·移动应用开发·arcts·arcui
lilian23320 小时前
HarmonyOS 7 新特性(五)|ContainerReader 容器断点与自适应布局
前端·华为·harmonyos