1. 前言

我希望这页能像桌上的便签板:放大时多摆几张卡,缩到旁边时少摆一点,挪到屏幕另一侧也不影响刚刚选中的灵感。窗口的位置和内容的状态,最好互不打扰。

于是做了一个“灵感工作台”。四张卡片有各自的标题与颜色,可以收藏;页面还显示当前宽度、列数与选中卡片。与单纯切换一列、两列的演示相比,这次把真实窗口移动也放进操作链,看看移动和缩放各自会带来什么变化。

2. 窗口移动与像素坐标

移动窗口只改变它在显示屏上的位置,不应该改变内容宽度。重排发生在窗口尺寸变化之后。若把窗口在屏幕中的坐标也当成布局依据,往左拖一下就切换列数,用户会很困惑。

本例的“移动窗口”调用当前应用窗口的 moveWindowTo,在两组位置之间切换;“缩窄”和“展开”调用 resize。布局只读取根容器的宽度,没有根据 left、top 或显示器中心位置猜测应该使用哪一种排版。

窗口接口接收像素坐标,页面排版使用逻辑宽度。位置信息和宽度信息即使都能写成一个数字,也不能直接混用。示例把它们放到不同方法里,既方便运行演示,也便于读代码时看清每次操作到底修改了什么。

本次移动与调整尺寸都只作用于当前应用窗口,不改显示屏分辨率,也不替用户移动其他应用。

窗口位置也受系统约束。这里的两组坐标用于当前模拟器演示,正式产品不宜擅自把用户窗口反复挪到固定位置。真正要记住窗口位置时,应保存用户选择,并在恢复前确认显示区域仍可用,避免把窗口放到用户够不着的位置。

3. 宽度驱动的卡片列数

根容器尺寸变化只更新 viewportWidth,列数由一个短方法计算。卡片标题、分类和收藏数据都已在内存中,不会每次拉动边缘就重新请求网络或读取一遍数据库。窗口交互频繁时,这种职责划分比堆一个延时器更容易保持响应清楚。

列宽同样扣掉左右内边距和相邻卡片间距,再按当前列数均分。否则窗口刚好进入四列时,最后一张卡片可能因为多出几个间隔被挤到下一行。开发时可以先用纯色边框观察边缘,确认宽度公式正确,再加颜色和圆角。

本例卡片保持固定高度,适合四条受控的短文案。若接入用户输入,应继续考虑长标题和多行内容,不能因为卡片颜色好看就默认内容永远这么短。窄窗可以滚动,而不是把每张卡都缩得很薄。

4. 卡片身份与收藏集合

四张卡片各自有 id。当前选择保存为 chosen,收藏保存为 id 数组。窗口缩窄后,卡片从四列变成一列,显示顺序仍然来自同一份 ideas;收藏数量不重新计算初始值,也不在布局回调里被清空。

ForEach 使用 id 生成稳定标识。这个小细节在后续加入排序、筛选时尤其有用:一张卡片换了显示位置,仍然是原来的那张卡片,而不是“第二个格子里的内容”。选择与收藏跟着数据走,重排只负责摆放。

收藏更新也保持简单。已经收藏的 id 再点一次就移除,没有收藏就加入;新的数组赋回状态,让页面同步更新。卡片的边框表达当前选择,按钮文案表达收藏状态,两种反馈分开,避免用户误以为“有边框就一定已经收藏”。

若要继续验证状态关系,可以在展开后取消原来的收藏,再收窄一次。计数与按钮文案应该一起更新,不会因为某张卡片换行而保留旧样式。视觉位置可以变化,但一次点击对应的仍然是同一个数据 id。

选择边框和收藏按钮也提供了两种不同反馈。边框告诉用户现在关注哪张卡,收藏文案告诉用户这张卡是否已经留下。窗口缩小后,两种反馈仍要各自准确。若把它们合成同一个布尔值,单次点击也许看起来方便,接上取消收藏或键盘选择时就会互相牵扯。

5. 灵感工作台代码

示例在 HarmonyOS 6.1.1、API 24 的二合一模拟器中运行。pages/Index.ets 包含卡片、位置切换、尺寸切换和状态处理。

