HarmonyOS 7闪控窗与闪控球连载02(共08篇):窄窗与宽窗的响应式任务布局【共创稿事节】
文件整理工作台正在处理文档归档、图片分类和报告生成时,用户希望只看一眼就知道还有几项待处理;需要排查某项失败原因时,则希望展开完整内容。这两个场景需要的信息密度完全不同。若把业务页里的任务列表按比例缩小塞进轻量窗口,往往会出现进度条拥挤、按钮截断和误触。窗口尺寸变化不仅是 width 数值变化,更是任务信息优先级的重新排列。

前一节建立了 TaskRecord、TaskQueue、TaskPhase 和 SurfaceMode,其中队列是任务事实的唯一来源,球态与窗态只决定如何展示。本节延续这套模型,不更改状态机的转换规则,重点给不同窗口宽度设计明确的内容层次,并让窗口收缩时任务 ID、处理阶段、进度和失败信息保持原值。HarmonyOS SDK 26 已将闪控窗 ArkUI 悬浮窗与闪控球列为新的交互形态,具体窗口承载与系统能力的使用应匹配所选开发套件。下文以应用内轻量视图建立可复用布局核心,便于衔接不同承载层。
先定义信息层级,而不是先写宽度判断
球态仅承担提醒和入口:活动任务数、最值得关注的异常、点击后的展开动作。窗态才包含任务列表、进度与必要操作;完整工作台保留历史记录、筛选与批量功能。三种界面引用同一个任务快照,不应把状态拆成三个分别更新的数组。
| 容器形态 | 必须出现 | 根据空间增加 | 避免出现 |
|---|---|---|---|
| 球态 | 待处理数量或失败标记 | 简短动态反馈 | 多行任务标题和多个按钮 |
| 紧凑窗态 | 最重要任务、阶段、主操作 | 进度百分比、失败摘要 | 长路径、堆叠操作组 |
| 展开窗态 | 多任务卡片及明确信息 | 时间、类型、次级操作 | 过长的连续滚动内容 |
“最重要任务”可以依据固定规则选择:失败需用户处理的任务优先,其次是运行中任务,再到待启动任务。规则应写在纯数据层,而不是把排序条件夹在 ForEach 内部。状态排序和视觉宽度彼此独立,这样扩大窗口不会导致卡片顺序突然变化。
用类型明确布局配置
把宽度处理整理到 entry/src/main/ets/model/TaskLayout.ets。容器宽度使用 vp 作为业务配置的尺寸单位,阈值为示例项目内的设计参数,实际应结合字体缩放、窗口安全区和内容量调整。宽度不足时选择紧凑布局,宽度增加后才展示额外文本;高度独立受限,不能因为横向空间变大就无限拉长窗口。
import { TaskPhase, TaskRecord } from './TaskQueue';
export enum TaskDensity {
COMPACT = 'compact',
EXPANDED = 'expanded'
}
export class TaskLayoutInfo {
density: TaskDensity;
widthVp: number;
showSubtitle: boolean;
maxVisible: number;
constructor(density: TaskDensity, widthVp: number,
showSubtitle: boolean, maxVisible: number) {
this.density = density;
this.widthVp = widthVp;
this.showSubtitle = showSubtitle;
this.maxVisible = maxVisible;
}
}
export function resolveTaskLayout(availableVp: number): TaskLayoutInfo {
const width: number = Math.max(220, Math.min(540, availableVp));
if (width < 360) {
return new TaskLayoutInfo(TaskDensity.COMPACT, width, false, 2);
}
return new TaskLayoutInfo(TaskDensity.EXPANDED, width, true, 4);
}
export function priorityOf(task: TaskRecord): number {
if (task.phase === TaskPhase.FAILED) { return 0; }
if (task.phase === TaskPhase.RUNNING) { return 1; }
if (task.phase === TaskPhase.PENDING) { return 2; }
return 3;
}
export function visibleTasks(tasks: TaskRecord[], limit: number): TaskRecord[] {
return tasks.slice().sort((a: TaskRecord, b: TaskRecord) =>
priorityOf(a) - priorityOf(b)).slice(0, limit);
}
slice() 保留原始队列数组,避免在 @State 持有的数据上直接排序。排序后再裁剪可减少轻量窗口的节点数量。若两个任务优先级相同,排序策略还应引入创建时间或任务 ID 作为稳定次序,避免数组更新时两个卡片互相交换。Math.max() 是最低绘制宽度保护,绝不是让窗口越界:真正的可用宽度必须扣除容器外边距、系统安全区与内边距。
任务卡片应当适应窄宽两种密度
界面层只接收任务记录和布局信息,不修改队列状态。对紧凑密度,任务卡片由标题、阶段和进度组成;展开密度下允许增加失败摘要与操作按钮。把每条任务卡片写成独立组件,避免一整个窗体同时维护所有行内部的临时交互状态。
import { TaskPhase, TaskRecord } from '../model/TaskQueue';
import { TaskDensity } from '../model/TaskLayout';
@Component
export struct TaskMiniCard {
@Prop task: TaskRecord;
@Prop density: TaskDensity;
onTaskClick: (id: string) => void = (id: string): void => {};
private phaseText(): string {
if (this.task.phase === TaskPhase.FAILED) { return '需处理'; }
if (this.task.phase === TaskPhase.SUCCEEDED) { return '已完成'; }
if (this.task.phase === TaskPhase.PENDING) { return '排队中'; }
return `${this.task.progress}%`;
}
build() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(this.task.title).fontSize(15).fontWeight(FontWeight.Medium)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
Text(this.phaseText()).fontSize(12).fontColor('#4975B0')
}.width('100%')
Progress({ value: this.task.progress, total: 100,
type: ProgressType.Linear }).width('100%')
if (this.density === TaskDensity.EXPANDED &&
this.task.phase === TaskPhase.FAILED) {
Text(this.task.errorText).fontSize(12).fontColor('#B34B4B')
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
if (this.density === TaskDensity.EXPANDED) {
Button('查看任务').fontSize(12).height(32)
.onClick(() => this.onTaskClick(this.task.id))
}
}
.padding(12).width('100%')
.backgroundColor('#F4F8FE').borderRadius(12)
}
}
这里把操作回调作为属性传入,组件本身并不知道任务执行器在哪里。以后需要给失败项增加“重试”时,应让回调传递任务 ID 和操作枚举,由外层使用上一节的 TaskQueue.transition() 决定是否合法。窄窗里的 TextOverflow.Ellipsis 是最终兜底,主要策略仍应是降低信息密度,而不是把所有文字压缩到极小字号。

