1. 前言

一张课表有三个入口:今天上什么课、这周练什么、已经学了多久。窗口比较窄时,把入口放在底部,正文留在中间;空间宽起来以后,让入口出现在正文上方,扫一眼就能切栏目。菜单换个位置并不难,容易被漏掉的是换位置之后的那一次点击。

比如先打开练习清单,加入一项练习,再把窗口拉宽。如果页面突然回到今日课程,或者加入按钮又变成初始状态,用户就得重新找一遍。映日课表把这个过程放在一起做:栏目位置跟随可用宽度,栏目选择和练习状态各自保留。

本例使用 HarmonyOS 6.1.1、API 24,运行环境是二合一模拟器。动图比较的是同一应用的宽窄自由窗口,布局依据实际内容区域;手机上的底部使用习惯是设计参考,不把这段窄窗口演示当成手机设备实测。

2. 课表状态的三条路径

在DevEco Studio中创建Empty Ability,选择HarmonyOS 6.1.1、API 24。示例的设备声明包含Phone和2in1,这一轮实际运行的是二合一模拟器。保留模板生成的EntryAbility与页面注册,把入口页面换成后文的Index,再在ets目录下建common,放入WindowTools。页面通过相对路径引入工具,不能把它误放进resources。

这个练习没有外部图片或网络数据,因此不用先申请联网权限,也不需要安装第三方导航库。课表、练习和学习档案都在一个页面内,用选中索引控制显示。先理解这一点,就不会把栏目切换误写成每点一次都向路由栈添加一个新页面。

可以沿着三条路径读代码:窗口尺寸进入widthVp,栏目点击进入selected,加入练习进入joined。三个值最后都参与界面构建,但变化来源不同。先在纸上把这三条线分开,后面看条件布局时就容易发现多余的初始化动作。

2.1 栏目高亮与练习状态

先看菜单的这一段,再回头看整页。tabs提供标题,ForEach给出标题与索引;每个按钮比较selected是否等于自己的索引,同时决定文字色和底色。点击回调只更新索引,显示结果由状态重新推导,不需要逐个找到另外两个按钮,把它们的颜色手动改回去。

  @Builder  menu() {    Row({ space: 8 }) {      ForEach(this.tabs, (name: string, index: number) => {        Button(name).id('tab' + index).layoutWeight(1).height(46)          .fontColor(this.selected === index ? '#FFFFFF' : '#315168')          .backgroundColor(this.selected === index ? '#247A86' : '#E9F3F1')          .onClick(() => { this.selected = index; })      }, (name: string) => name)    }.width('100%').padding(12).backgroundColor('#FFFFFF')  }

假设先点“练习清单”,selected从零变为一。菜单中索引一的按钮使用强调色,正文分支也读到同一个一,于是标题和内容一起对应练习清单。这份值是两处显示的共同来源;若标题另外保存一个字符串,每次点击就要维护两个值,很容易出现标题已切换、正文没跟上的情况。

joined属于当前页面,不属于menu里的某一次调用。加入按钮本次采用开关行为,连续点击会在加入与未加入之间切换;它不是只允许执行一次的报名接口。若改成真正提交练习,应该再引入请求中、成功和失败,而不能只把按钮文字改成“已加入”就当作提交完成。

3. 菜单位置与宽度条件

页面把菜单写成 menu() 构建方法。宽度达到 720 vp 时,在正文之前调用;低于这个宽度时,在正文之后调用。两个条件互斥,因此画面上始终只有一份正在使用的菜单,不需要同时摆两套按钮再把其中一套调成透明。

菜单的三个项目仍来自同一个字符串数组。按钮的选中颜色取决于 selected,点击后也只更新 selected。这个值保存的是栏目索引,与菜单位于上方还是下方无关。将来增加栏目时,若栏目会重排或由服务端配置,可以进一步改用稳定的栏目 id。

joined 单独记录是否加入练习。它没有写在菜单构建方法的局部变量里,也没有放进宽度变化回调。菜单重新出现在另一个位置时,只会重新读取这份状态。页面退出进程后目前不会保存它;需要长期保留的练习记录,应另接持久化数据层。

自适应菜单位置代码截图

720 vp 是这张课表的版面选择,并不是平台替所有导航页面规定的统一界线。三个中文入口需要的空间、正文的行宽、工具区的留白,都影响这个决定。如果以后增加到六七个长标题,应重新考虑导航形式,不能仅把阈值往上推一点就结束。

3.1 720 vp临界值

二合一设备也能开窄窗口,手机横屏也可能拥有更宽区域。这里让最外层Column的onAreaChange记录可用宽度,条件直接比较这个值。这样同一个运行实例就能跨过720的断点,不必在设备类型变化时重新加载另一套页面。

      }.layoutWeight(1).width('100%')      Text(this.notice).fontSize(12).fontColor('#527177').padding(10)      if (this.widthVp < 720) { this.menu() }    }.width('100%').height('100%').backgroundColor('#F1F6EF')      .onAreaChange((_, area) => { this.widthVp = Number(area.width); })

