HarmonyOS ArkTS画板深度解析:Grid 像素格布局、画图/擦除模式切换与颜色预设

一、引言
画板是用户创意表达的工具。虽然 ArkTS 原生支持 Canvas 组件用于自由绘图,但本应用采用了一种更轻量、更声明式的方式——使用 Grid 网格布局模拟 10×10 像素画板,每个格子是一个独立的 GridItem。
从技术角度来看,这个应用覆盖了 ArkTS 中几个独特的模式:
- Grid 的精确网格控制:
columnsTemplate('1fr × 10')创建 10×10 的规整像素网格 - 状态驱动的像素着色:每个格子的颜色由数组状态管理,点击即改变
- 模式切换(画图/擦除):通过
Button的选中态装饰器切换两种交互模式 - 颜色预设选择:7 种预设颜色,选中态高亮显示
- 模拟像素艺术:在无 Canvas 的情况下实现类像素画的交互体验
本文将从网格布局的数学原理出发,深入到 ArkTS 中用数组驱动 UI 渲染的核心思想。
二、完整源码
// index16.ets
@Entry
@Component
struct Index16 {
@State grid: number[] = new Array(100).fill(0);
@State currentColor: string = '#007AFF';
@State isEraseMode: boolean = false;
private colors: string[] = ['#FF3B30', '#FF9500', '#FFCC00',
'#34C759', '#007AFF', '#AF52DE', '#000000'];
paintCell(index: number): void {
this.grid[index] = this.isEraseMode ? 0 : 1;
}
clearAll(): void {
this.grid = new Array(100).fill(0);
}
getCellColor(value: number): string {
return value === 1 ? this.currentColor : '#F0F0F0';
}
build() {
Column() {
Text('🎨 简易画板').fontSize(28).fontWeight(FontWeight.Bold)
.margin({ top: 40, bottom: 5 })
Text('点击格子涂色,10×10 像素画板').fontSize(13)
.fontColor('#8E8E93').margin({ bottom: 15 })
// 颜色预设
Row({ space: 6 }) {
ForEach(this.colors, (color: string) => {
Column()
.width(28).height(28).backgroundColor(color).borderRadius(14)
.border({
width: this.currentColor === color && !this.isEraseMode ? 3 : 1,
color: this.currentColor === color && !this.isEraseMode ?
'#333333' : '#DDDDDD'
})
.onClick(() => {
this.currentColor = color;
this.isEraseMode = false;
})
}, (color: string) => color)
}.margin({ bottom: 10 })
// 模式切换
Row({ space: 12 }) {
Button('✏️ 画图').height(36).fontSize(13)
.backgroundColor(this.isEraseMode ? '#E5E5EA' : '#007AFF')
.fontColor(this.isEraseMode ? '#333333' : '#FFFFFF')
.borderRadius(18)
.onClick(() => { this.isEraseMode = false; })
Button('🧹 擦除').height(36).fontSize(13)
.backgroundColor(this.isEraseMode ? '#FF3B30' : '#E5E5EA')
.fontColor(this.isEraseMode ? '#FFFFFF' : '#333333')
.borderRadius(18)
.onClick(() => { this.isEraseMode = true; })
Button('🗑 清空').height(36).fontSize(13)
.backgroundColor('#8E8E93').fontColor('#FFFFFF')
.borderRadius(18)
.onClick(() => { this.clearAll(); })
}.margin({ bottom: 15 })
// 10×10 像素网格
Grid() {
ForEach(this.grid, (value: number, index: number) => {
GridItem() {
Column()
.width('100%').height('100%')
.backgroundColor(this.getCellColor(value))
.border({ width: 1, color: '#E0E0E0' })
.onClick(() => { this.paintCell(index); })
}
}, (value: number, index: number) => index.toString())
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(0).columnsGap(0)
.width(280).height(280)
Text('提示: 点击格子填充颜色,支持画图/擦除模式切换')
.fontSize(12).fontColor('#AAAAAA').margin({ top: 20 })
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Start)
.alignItems(HorizontalAlign.Center)
}
}
三、核心技术解析
3.1 数组状态驱动 UI:100 个像素的数据结构
画板的核心数据是一个长度为 100 的数字数组:
@State grid: number[] = new Array(100).fill(0);
每个元素代表一个像素格子的状态:
0:空白(默认颜色#F0F0F0)1:已着色(使用当前选中的颜色)
new Array(100).fill(0) 一次性创建 100 个元素并初始化为 0。这是 JavaScript 创建固定大小数组并填充默认值的最简洁方式。
数组索引 → 屏幕坐标的映射:
索引 i → 行 = Math.floor(i / 10)
列 = i % 10
例如 i = 23:
行 = Math.floor(23 / 10) = 2
列 = 23 % 10 = 3
→ 第 3 行第 4 列(从 0 开始编号)
这种一维数组映射二维网格的技术在游戏开发(如贪吃蛇、2048、扫雷)中广泛使用。
单元格颜色读取函数:
getCellColor(value: number): string {
return value === 1 ? this.currentColor : '#F0F0F0';
}
这个函数的精妙之处在于:当 value = 1 时,它返回的是当前的 currentColor。这意味着如果用户先画了一些蓝色格子,然后切换颜色为红色,之前画的蓝色格子仍然是蓝色(因为 grid[i] 的值保持为 1,而 getCellColor 返回的是当前的 currentColor……等等,这其实是一个 bug!)
发现问题了吗? 当 value = 1 时,getCellColor 总是返回当前的 currentColor,而不是"格子被涂色时的颜色"。这意味着:
- 用户用蓝色画了几个格子(
grid[0]=1, grid[1]=1) - 用户切换到红色(
currentColor = '#FF3B30') - 之前蓝色的格子变成了红色——因为
getCellColor(1)总是返回this.currentColor
修复方案:将 grid 改为存储具体的颜色值,而非简单的 0/1 标记:
@State grid: string[] = new Array(100).fill('');
paintCell(index: number): void {
this.grid[index] = this.isEraseMode ? '' : this.currentColor;
}
getCellColor(value: string): string {
return value || '#F0F0F0'; // 空字符串 → 底色,#XXXXXX → 该格子的颜色
}
这样每个格子独立存储自己的颜色值,切换颜色后不会影响已画好的格子。这是原始代码的一个设计缺陷,也是从 Demo 代码到生产级代码需要跨越的鸿沟。
3.2 Grid 的 columnsTemplate 精确控制
columnsTemplate 是 Grid 组件最强大的布局属性:
Grid()
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(0)
.columnsGap(0)
columnsTemplate 的语法类似 CSS Grid 的 grid-template-columns:
| 语法 | 含义 | 示例 |
|---|---|---|
1fr |
比例单位(fraction) | 3 个 1fr = 三等分 |
px |
固定像素 | 50px = 固定 50 像素 |
% |
百分比 | 20% = 父容器宽度的 20% |
repeat(n, 1fr) |
重复 | 部分版本支持 |
'1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr' 表示 10 列等宽的网格。当 Grid 总宽度为 280px 时,每列宽度为 28px,每个格子是一个 28×28 的正方形。
rowsGap(0) 和 columnsGap(0) 将间距设为 0,格子之间仅通过 1px 的 border 分隔。使用 border 而非 gap 的原因在于:边框在每个格子自己的区域内,不影响整体布局的精确计算。
3.3 画图/擦除模式切换
画板支持两种交互模式,通过布尔值 isEraseMode 控制:
@State isEraseMode: boolean = false;
paintCell(index: number): void {
this.grid[index] = this.isEraseMode ? 0 : 1;
}
模式切换的 UI 表现:
画图模式:isEraseMode = false
- "画图"按钮:蓝色(#007AFF),白色文字——表示当前激活
- "擦除"按钮:灰色(#E5E5EA),深色文字——表示非激活
擦除模式:isEraseMode = true
- "画图"按钮:灰色,深色文字
- "擦除"按钮:红色(#FF3B30),白色文字——表示当前激活
Button('✏️ 画图')
.backgroundColor(this.isEraseMode ? '#E5E5EA' : '#007AFF')
.fontColor(this.isEraseMode ? '#333333' : '#FFFFFF')
Button('🧹 擦除')
.backgroundColor(this.isEraseMode ? '#FF3B30' : '#E5E5EA')
.fontColor(this.isEraseMode ? '#FFFFFF' : '#333333')
这是互斥按钮组的典型实现方式——用一个状态变量控制两个按钮的样式。
3.4 颜色预设选择
画板提供了 7 种预设颜色,使用 ForEach 渲染:
private colors: string[] = ['#FF3B30', '#FF9500', '#FFCC00',
'#34C759', '#007AFF', '#AF52DE', '#000000'];
颜色预设的选中态通过边框宽度和颜色来实现:
Column()
.width(28).height(28)
.backgroundColor(color)
.borderRadius(14)
.border({
width: this.currentColor === color && !this.isEraseMode ? 3 : 1,
color: this.currentColor === color && !this.isEraseMode ?
'#333333' : '#DDDDDD'
})
当某个颜色是当前选中色且不在擦除模式时,该颜色圆点显示 3px 深色边框;否则显示 1px 浅色边框。
&& 条件:this.currentColor === color && !this.isEraseMode 确保当用户切换到擦除模式时,所有颜色预设都不显示"选中"状态——因为擦除模式下选色是无效的。
四、Grid 组件与其他布局的对比
在 ArkTS 中创建网格布局有多种方式:
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
Grid + columnsTemplate |
精确控制行列,自动换行 | 需要指定列数 | 像素画板、九宫格、图片墙 |
Column + 多个 Row |
每行可不同布局 | 手动管理行数 | 计算器按键、表单 |
Flex wrap |
自动换行 | 控制不够精细 | 标签、徽章 |
对于画板这种规整的网格(每行每列数量固定、格子大小完全一致),Grid 是最佳选择。
五、清空操作:数组替换 vs 逐元素赋值
clearAll(): void {
this.grid = new Array(100).fill(0);
}
这里通过整体替换数组(而不是 for 循环逐个赋值)来实现清空。这是有意为之的:
整体替换:
this.grid = new Array(100).fill(0); // ✅ 触发 @State 更新
逐个赋值(不可行):
for (let i = 0; i < 100; i++) {
this.grid[i] = 0; // ⚠️ 100次触发更新,性能极差
}
// 或者
this.grid.fill(0); // ⚠️ 部分 ArkTS 版本不支持
ArkTS 的 @State 对数组的代理支持直接索引赋值(this.grid[i] = x),但 100 次 for 循环赋值会导致 100 次 UI 更新,性能开销巨大。整体替换一次触发一次更新,效率极高。
六、扩展方向
- 支持 Canvas 自由绘制:集成
Canvas组件,支持手指拖拽连续绘制而非逐格点击 - 填充工具:实现"油漆桶"功能,点击后填充相邻同色区域(Flood Fill 算法)
- 保存与分享:将像素画板内容导出为图片,通过分享 Kit 分享
- 撤销/重做:保存操作栈(
Command Pattern),支持 Ctrl+Z 撤销 - 多种笔刷大小:支持 1×1、2×2、3×3 笔刷
- 对称绘制:水平/垂直对称模式,画一笔出现对称的两笔
- 导入模板:加载预设图案模板(爱心、笑脸、星星等)
七、本章小结
| 知识点 | 掌握程度 |
|---|---|
| 一维数组映射二维网格 | ✅ 理解 Math.floor(i/w) + i%w 映射 |
columnsTemplate('1fr ...') |
✅ 掌握 Grid 精确列控制 |
| 画图/擦除模式切换 | ✅ 掌握互斥按钮的设计模式 |
| 颜色预设选中态高亮 | ✅ 理解条件边框样式 |
| 数组整体替换 vs 逐元素赋值 | ✅ 理解性能差异 |
new Array(100).fill(0) |
✅ 掌握固定大小数组的创建 |
简易画板展示了 ArkTS 中用数据驱动网格 UI的经典模式,以及模式切换的设计思想。虽然在真正的绘图应用中 Canvas 更为合适,但 Grid 方式在学习数组状态管理和网格布局方面有独特的教学价值。
更多推荐


所有评论(0)