欢迎加入开源鸿蒙 PC 社区

欢迎在 PC 社区平台申请新建项目

框架与适配:ArkUI 引擎源码

本文示例与复现材料

给一组发布资源打包之前,编辑常常先点几项,再按名称或大小重排。重排以后既要保住之前的选择,按 Shift 时又得用眼前这份列表来解释“从这里到那里”。这个素材页专门把身份和位置拆开处理,让选择结果可以从侧栏复核。

整理发布素材时,常常要一起检查封面、演示视频和文档。如果清单按大小重排后,把旧的行号继续当成选中项,就可能选中另一份素材。这个 ArkUI 示例用固定编号保存选择,用当前显示顺序计算 Shift 范围,并在侧栏实时汇总已选文件名与大小。

选中项和选择起点分别保存

页面保存两个值,选中素材编号数组和最近一次单击建立的锚点。普通单击把选择替换成当前项。Ctrl 单击增选或取消该项,并把该项设为新锚点。即使 Ctrl 取消了一个项目,它仍可作为下一次 Shift 的起点。这使取消选中和设置范围起点可以分别处理。

Shift 单击时,在当前显示顺序里查找锚点和目标,取两端之间的连续编号。向上或向下点选都包含两端。连续按 Shift 扩大或收缩范围时保留锚点,选中的范围随终点变化。找不到锚点时,以当前点击项为起点。Ctrl 与 Shift 同时按下时,把范围合入已有选择并去掉重复项。选择函数收到的都是编号,因此输入事件和页面布局可以独立验证。

组合键与排序如何接到列表

每行在 onClick 里读取 ClickEvent 的修饰键状态。模拟器分别注入键盘和鼠标事件时,只读 ClickEvent 的初版把组合点击识别为普通单击。最终版在页面焦点范围用 onKeyEventDispatch 记录左右 Ctrl 和 Shift 的按下、松开,再与 ClickEvent 自带的状态合并。处理函数返回 false,保留原有事件分发。焦点离开或页面隐藏时清空记录,避免键在窗口外松开后继续影响点击。Ctrl + A 单独绑定到「全选」按钮。 ArkUI 点击事件接口

按大小排序时对素材数组的副本排序,选中编号和锚点照常保留。之后做 Shift 范围选择,用的是新顺序。比如按名称相隔较远的两项,在按大小排列时可能挨在一起。ForEach 使用素材编号作为键,选中行同时有勾选符号和高亮背景,并设置 accessibilitySelected。布局参考 Windows 文件资源管理器的详细信息视图,文件名和大小在同一行,操作按钮与选择摘要各有固定位置。 Windows 文件资源管理器

宽窗右侧展示选中项名称和总大小。低于 700 vp 时把摘要移到列表上方,只保留数量和总大小。文件列表独立滚动,选中状态和排序不随窗口变化而重置。这个示例只操作八条内置素材记录,单位为 KiB。

合计大小从原始记录按选中编号求和。高亮状态、摘要名称和总大小都从同一份选择派生。全选沿用当前排序,便于直接比对日志和界面。清除则同时清空已选编号与锚点,下一次 Shift 单击会从当前项重新开始。窄窗省略名称摘要后,仍可用列表内的勾选符号逐项核对。

模拟器实测

在 MateBook Pro 2in1 模拟器(HarmonyOS 6.1.1,API 24,x86_64)安装调试包。先点 02-layout.fig,按 Ctrl 再点 05-demo.mp4,得到 2 项、9680 KiB。再按 Ctrl 点一次视频,选中项回到 1 项,锚点仍是视频。接着按 Shift 点 07-notes.md,选中名称顺序中的 05、06、07,共 9872 KiB。

切换到大小降序,这三项仍保持选中。再按 Shift 点 02-layout.fig,选中的顺序变为 05、08、06、02,共 17400 KiB。窗口缩到约 546 vp 后,摘要移到列表上方,四项选择仍保留。清除选择后按 Ctrl + A,选中全部 8 项,共 18836 KiB。以下三张图依次展示 Ctrl 多选、排序后的范围选择和窄窗全选。

01-ctrl.jpeg

02-shift-sorted.jpeg

03-narrow-all.jpeg

复现配置与完整代码