widthVp初始为1000,只是布局回调到来前的初值;真正显示后会被当前区域宽度覆盖。调试时应看工具区报告的实际宽度,不能因为点了“收窄”就直接把widthVp赋成440。前者反映布局结果,后者只是我们发出的请求,两者之间还隔着窗口管理器的限制。

当宽度等于720时,顶部条件包含等号,底部条件不包含,两者不会同时出现。若分别写成“大于720”和“小于720”,恰好等于边界时菜单可能暂时消失。断点代码只有一行,但等号放在哪里,会直接影响边界行为。

4. 正文与固定菜单分区

外层使用纵向容器。头部、宽窗菜单、正文滚动区、提示和窄窗菜单依次参与排版;正文通过 layoutWeight(1) 使用剩余高度。底部菜单因此有自己的一段空间,不靠绝对坐标悬浮在课程卡片上,也不用给最后一张卡片猜一段额外的底部占位。

课程较多时,滚动发生在正文内部,菜单保持在自己的位置。这样做也让两个方向的阅读比较清楚:上方是页面名称,中间是当前内容,下方在窄窗承担栏目切换。真正加入系统导航容器后,还要沿用容器已有的安全区处理,避免外层和内层各留一次相同空白。

菜单按钮没有随着窗口按比例缩小。宽度不足时,各项平分当前一行,字号与按钮高度保持明确约束。若后续支持更大的系统字号,应继续检查这三个标签,必要时调整布局和文本策略,不能默认正文适配好了,导航就自动都能读清。

还可以试试在同一栏目里连续拉过断点。工具栏报告的宽度可能经过多个中间值,页面允许菜单随条件切换,但不应在切换时自动执行栏目按钮的业务回调。这也是把选中项的更新放在点击事件里、把宽度更新留在区域回调里的原因:两个入口各管一个变化,读代码时就能直接核对。

菜单换位置也会改变键盘焦点所在的组件。当前示例检查了点选路径,若继续面向键盘用户完善,可以在调整后恢复到当前栏目对应的焦点,并给选中按钮明确的视觉提示。恢复焦点与保存栏目不是同一项功能,前者让操作接得上,后者让内容接得上,两者都值得在实际导航中单独安排。

layoutWeight让Scroll使用标题、提示和菜单之外的剩余空间。它与Scroll里的内容宽度配合,解决的是“滚动发生在哪一层”。把Scroll直接设为整页百分之百高,再在它前后放菜单,容易让总高度超过窗口,底部操作随之被挤出可见区域。

5. 课表入口代码

入口页面放在 pages/Index.ets。下面包含三个栏目的内容、练习按钮、菜单位置和状态处理。

import { common } from '@kit.AbilityKit';import { resizeWindow } from '../common/WindowTools';@Entry@Componentstruct Index {  @State widthVp: number = 1000;  @State selected: number = 0;  @State joined: boolean = false;  @State notice: string = '选中的栏目跟着你走';  private tabs: string[] = ['今日课程', '练习清单', '学习档案'];  private async resize(width: number): Promise<void> {    this.notice = await resizeWindow(this.getUIContext().getHostContext() as common.UIAbilityContext,      width, this.getUIContext().vp2px(1));  }  @Builder  menu() {    Row({ space: 8 }) {      ForEach(this.tabs, (name: string, index: number) => {        Button(name).id('tab' + index).layoutWeight(1).height(46)          .fontColor(this.selected === index ? '#FFFFFF' : '#315168')          .backgroundColor(this.selected === index ? '#247A86' : '#E9F3F1')          .onClick(() => { this.selected = index; })      }, (name: string) => name)    }.width('100%').padding(12).backgroundColor('#FFFFFF')  }  build() {    Column() {      Row() {        Text('映日 · 课表').fontSize(25).fontWeight(FontWeight.Bold).layoutWeight(1)        Button('收窄').id('narrow').onClick(() => { this.resize(440); })        Button('展开').id('wide').margin({ left: 8 }).onClick(() => { this.resize(1080); })      }.padding(18).width('100%')      Text((this.widthVp >= 720 ? '顶部菜单' : '底部菜单') + ' · ' + Math.round(this.widthVp) + ' vp')        .id('placement').fontSize(13).fontColor('#527177').padding({ bottom: 10 })      if (this.widthVp >= 720) { this.menu() }      Scroll() {        Column({ space: 18 }) {          Text(this.tabs[this.selected]).id('currentTab').fontSize(32).fontWeight(FontWeight.Bold)          if (this.selected === 0) {            Text('把今天留一点时间给学习').fontSize(18)            ForEach(['09:30  色彩与构图', '14:00  城市速写', '19:30  十分钟复盘'], (s: string) => {              Text(s).width('100%').padding(22).backgroundColor('#FFFFFF').borderRadius(12)            })          } else if (this.selected === 1) {            Text('本周练习:画一张窗边的光').fontSize(21)            Button(this.joined ? '已加入练习' : '加入练习').id('join')              .backgroundColor('#D6754F').onClick(() => { this.joined = !this.joined; })            Text(this.joined ? '清单里已有这项练习' : '准备好就开始,不必等到周末').id('joinState')          } else {            Text('累计学习 12 天').fontSize(38).fontColor('#247A86')            Text('下一次打开菜单,仍从刚才的栏目接着看。').fontSize(17)          }        }.width('100%').alignItems(HorizontalAlign.Start).padding(24)      }.layoutWeight(1).width('100%')      Text(this.notice).fontSize(12).fontColor('#527177').padding(10)      if (this.widthVp < 720) { this.menu() }    }.width('100%').height('100%').backgroundColor('#F1F6EF')      .onAreaChange((_, area) => { this.widthVp = Number(area.width); })  }}

