拖走窗口,灵感还在:HarmonyOS 6 多窗口重排
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 与独立状态。先把自由窗口里的基本操作走顺,再谈更复杂的多实例和跨窗口协作,会有一个更可靠的起点。
更多推荐


所有评论(0)