在 DevEco Studio 创建 ArkTS Empty Ability 工程。目标和最低兼容 API 设为 24,设备类型设为 2in1,沿用模板的 Hvigor 文件、图标和资源。将应用名和入口标签改为「素材清单」,页面路由保留 pages/Index。

应用声明与入口

AppScope/app.json5

{
  "app": {
    "bundleName": "com.aasd23.ugcassetselection",
    "vendor": "UGC Lab",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "icon": "$media:layered_image",
    "label": "$string:app_name"
  }
}

entry/src/main/module.json5

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "deviceTypes": ["2in1"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "icon": "$media:layered_image",
        "label": "$string:entry_label",
        "startWindowIcon": "$media:foreground",
        "startWindowBackground": "$color:startup",
        "exported": true,
        "skills": [{ "entities": ["entity.system.home"], "actions": ["action.system.home"] }]
      }
    ]
  }
}

entry/src/main/ets/entryability/EntryAbility.ets

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(stage: window.WindowStage): void {
    stage.loadContent('pages/Index').then(() => {
      hilog.info(0x0418, 'AssetSelection', 'page_ready');
    }).catch((error: BusinessError) => {
      hilog.error(0x0418, 'AssetSelection', 'page_error code=%{public}d', error.code);
    });
  }
}

选择规则与示例数据

entry/src/main/ets/AssetSelection.ets

export interface Asset {
  id: string;
  name: string;
  type: string;
  kib: number;
}

export const ASSETS: Asset[] = [
  { id: 'a1', name: '01-cover.png', type: 'PNG', kib: 920 },
  { id: 'a2', name: '02-layout.fig', type: 'FIG', kib: 1440 },
  { id: 'a3', name: '03-preview.jpg', type: 'JPG', kib: 480 },
  { id: 'a4', name: '04-captions.txt', type: 'TXT', kib: 24 },
  { id: 'a5', name: '05-demo.mp4', type: 'MP4', kib: 8240 },
  { id: 'a6', name: '06-release.pdf', type: 'PDF', kib: 1620 },
  { id: 'a7', name: '07-notes.md', type: 'MD', kib: 12 },
  { id: 'a8', name: '08-assets.zip', type: 'ZIP', kib: 6100 }
];

export interface Selection {
  ids: string[];
  anchor: string;
}

export function selectRow(current: Selection, order: string[], id: string, ctrl: boolean, shift: boolean): Selection {
  const position = order.indexOf(id);
  if (position < 0) {
    return current;
  }

  if (shift) {
    const anchorIndex = order.indexOf(current.anchor);
    const start = anchorIndex < 0 ? position : anchorIndex;
    const range = order.slice(Math.min(start, position), Math.max(start, position) + 1);
    if (!ctrl) {
      return { ids: range, anchor: order[start] };
    }
    const added = range.filter((item: string) => !current.ids.includes(item));
    return { ids: current.ids.concat(added), anchor: order[start] };
  }

  if (ctrl) {
    const ids = current.ids.includes(id) ?
      current.ids.filter((item: string) => item !== id) : current.ids.concat(id);
    return { ids, anchor: id };
  }
  return { ids: [id], anchor: id };
}

列表、修饰键与窄窗摘要

entry/src/main/ets/pages/Index.ets

import { hilog } from '@kit.PerformanceAnalysisKit';
import { KeyCode } from '@kit.InputKit';
import { ASSETS, Asset, Selection, selectRow } from '../AssetSelection';

const INK = '#263547';
const MUTED = '#596C80';
const BLUE = '#245C9A';
const BORDER = '#DCE5EF';
const WHITE = '#FFFFFF';

@Entry
@Component
struct Index {
  @State private selection: Selection = { ids: [], anchor: '' };
  @State private bySize: boolean = false;
  @State private compact: boolean = false;
  private modifiers: Set<number> = new Set<number>();

  onPageHide(): void {
    this.modifiers.clear();
  }

  private trackKey(event: KeyEvent): boolean {
    const code = event.keyCode;
    if (code === KeyCode.KEYCODE_CTRL_LEFT || code === KeyCode.KEYCODE_CTRL_RIGHT ||
        code === KeyCode.KEYCODE_SHIFT_LEFT || code === KeyCode.KEYCODE_SHIFT_RIGHT) {
      if (event.type === KeyType.Down) {
        this.modifiers.add(code);
      } else if (event.type === KeyType.Up) {
        this.modifiers.delete(code);
      }
    }
    return false;
  }