演示中的收窄、展开按钮调用 common/WindowTools.ets。传入目标逻辑宽度之前,先取得当前 UIContext 的像素换算关系,窗口接口最终接收的是像素值。它负责发出调整请求,页面仍从自己的区域回调读取实际宽度。

import { window } from '@kit.ArkUI';import { common } from '@kit.AbilityKit';export async function resizeWindow(context: common.UIAbilityContext, widthVp: number,  density: number): Promise<string> {  try {    const mainWindow = await window.getLastWindow(context);    const rect = mainWindow.getWindowProperties().windowRect;    await mainWindow.resize(Math.round(widthVp * density), rect.height);    return '窗口已调整';  } catch (error) {    return '请先将窗口还原为自由窗口,再调整宽度';  }}

课表入口与选中状态代码截图

窗口尺寸受系统和窗口形态限制,操作失败时会返回提示。验证布局时可以先把应用还原成自由窗口,再点击按钮或手动拖动边缘。不要在请求失败后直接修改 widthVp 来制造已经成功的效果,否则菜单虽然换了位置,窗口本身却可能没有变化。

6. 栏目保留的运行记录

二合一模拟器切换宽窄窗口并保留选中栏目的演示

演示先在宽窗口打开“练习清单”,加入练习,然后收窄。菜单移到下方,当前栏目仍是练习清单,按钮显示“已加入练习”。接着切到学习档案,再展开窗口,最后回到练习清单。前一次加入的结果还在。

这里可以只盯着两个地方:菜单里哪一项高亮,以及正文里加入按钮的文字。前者验证当前选择有没有跟丢,后者验证栏目背后的状态有没有被重建。两个地方都对上,才说明位置变化没有顺便改变用户已经做过的事。

宽窄窗口的栏目选择与练习状态对照

6.1 点击与尺寸故障定位

若按钮点了没有切栏目,先检查点击回调是否写到selected,再看正文是否仍读取另一个临时值。若窗口变窄后练习记录消失,检查joined是否被放进条件分支的子组件,或者在尺寸回调里重置。若菜单看得到但最后一门课滚不到,优先核对Scroll的高度分配,不急着加一块固定高度的空白。

窗口工具还有一个单位边界。组件宽度按vp理解,窗口resize接收像素尺寸,所以工具用当前密度转换目标宽度,保留已有像素高度,只修改横向尺寸。这里的Math.round只对传给窗口的像素尺寸取整,不参与栏目判断。

export async function resizeWindow(context: common.UIAbilityContext, widthVp: number,  density: number): Promise<string> {  try {    const mainWindow = await window.getLastWindow(context);    const rect = mainWindow.getWindowProperties().windowRect;    await mainWindow.resize(Math.round(widthVp * density), rect.height);    return '窗口已调整';

验证时可以分三轮:第一轮只切栏目,确认内容与高亮一致;第二轮只加入练习,确认业务状态变化;第三轮才在这份状态上收窄和展开。若三件事同时操作,最后看到不对的画面,也很难知道是按钮事件、布局分支还是尺寸请求先出了问题。

这个顺序也适合修改断点以后复查。先保持同一个选中栏目,再慢慢经过边界,确认菜单只是换位置,没有额外触发业务操作。尺寸与内容各自有稳定来源,后续增加更多课程才有可靠的基础。

7. 总结

这个例子保留了一个很具体的交互约定:空间决定入口放在哪里,用户决定现在看哪一栏。将两者分开,菜单搬家之后,练习清单还是刚才的清单,加入记录也不会因为版面调整消失。

后面接入路由时,还应明确栏目切换与系统返回的关系。本例只在一个页面里切换内容,没有为每个栏目创建新的路由历史;复杂应用可以沿着现有导航体系保存选择与阅读位置,再把菜单布局接进去。关于可换行与线性容器的组合,可参阅华为线性布局说明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