文件整理工作台正在处理文档归档、图片分类和报告生成时,用户希望只看一眼就知道还有几项待处理;需要排查某项失败原因时,则希望展开完整内容。这两个场景需要的信息密度完全不同。若把业务页里的任务列表按比例缩小塞进轻量窗口,往往会出现进度条拥挤、按钮截断和误触。窗口尺寸变化不仅是 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 是最终兜底,主要策略仍应是降低信息密度,而不是把所有文字压缩到极小字号。

DevEco Studio 中的窗体密度切换与任务列表

使用容器实际宽度选择排布

窗口尺寸、组件内容尺寸和设备物理尺寸不是同一个值。轻量窗体可能因为拖拽、输入法、系统栏或外层容器边距发生变化,因此应以承载任务视图的可用内容宽度为准。应用内 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 快照计算,而不是分别在各自生命周期内重新读取旧数据。状态由队列发出新快照,再由展示组件更新;承载层只控制显示或隐藏。

这套响应式结构将任务状态、信息密度和具体承载方式分离。即便后续对接新的闪控窗口形态,核心仍是“同一快照,多种呈现”:球态提供入口,紧凑窗态提供最低必要操作,宽窗态提供更完整的任务信息。下一节将专门处理球态与窗态相互切换时的订阅、瞬时状态和交互一致性。

参考资料

Logo

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

更多推荐