  private order(): Asset[] {
    if (!this.bySize) {
      return ASSETS;
    }
    return ASSETS.slice().sort((left: Asset, right: Asset) => right.kib - left.kib || left.id.localeCompare(right.id));
  }

  private report(action: string): void {
    hilog.info(0x0418, 'AssetSelection', 'action=%{public}s ids=%{public}s anchor=%{public}s total_kib=%{public}d',
      action, this.selection.ids.join(','), this.selection.anchor, this.totalKiB());
  }

  private activate(asset: Asset, event: ClickEvent): void {
    const ctrl = event.getModifierKeyState?.(['Ctrl']) || this.modifiers.has(KeyCode.KEYCODE_CTRL_LEFT) ||
      this.modifiers.has(KeyCode.KEYCODE_CTRL_RIGHT);
    const shift = event.getModifierKeyState?.(['Shift']) || this.modifiers.has(KeyCode.KEYCODE_SHIFT_LEFT) ||
      this.modifiers.has(KeyCode.KEYCODE_SHIFT_RIGHT);
    this.selection = selectRow(this.selection, this.order().map((row: Asset) => row.id), asset.id, ctrl, shift);
    this.report(ctrl ? (shift ? 'ctrl-shift' : 'ctrl') : (shift ? 'shift' : 'single'));
  }

  private totalKiB(): number {
    return ASSETS.filter((item: Asset) => this.selection.ids.includes(item.id))
      .reduce((total: number, item: Asset) => total + item.kib, 0);
  }

  private selectAll(): void {
    const ids = this.order().map((item: Asset) => item.id);
    this.selection = { ids, anchor: this.selection.anchor || ids[0] };
    this.report('all');
  }

  @Builder
  private Actions() {
    Row({ space: 7 }) {
      Button('全选', { type: ButtonType.Normal }).id('asset-all')
        .height(34).fontSize(13).fontColor(BLUE).backgroundColor('#E6F0FC').borderRadius(7)
        .onClick(() => this.selectAll())
        .keyboardShortcut('A', [ModifierKey.CTRL], () => this.selectAll())
      Button('清除', { type: ButtonType.Normal }).id('asset-clear')
        .height(34).fontSize(13).fontColor(MUTED).backgroundColor('#EDF2F7').borderRadius(7)
        .onClick(() => {
          this.selection = { ids: [], anchor: '' };
          this.report('clear');
        })
    }
  }

  @Builder
  private FileRow(item: Asset) {
    Row({ space: 10 }) {
      Text(this.selection.ids.includes(item.id) ? '✓' : '·').id('asset-mark-' + item.id)
        .width(22).height(22).textAlign(TextAlign.Center).fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(this.selection.ids.includes(item.id) ? WHITE : MUTED)
        .backgroundColor(this.selection.ids.includes(item.id) ? BLUE : '#EEF3F8').borderRadius(6)
      Text(item.type).width(44).fontSize(11).fontWeight(FontWeight.Bold).textAlign(TextAlign.Center)
        .fontColor('#3A6488').backgroundColor('#E8F0F7').padding({ top: 4, bottom: 4 }).borderRadius(6)
      Text(item.name).layoutWeight(1).fontSize(15).fontFamily('monospace').fontColor(INK)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(item.kib + ' KiB').width(this.compact ? 85 : 108).textAlign(TextAlign.End).fontSize(13)
        .fontFamily('monospace').fontColor(MUTED)
    }.id('asset-row-' + item.id).width('100%').height(46).padding({ left: 10, right: 12 })
      .backgroundColor(this.selection.ids.includes(item.id) ? '#DEEDFF' : WHITE)
      .border({ width: { bottom: 1 }, color: '#EDF2F7' }).focusable(true)
      .accessibilityText(item.name + ',' + item.kib + ' KiB')
      .accessibilitySelected(this.selection.ids.includes(item.id))
      .onClick((event: ClickEvent) => this.activate(item, event))
  }