import { LengthMetrics, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { resizeWindow } from '../common/WindowTools';

interface Idea { id: number; title: string; category: string; color: string; }

@Entry
@Component
struct Index {
  @State private viewportWidth: number = 0;
  @State private chosen: number = 1;
  @State private starred: number[] = [];
  @State private moved: boolean = false;
  @State private status: string = '拖动自由窗口,灵感卡片仍在原处';
  private ideas: Idea[] = [
    { id: 1, title: '把周末留给山林', category: '出行', color: '#DDD8EF' },
    { id: 2, title: '给旧照片写一句话', category: '记录', color: '#F4DFCB' },
    { id: 3, title: '做一次十分钟速写', category: '练习', color: '#DBE8D8' },
    { id: 4, title: '听一张完整的专辑', category: '音乐', color: '#F1DADF' }
  ];
  private cols(): number { return this.viewportWidth >= 880 ? 4 : this.viewportWidth >= 600 ? 2 : 1; }
  private async resize(w: number): Promise<void> {
    this.status = await resizeWindow(this.getUIContext().getHostContext() as common.UIAbilityContext, w, this.getUIContext().vp2px(1));
  }
  private async move(): Promise<void> {
    try {
      const w = await window.getLastWindow(this.getUIContext().getHostContext() as common.UIAbilityContext);
      this.moved = !this.moved;
      await w.moveWindowTo(this.moved ? 100 : 300, this.moved ? 100 : 180);
      this.status = '窗口位置已变化,当前选择与收藏继续保留';
    } catch (error) { this.status = '请使用自由窗口测试移动'; }
  }
  build() {
    Column({ space: 20 }) {
      Row() {
        Text('灵感工作台').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#463A5E');
        Blank();
        Text(`收藏 ${this.starred.length}`).id('saved').fontSize(16).fontColor('#79698E');
      }.width('100%');
      Flex({ wrap: FlexWrap.Wrap, space: { main: LengthMetrics.vp(8), cross: LengthMetrics.vp(8) } }) {
        Button('缩窄').id('narrow').backgroundColor('#79629B').onClick(() => this.resize(450));
        Button('展开').id('wide').backgroundColor('#79629B').onClick(() => this.resize(1120));
        Button('移动窗口').id('move').backgroundColor('#AA8C79').onClick(() => this.move());
      }.width('100%');
      Text(`${Math.round(this.viewportWidth)} vp · ${this.cols()} 列 · 当前卡片 ${this.chosen}`).id('viewport').fontSize(14).fontColor('#79698E').width('100%');
      Scroll() {
        Flex({ wrap: FlexWrap.Wrap, space: { main: LengthMetrics.vp(14), cross: LengthMetrics.vp(14) } }) {
          ForEach(this.ideas, (idea: Idea) => {
            Column({ space: 22 }) {
              Text(idea.category).fontSize(13).fontColor('#7C708A').width('100%');
              Text(idea.title).fontSize(24).fontWeight(FontWeight.Bold).width('100%').layoutWeight(1).fontColor('#4F445F');
              Button(this.starred.includes(idea.id) ? '已收藏' : '收藏灵感').id(`star${idea.id}`)
                .backgroundColor('#79629B').onClick(() => {
                  this.chosen = idea.id;
                  this.starred = this.starred.includes(idea.id) ? this.starred.filter((v: number) => v !== idea.id) : [...this.starred, idea.id];
                });
            }.width((this.viewportWidth - 48 - (this.cols() - 1) * 14) / this.cols()).height(270)
              .padding(22).backgroundColor(idea.color).borderRadius(18)
              .border({ width: this.chosen === idea.id ? 3 : 0, color: '#79629B' });
          }, (idea: Idea) => idea.id.toString());
        }.width('100%');
      }.layoutWeight(1).width('100%');
      Text(this.status).fontSize(12).fontColor('#79698E').width('100%');
    }.padding(24).width('100%').height('100%').backgroundColor('#F7F4FA')
      .onAreaChange((_old: Area, area: Area) => { this.viewportWidth = Number(area.width); });
  }
}

另加 common/WindowTools.ets,将用于演示的窗口宽度转换为像素后交给窗口接口。

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 '请先将窗口还原为自由窗口,再调整宽度';
  }
}

5.1 移动、收窄与收藏演示

录制先收藏第二张卡,收窄窗口,再移动窗口,随后展开并收藏另一张卡。窗口与桌面的相对位置可以在动图中直接比较,卡片按新的宽度重排,收藏数量和当前选择也随操作继续更新。

检查移动操作时,看窗口与桌面的相对位置;检查重排时,看内容宽度与列数;检查状态时,看收藏数量和当前卡片。把这三个观察点拆开,一旦出现异常,比较容易知道应该回到窗口接口、宽度计算还是业务状态去查。

把操作拆开更容易观察:先收藏一张卡,确认计数;再缩窄,确认列数;最后移动窗口,确认桌面上的相对位置。三个结果分别对应收藏、尺寸和位置。哪一步没有变化,就沿着那一步回去找,不必一次修改全部窗口代码。

6. 窗口操作范围

这份工作台没有实现跨窗口拖拽卡片,也没有创建多个应用主窗口。标题中的多窗口场景,指的是应用处于桌面自由窗口环境,可以被移动、调整大小并与其他窗口共处。若继续做真正的多实例协同,需要另外设计窗口之间的数据同步。

对于以后接入的异步数据,也可以沿用这个原则。窗口尺寸变化不会改变当前查询条件,数据返回时仍应按自身的请求与 id 更新;不要让一个布局回调触发新的列表初始化,再把尚未返回的旧请求结果覆盖上来。先把状态边界放清楚,后面的功能增加才不会反复打断用户操作。

7. 总结

这页并不需要占满桌面才能工作。用户可以先挑选灵感,再安排窗口的位置与大小;卡片按空间重新排好,收藏继续跟着数据走。真正多实例的数据协同可以另行设计,当前先把这一个自由窗口里的连续操作走稳。

下一步无论加入搜索、标签还是持久化,都可以继续沿用这份稳定 id 与独立状态。先把自由窗口里的基本操作走顺,再谈更复杂的多实例和跨窗口协作,会有一个更可靠的起点。

Logo

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

更多推荐