鸿蒙 PC ArkUI 实战:素材清单的 Ctrl 多选与 Shift 范围选择
框架与适配: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 多选、排序后的范围选择和窄窗全选。



复现配置与完整代码
在 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'
更多推荐




所有评论(0)