  @Builder
  private Listing() {
    Column({ space: 0 }) {
      Row({ space: 8 }) {
        Text('文件名').fontSize(14).fontWeight(FontWeight.Bold).fontColor(INK).layoutWeight(1)
        Button(this.bySize ? '大小降序 ↓' : '文件名升序 ↑', { type: ButtonType.Normal }).id('asset-sort')
          .height(33).fontSize(12).fontColor(BLUE).backgroundColor('#E5EEF8').borderRadius(7)
          .onClick(() => {
            this.bySize = !this.bySize;
            this.report(this.bySize ? 'sort-size' : 'sort-name');
          })
      }.width('100%').padding(10).backgroundColor('#F3F7FB')
      List() {
        ForEach(this.order(), (item: Asset) => {
          ListItem() { this.FileRow(item) }
        }, (item: Asset) => item.id)
      }.layoutWeight(1).width('100%').scrollBar(BarState.Auto)
    }.width('100%').height('100%').backgroundColor(WHITE).borderRadius(10)
      .border({ width: 1, color: BORDER }).clip(true)
  }

  @Builder
  private Summary() {
    Column({ space: 9 }) {
      Text('已选 ' + this.selection.ids.length + ' 项').id('asset-count')
        .fontSize(this.compact ? 15 : 20).fontWeight(FontWeight.Bold).fontColor(INK)
      Text('合计 ' + this.totalKiB() + ' KiB').id('asset-total')
        .fontSize(13).fontColor(MUTED).fontFamily('monospace')
      if (!this.compact) {
        Divider().color(BORDER)
        if (!this.selection.ids.length) {
          Text('点击列表中的文件以选择。').fontSize(13).fontColor(MUTED)
        } else {
          ForEach(this.order().filter((item: Asset) => this.selection.ids.includes(item.id)), (item: Asset) => {
            Text(item.name).fontSize(13).fontFamily('monospace').fontColor(INK)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }, (item: Asset) => item.id)
        }
      }
    }.width('100%').padding(14).alignItems(HorizontalAlign.Start).backgroundColor('#F4F8FD')
      .border({ width: 1, color: BORDER }).borderRadius(10)
  }

  build() {
    Column({ space: 12 }) {
      Row({ space: 8 }) {
        Column({ space: 3 }) {
          Text('素材清单').fontSize(27).fontWeight(FontWeight.Bold).fontColor(INK)
          Text('Ctrl 多选 · Shift 连选').fontSize(13).fontColor(MUTED)
        }.layoutWeight(1).alignItems(HorizontalAlign.Start)
        this.Actions()
      }.width('100%')
      if (this.compact) {
        this.Summary()
      }
      Row({ space: 14 }) {
        Column() { this.Listing() }.layoutWeight(1).height('100%')
        if (!this.compact) {
          Column() { this.Summary() }.width(228)
        }
      }.width('100%').layoutWeight(1).alignItems(VerticalAlign.Top)
    }.width('100%').height('100%').padding(18).backgroundColor('#ECF2F8')
      .focusable(true).defaultFocus(true)
      .onBlur(() => this.modifiers.clear())
      .onKeyEventDispatch((event: KeyEvent) => this.trackKey(event))
      .onAreaChange((_previous: Area, area: Area) => {
        const width = Number(area.width);
        const compact = width < 700;
        if (width > 0 && compact !== this.compact) {
          this.compact = compact;
          hilog.info(0x0418, 'AssetSelection', 'width_vp=%{public}d compact=%{public}d', Math.round(width), compact ? 1 : 0);
        }
      })
  }
}

安装并检查运行日志

在 DevEco Studio 构建调试 HAP,然后从工程根目录执行以下命令。先核对目标为 API 24 的 2in1 模拟器,再安装并检查 AssetSelection 日志中的选中编号、锚点和合计大小。

$devEco = Read-Host '输入 DevEco Studio 安装目录'
$hdc = Join-Path $devEco 'sdk/default/openharmony/toolchains/hdc.exe'
& $hdc list targets -v
$target = Read-Host '输入 2in1 模拟器的 HDC 地址'
& $hdc -t $target shell 'param get const.product.devicetype; param get const.ohos.apiversion'
& $hdc -t $target install -r 'entry/build/default/outputs/default/entry-default-unsigned.hap'
& $hdc -t $target shell 'aa start -b com.aasd23.ugcassetselection -a EntryAbility'
& $hdc -t $target shell 'hilog -x -T AssetSelection'
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