使用容器实际宽度选择排布
窗口尺寸、组件内容尺寸和设备物理尺寸不是同一个值。轻量窗体可能因为拖拽、输入法、系统栏或外层容器边距发生变化,因此应以承载任务视图的可用内容宽度为准。应用内 Stack 可以把任务窗体置于页面右下方;日后换成独立承载层,布局参数仍是 availableVp,无需改写卡片逻辑。
import { TaskLayoutInfo, TaskDensity, resolveTaskLayout,
visibleTasks } from '../model/TaskLayout';
import { TaskMiniCard } from '../components/TaskMiniCard';
@State availableVp: number = 320;
@State layoutInfo: TaskLayoutInfo = resolveTaskLayout(320);
private resizeContent(widthVp: number): void {
const next: TaskLayoutInfo = resolveTaskLayout(widthVp);
if (next.density !== this.layoutInfo.density ||
next.widthVp !== this.layoutInfo.widthVp) {
this.layoutInfo = next;
}
}
@Builder
private responsiveWindow() {
Column({ space: 10 }) {
Row() {
Text('轻任务').fontSize(18).fontWeight(FontWeight.Bold)
Blank()
Button('收起').fontSize(12).height(30)
.onClick(() => { this.surface = SurfaceMode.BALL; })
}.width('100%')
List({ space: 8 }) {
ForEach(visibleTasks(this.tasks, this.layoutInfo.maxVisible),
(task: TaskRecord) => {
ListItem() {
TaskMiniCard({
task: task,
density: this.layoutInfo.density,
onTaskClick: (id: string): void => { this.openTask(id); }
})
}
}, (task: TaskRecord) => task.id)
}.layoutWeight(1)
}
.padding(14).width('100%').height('100%')
.backgroundColor(Color.White).borderRadius(20)
}
上面的构建器应放进上一节 Index,沿用已有的 tasks 和 surface。openTask(id) 是业务页导航适配点,例如可先检查任务 ID 是否还在队列中,再进入完整任务页。外层容器可在尺寸变化时调用 resizeContent();onAreaChange 的回调参数表示组件实际区域,而不是整个屏幕宽度。宽度来源要统一成 vp:若获取到的尺寸为 px,先使用 px2vp() 转换;若接口已返回 vp,则直接使用,避免高像素密度设备上的重复换算。这个细节会直接影响断点选择。
球态应继续走同一份摘要
球态与紧凑窗态不是同一种布局。球态没有空间承载多行进度卡片,通常只显示活动数和错误提示,点击后切换到窗态;任务执行器不受该切换影响。摘要应来自同一数组,可以复用下面的纯函数,避免分别维护 activeCount 和 hasError 状态。
export class TaskSummary {
activeCount: number;
failedCount: number;
constructor(activeCount: number, failedCount: number) {
this.activeCount = activeCount;
this.failedCount = failedCount;
}
}
export function summarizeTasks(items: TaskRecord[]): TaskSummary {
const active: number = items.filter((item: TaskRecord) =>
item.phase === TaskPhase.RUNNING ||
item.phase === TaskPhase.PENDING).length;
const failed: number = items.filter((item: TaskRecord) =>
item.phase === TaskPhase.FAILED).length;
return new TaskSummary(active, failed);
}
当活动数为零但存在失败项时,球态不应直接隐藏,否则用户失去处理失败的入口。反过来,所有任务都完成后可以展示短暂的完成反馈,再由明确的业务规则收起。摘要函数属于数据层,跨页面或多窗口重新订阅任务快照时仍可以直接复用。

