图标行文件浏览器:ArkUI 让鸿蒙文件库像电脑资源管理器


实例:文件资料库(FileLib)|风格:图标行文件浏览器
一、设计理念:文件库要「扫一眼认文件」
文件资料库的 UI 目标是电脑资源管理器/手机文件管理器——每行一个文件:图标(类型)+ 名称 + 元信息(类型标签/大小/日期/标签)。实例 16 的风格「图标行文件浏览器」:搜索框 + 类型筛选条 + 图标行列表。
页面信息架构:
- 标题栏:文件库 + 文件总数/总大小 + 刷新;
- 搜索框:按名称/标签模糊搜索(LIKE);
- 类型筛选条:全部/文档/图片/视频/音频/压缩包/其他(动态——来自统计结果);
- 文件列表:图标行(类型 emoji 图标 + 文件名 + 类型标签/大小/日期 + 标签 + 删除)。
二、页面骨架与核心状态
@State files: FileRecord[] = [];
@State typeFilter: string = '全部';
@State keyword: string = '';
@State totalCount: number = 0;
@State totalSize: number = 0;
@State typeStats: TypeStat[] = [];
private readonly icons: Record<string, string> = {
'文档': '📄', '图片': '🖼️', '视频': '🎬', '音频': '🎵', '压缩包': '🗜️', '其他': '📁',
};
icons 类型映射:Record<string, string>——类型 → emoji 图标的映射表(ArkTS 对象字面量需声明类型)。列表渲染时 this.icons[f.fileType] ?? '📁':未知类型兜底「📁」。
build 骨架:
Column() {
// 标题栏
// 搜索框
// 类型筛选条(动态)
// 文件列表(List 图标行)
}
三、搜索框:实时模糊搜索
TextInput({ placeholder: '🔍 按名称 / 标签搜索…', text: this.keyword })
.width('94%').height(40).margin({ top: 10 })
.backgroundColor(Color.White).borderRadius(20)
.onChange((v: string) => this.onSearch(v))
onSearch(v: string): void {
this.keyword = v;
this.applyFilter();
}
async applyFilter(): Promise<void> {
if (this.keyword.trim()) {
this.files = await FileLibDao.search(this.context, this.keyword.trim());
} else {
this.files = await FileLibDao.queryByType(this.context, this.typeFilter);
}
}
搜索优先:关键词非空 → search(名称/标签 LIKE);为空 → 类型筛选。「搜索与筛选互斥」:搜索时不叠加类型过滤(简化交互);placeholder 说明搜索范围「按名称 / 标签搜索…」——用户知道搜什么。圆角 20 胶囊搜索框——常见搜索框样式。
四、类型筛选条:动态生成
类型筛选条的特殊之处——选项来自数据库统计(不是写死):
Scroll() {
Row({ space: 8 }) {
ForEach(['全部'].concat(this.typeStats.map((s: TypeStat) => s.fileType)), (t: string) => {
Text(`${t}`)
.fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.typeFilter === t ? '#111827' : '#FFFFFF')
.fontColor(this.typeFilter === t ? Color.White : '#4B5563')
.onClick(() => this.switchType(t))
}, (t: string) => t)
}.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
动态选项:['全部'].concat(typeStats.map(s => s.fileType))——「全部」+ 数据库里实际存在的类型(GROUP BY 结果)。为什么动态? 数据库只有 6 类时显示 7 个 tab;某类型文件全删了,tab 自动消失——筛选条与数据同步,不显示空类型。
switchType 清空搜索:
async switchType(t: string): Promise<void> {
this.typeFilter = t;
this.keyword = '';
await this.applyFilter();
}
切类型清空关键词——搜索与筛选互斥的一致性维护(从搜索状态切到类型筛选,清掉搜索词避免残留过滤)。
五、文件列表:图标行
每行 = 类型图标 + 名称 + 元信息 + 删除:
List({ space: 8 }) {
ForEach(this.files, (f: FileRecord) => {
ListItem() {
Row({ space: 12 }) {
Text(this.icons[f.fileType] ?? '📁').fontSize(28)
.width(48).height(48).borderRadius(10).backgroundColor('#F3F4F6')
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(f.name).fontSize(15).fontWeight(FontWeight.Medium).maxLines(1)
Row({ space: 8 }) {
Text(f.fileType).fontSize(11).padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor('#DBEAFE').fontColor('#3B82F6')
Text(this.fmtSize(f.size)).fontSize(11).fontColor('#9CA3AF')
Text(this.fmtTime(f.createdTime)).fontSize(11).fontColor('#9CA3AF')
}
if (f.tags) {
Text(`🏷 ${f.tags}`).fontSize(11).fontColor('#9CA3AF')
}
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('✕').fontSize(14).fontColor('#CCCCCC').onClick(() => this.deleteFile(f))
}
.width('100%').padding(12).backgroundColor(Color.White).borderRadius(10)
}
}, (f: FileRecord) => `${f.id}-${f.name}`)
}
.width('94%').layoutWeight(1).margin({ top: 10 })
.scrollBar(BarState.Off)
图标行五要素:
- 类型图标:28fp emoji 放 48×48 浅灰圆角块(
#F3F4F6)——像文件图标位; - 文件名:15fp 中等字重,单行截断;
- 类型标签:蓝色小胶囊(
#DBEAFE底#3B82F6字)——类型可扫读; - 大小 + 日期:灰字(fmtSize 换算 + fmtTime 月日)——文件元信息;
- 标签行:
🏷 需求,产品(有 tags 才显示)——if 条件渲染。
fmtSize 大小换算(字节 → 人类可读):
private fmtSize(size: number): string {
if (size >= 1073741824) {
return (size / 1073741824).toFixed(2) + ' GB';
}
if (size >= 1048576) {
return (size / 1048576).toFixed(1) + ' MB';
}
if (size >= 1024) {
return (size / 1024).toFixed(0) + ' KB';
}
return size + ' B';
}
分级换算:GB(2 位小数)/ MB(1 位)/ KB(0 位)/ B——按数量级选单位与精度。800MB → 「800.0 MB」、350MB → 「350.0 MB」、120MB → 「120.0 MB」;2.3MB → 「2.3 MB」。
六、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 极浅灰蓝 |
文件管理清爽 |
| 搜索框 | 白色胶囊圆角 20 | 搜索入口 |
| 筛选条 | 动态选项 + 黑底选中 | 与数据同步 |
| 类型图标 | 28fp emoji + 浅灰底 | 文件图标位 |
| 类型标签 | 蓝底胶囊 | 类型可扫读 |
| 元信息 | 灰字大小/日期 | 次级信息 |
| 删除 | 行尾灰色 ✕ | 轻量删除 |
七、文章小结
本篇文章完成了实例 16 的 UI 层:搜索框(实时 LIKE 搜索)+ 动态类型筛选条(选项来自 GROUP BY 统计,与数据同步)+ 图标行文件列表(类型 emoji 图标 + 名称 + 类型胶囊 + 大小/日期 + 标签 + 删除)。设计核心是「资源管理器感」——图标行五要素扫一眼认文件;技术亮点是动态筛选条(typeStats 驱动选项,无空类型残留)与 fmtSize 分级换算(字节 → GB/MB/KB 按数量级选单位)。
下一篇(16-3)深入数据层,讲解 LIKE 通配符搜索、GROUP BY 类型统计与批量导入去重。
八、搜索框实时交互:击键即查库的节奏
「实时」的含义:onChange 在用户每敲一个字符时触发——无需回车、无需按钮。击键 → onSearch(v) → applyFilter() → 数据库查询 → this.files 更新 → List 差量重渲染,整条链路毫秒级完成:
| 环节 | 触发点 | 职责 |
|---|---|---|
| onChange | 每敲一个字符 | 更新 keyword 并触发查询 |
| applyFilter | onSearch 内 | 关键词非空走 search;为空回落到类型筛选 |
| ForEach | files 变化 | key 相同条目复用,差异条目重渲染 |
小数据量直接查库:18 个文件的本地 SQLite 查询是微秒~毫秒级,击键即查无压力,不必防抖。数据量上千条后可加 300ms 防抖——只查最后一次击键后的结果:
private timer: number = -1;
onSearch(v: string): void {
this.keyword = v;
if (this.timer !== -1) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.applyFilter();
}, 300);
}
防抖原理:连续击键只重置定时器不查询,停顿 300ms 后才真正查一次——把「每键一查」降为「停顿一查」。注意 setTimeout 在 ArkTS 中返回 number,定时器句柄必须显式声明类型。清空搜索词:删除最后一个字符时 keyword 变空串,applyFilter 自动回落到类型筛选——搜索与筛选状态无缝切换,无需额外分支。
九、typeStats 驱动的筛选条:统计结果长成 UI
筛选条不是写死的 7 个 tab,而是由数据库 GROUP BY 统计结果生成。数据流:
interface TypeStat {
fileType: string;
count: number;
totalSize: number;
}
onPageShow(): void {
this.loadStats();
this.applyFilter();
}
async loadStats(): Promise<void> {
this.typeStats = await FileLibDao.queryTypeStats(this.context);
}
渲染侧 ['全部'].concat(this.typeStats.map((s: TypeStat) => s.fileType))——「全部」永远第一,其余 tab 与数据库实际存在的类型一一对应。动态 vs 静态:
| 场景 | 静态写死 | typeStats 动态 |
|---|---|---|
| 某类型文件全删 | tab 残留,点开为空 | tab 自动消失 |
| 新增类型 | 需改代码 | 自动出现 |
| tab 数量 | 固定 | 随数据变化 |
typeStats 的刷新时机:onPageShow(每次进入页面)+ 删除文件后重查——保证「tab 与数据库一致」,绝不出现空类型 tab。
十、图标行五要素逐块拆解
图标行的「资源管理器感」来自五要素各司其职:
1. 类型 emoji(图标位):this.icons[f.fileType] ?? '📁'——映射表命中返回对应 emoji,未知类型兜底「📁」,数据库出现新类型 UI 也不空白。48×48 浅灰圆角块 + 28fp emoji 居中,模拟文件图标位。
2. 名称(主信息):15fp Medium 字重 + maxLines(1) 单行截断——超长省略号收尾,行高稳定,列表不因文件名长短参差。
3. 类型胶囊(类型可扫读):#DBEAFE 底 + #3B82F6 蓝字小胶囊——与 emoji 双保险,扫一眼既能从图标也能从文字认出类型。
4. 大小 + 日期(元信息):fmtSize(f.size) 换算可读大小、fmtTime(f.createdTime) 输出月日时间。两个 11fp 灰字(#9CA3AF)弱化视觉——元信息是辅助,不抢主信息权重。
5. 标签行(扩展信息):if (f.tags) Text('🏷 ' + f.tags)——有标签才渲染,无标签的行不高出一行,列表行高统一。
| 要素 | 代码要点 | 视觉 | 职责 |
|---|---|---|---|
| 类型 emoji | icons 映射 + ?? '📁' |
28fp 浅灰底 | 图标识别 |
| 名称 | maxLines(1) |
15fp Medium | 主信息 |
| 类型胶囊 | 蓝底蓝字 | 11fp 胶囊 | 类型识别 |
| 大小/日期 | fmtSize + fmtTime | 11fp 灰字 | 元信息 |
| 标签 | if 条件渲染 | 11fp 灰字 🏷 | 扩展信息 |
十一、fmtSize 分级换算:数量级决定单位与精度
private fmtSize(size: number): string {
if (size >= 1073741824) {
return (size / 1073741824).toFixed(2) + ' GB';
}
if (size >= 1048576) {
return (size / 1048576).toFixed(1) + ' MB';
}
if (size >= 1024) {
return (size / 1024).toFixed(0) + ' KB';
}
return size + ' B';
}
阈值即 1024 的幂:1073741824 = 1024³(GB)、1048576 = 1024²(MB)、1024 = 1024¹(KB)——文件大小是二进制单位,除数与阈值必须同源,不能拿 1000 除却拿 1024 判断,否则出现「1023.9 KB」这类怪异值。
| 数量级 | 阈值 | 除数 | 精度 | 示例 |
|---|---|---|---|---|
| GB | ≥ 1024³ | 1073741824 | 2 位小数 | 1.25 GB |
| MB | ≥ 1024² | 1048576 | 1 位小数 | 800.0 MB |
| KB | ≥ 1024 | 1024 | 0 位小数 | 800 KB |
| B | < 1024 | — | 整数 | 512 B |
精度随数量级递减:MB 显示 1 位小数已够精确,KB 若也显示小数(「800.5 KB」)占位更宽且无信息量——单位越大,需要的有效数字越少。边界值:1024 字节落入 KB 分支输出「1 KB」;0 字节输出「0 B」。fmtTime 同理分级:1 小时内「x 分钟前」、当天「今天 HH:mm」、更早「M月d日」——时间也按距离分级。
十二、删除交互:确认 + 双通道刷新
行尾灰色 ✕ 是轻量删除入口,但直接删有误触风险,用 promptAction.showDialog 确认:
async deleteFile(f: FileRecord): Promise<void> {
const res = await promptAction.showDialog({
title: '删除文件',
message: `确定删除「${f.name}」?`,
buttons: [
{ text: '取消', color: '#9CA3AF' },
{ text: '删除', color: '#EF4444' },
],
});
if (res.index === 1) {
await FileLibDao.deleteById(this.context, f.id);
this.files = this.files.filter((x: FileRecord) => x.id !== f.id);
this.loadStats();
}
}
删除后两条刷新链路:① 列表——本地 filter 移除已删行(比重新查库快);② 统计——loadStats() 重查 typeStats,被删类型的 tab 计数归零后自动消失。res.index === 1 对应「删除」按钮(buttons 从 0 编号,index 0 是取消)。
十三、ArkTS 细节清单
| 细节 | 写法 | 说明 |
|---|---|---|
| 对象字面量 | readonly icons: Record<string, string> |
ArkTS 要求字面量带显式类型 |
| 兜底取值 | this.icons[f.fileType] ?? '📁' |
索引可能未命中,?? 兜底 |
| ForEach key | ${f.id}-${f.name} |
key 唯一稳定,差量更新 |
| 回调参数 | (t: string) => t |
必须显式标注类型,无隐式 any |
| 组件方法 | private fmtSize 等 |
方法声明为私有 |
| setTimeout 句柄 | private timer: number = -1 |
定时器返回 number |
ForEach key 的坑:key 必须全局唯一且稳定——只用 f.name 会因重名文件 key 冲突导致渲染错乱;${f.id}-${f.name} 用自增主键保证唯一。无 any 约束:ForEach/map 回调、对象属性访问全部显式类型——编译期强约束,杜绝隐式 any 的运行时风险。
十四、FAQ 问答
Q:搜索时为什么不做类型叠加过滤?
A:搜索(LIKE 全库模糊)与筛选(类型等值)是两种心智模型,叠加会让结果语义混乱,SQL 也要拼 OR+AND 复合条件。本实例选择互斥:搜索时忽略筛选,切筛选时清空搜索词——交互简单、语义清晰。
Q:typeStats 会不会和列表不同步?
A:三个刷新时机:onPageShow、删除后重查、种子数据导入后。选项来自 GROUP BY 统计而非写死,数据变了 tab 跟着变,天然一致。
Q:fmtSize 为什么 KB 不保留小数、GB 却保留两位?
A:按数量级给精度:KB 数值大,整数足够表达;GB 数值小(1.25GB 与 1.3GB 差约 50MB),两位小数才看得出差异。单位越大精度越高——这是「人类可读」的核心。
Q:删除为什么不直接删、非要弹确认框?
A:元数据删除后不可恢复,误触成本高。showDialog 是一次性低成本防护——多一步点击,换来删除操作的确定性。行尾 ✕ 保持轻量入口,确认框承担安全兜底。
更多推荐




所有评论(0)