四类窄窗问题的排查顺序
标题遮挡阶段标签。 检查 Row 内标题是否设置 layoutWeight(1) 与 maxLines(1),阶段标签是否保持稳定宽度;如果仍然拥挤,进一步减少标签文本,而不是继续缩小字号。大字体辅助设置下,注意让重要操作具有足够的点击区域。
展开后内容超出可视范围。 窗体高度应有上限;将任务列表交给 List 滚动,顶部的摘要与收起按钮保持固定。长错误原因只展示两行,完整说明进入任务详情页读取。
拖拽窗口后卡片反复闪动。 检查尺寸变化时是否每帧都更换任务数组引用,或是否断点附近反复切换密度。可以对断点增加少量回差,减少临界宽度的布局振荡;回差只应作用于布局状态,不应改变任务状态机。
球态与窗态显示不同数量。 查看两处是否从同一 tasks 快照计算,而不是分别在各自生命周期内重新读取旧数据。状态由队列发出新快照,再由展示组件更新;承载层只控制显示或隐藏。
这套响应式结构将任务状态、信息密度和具体承载方式分离。即便后续对接新的闪控窗口形态,核心仍是“同一快照,多种呈现”:球态提供入口,紧凑窗态提供最低必要操作,宽窗态提供更完整的任务信息。下一节将专门处理球态与窗态相互切换时的订阅、瞬时状态和交互一致性。
参考资料
更多推荐

所有评论(0)