Flutter 数字油画应用开发实战:Canvas 绘制与调色板交互
Flutter 数字油画应用开发实战:Canvas 绘制与调色板交互
前言
数字油画(Paint by Numbers)是一种将艺术创作数字化的休闲方式,用户根据编号区域选择对应颜色进行填色,最终完成一幅精美的画作。这种应用不仅能够帮助用户释放压力、培养耐心,还能让没有绘画基础的人体验到创作的乐趣。本文将基于 Flutter 框架,从零构建一个清新风格的「数字油画」应用,涵盖 Canvas 画布绘制、调色板交互、作品进度管理以及 CustomPainter 自定义绘制等核心技术点。
背景
Flutter 作为 Google 推出的跨平台 UI 框架,凭借其强大的渲染能力和丰富的组件生态,在创意工具类应用中表现出色。Canvas 绘图是 Flutter 中实现自定义视觉效果的核心机制,通过 CustomPainter 可以在像素级别控制画布内容。对于数字油画这类需要精细图形操作的应用,Flutter 的 Canvas API 能够高效地处理格子绘制、颜色填充、文字标注等复杂场景,同时保持 60fps 的流畅动画体验。本应用采用清新的艺术设计风格,通过圆角卡片、柔和阴影和协调的配色方案,打造沉浸式的填色创作体验。

核心功能概览
本应用包含四大核心模块:
- 画布预览区域:基于
CustomPaint和_CanvasPainter实现,280 高度的 6x5 格子画布,支持根据进度动态填充颜色或显示编号 - 10 色调色板:水平排列的圆形色块选择器,支持点击切换选中状态,选中态具有放大、边框、阴影三重反馈
- 作品列表:展示用户的数字油画作品及完成进度,每项包含标题、风格、色块数量和进度条
- 自定义绘制引擎:
_CanvasPainter类负责核心渲染逻辑,包括背景绘制、格子填充、编号文字等
开发核心代码
1. 数据模型与状态定义
首先定义应用的核心数据结构,包括调色板颜色集合、作品列表数据以及当前选中的颜色索引和全局进度:
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
int _selectedColor = 2; // 当前选中的调色板颜色索引
final _progress = 0.38; // 全局完成进度(38%)
static const Color _bg = Color(0xFFFAFAFA); // 页面背景色
static const Color _ink = Color(0xFF1F2937); // 主文字颜色
// 10色调色板:深灰、红、橙黄、绿、蓝、紫、粉、橙、青、黄绿
final _palette = const [
Color(0xFF1F2937), Color(0xFFEF4444), Color(0xFFF59E0B),
Color(0xFF10B981), Color(0xFF3B82F6), Color(0xFF8B5CF6),
Color(0xFFEC4899), Color(0xFFF97316), Color(0xFF06B6D4), Color(0xFF84CC16),
];
// 我的作品列表数据
final _works = const [
{'title': '星月夜', 'artist': '梵高风格', 'pieces': 286, 'progress': 0.38, 'color': Color(0xFF3B82F6)},
{'title': '向日葵', 'artist': '梵高风格', 'pieces': 198, 'progress': 0.72, 'color': Color(0xFFF59E0B)},
{'title': '富士山', 'artist': '浮世绘风格', 'pieces': 342, 'progress': 0.15, 'color': Color(0xFF8B5CF6)},
];
}
这段代码定义了应用的完整状态模型。_selectedColor 使用整数索引追踪当前选中的颜色,默认值为 2(橙黄色),与调色板数组形成映射关系。_progress 存储当前画作的总体填充比例,驱动画布格子的显示状态。_palette 数组包含 10 种精选配色,覆盖了数字油画常用的主色调范围。_works 列表存储三个示例作品的元信息,每个作品包含标题、艺术风格、总色块数、当前进度和主题色,为后续的作品卡片渲染提供数据源。
2. 页面整体布局构建
使用 Scaffold + SingleChildScrollView 组合搭建页面骨架,确保在不同屏幕尺寸下都能正常滚动显示:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bg,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(children: [
const SizedBox(height: 8),
_topBar(), // 顶部导航栏
const SizedBox(height: 12),
_canvasPreview(), // 画布预览区
const SizedBox(height: 14),
_colorPalette(), // 调色板选择器
const SizedBox(height: 20),
_worksList(), // 作品列表
]),
),
),
);
}
布局采用垂直堆叠的 Column 结构,各模块之间使用 SizedBox 设置合理的间距。外层 SingleChildScrollView 保证内容超出屏幕时可以滚动,SafeArea 避免内容被系统状态栏遮挡。整体背景使用浅灰色 #FAFAFA,营造清爽的视觉感受。
3. 顶部导航栏实现
顶部栏包含应用标题和实时进度百分比标签:
Widget _topBar() {
return Row(children: [
const Text('🎨 数字油画',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: _ink)),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFEEF2FF),
borderRadius: BorderRadius.circular(20)),
child: Text('${(_progress * 100).toInt()}%',
style: const TextStyle(fontSize: 10, color: Color(0xFF6366F1), fontWeight: FontWeight.w700))),
]);
}
顶部导航使用 Row 实现左右分布布局,左侧是带 emoji 的加粗标题,右侧是胶囊形状的进度标签。进度标签使用靛蓝色系配色(#EEF2FF 背景 + #6366F1 文字),圆角 20 呈现圆润的药丸形态。通过 _progress * 100 将小数转换为整型百分比值进行显示,实时反映当前填色进度。
4. Canvas 画布预览区域
这是整个应用的核心视觉组件,使用 CustomPaint 配合自定义 _CanvasPainter 实现画布渲染:
Widget _canvasPreview() {
return Container(
height: 280,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 12)],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: CustomPaint(
size: Size.infinite,
painter: _CanvasPainter(progress: _progress),
),
),
);
}
画布容器固定高度 280,使用白色背景配合 24 的大圆角设计,营造出类似相框的视觉效果。阴影参数设置为极低透明度(4%)+ 12 模糊半径,呈现轻盈的悬浮感。内部使用 ClipRRect 对子组件进行圆角裁剪,防止 CustomPaint 绘制的内容溢出容器边界。CustomPaint 的 size 设置为 Size.infinite 使其自动填充父容器可用空间,painter 属性传入 _CanvasPainter 实例并将 _progress 作为参数传递,驱动画布内容的动态更新。
5. 调色板颜色选择器
调色板是用户与画作交互的核心入口,实现了颜色选择的视觉反馈和状态管理:
Widget _colorPalette() {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(_palette.length, (i) {
final sel = _selectedColor == i;
return GestureDetector(
onTap: () => setState(() => _selectedColor = i),
child: Container(
width: sel ? 36 : 28,
height: sel ? 36 : 28,
decoration: BoxDecoration(
color: _palette[i],
shape: BoxShape.circle,
border: sel ? Border.all(color: _ink, width: 2.5) : null,
boxShadow: sel ? [BoxShadow(color: _palette[i].withValues(alpha: 0.4), blurRadius: 8)] : [],
),
),
);
})),
);
}
调色板使用水平 Row 布局,spaceEvenly 让 10 个色块均匀分布。每个色块是一个圆形 Container,通过 List.generate 动态生成,避免重复代码。选中态与非选中态存在三处差异化表现:尺寸上选中时放大到 36x36(未选中 28x28);边框上选中时添加深灰色描边;阴影上选中时添加与自身颜色同系的辉光效果。点击事件通过 GestureDetector 拦截,调用 setState 更新 _selectedColor 触发界面重绘。这种多重视觉反馈的设计让用户能清晰感知当前的选中状态,提升交互体验。
6. 作品进度列表
作品列表以卡片形式展示用户的所有数字油画作品及其完成情况:
Widget _worksList() {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(padding: EdgeInsets.only(left: 4, bottom: 10),
child: Text('我的作品', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: _ink))),
..._works.map((w) {
final c = w['color'] as Color;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Row(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(color: c.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(14)),
child: Center(child: Icon(Icons.palette, color: c, size: 28))),
const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(w['title'] as String, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800, color: _ink)),
const SizedBox(height: 2),
Text('${w['artist']} · ${w['pieces']}个色块', style: const TextStyle(fontSize: 10, color: Color(0xFF9CA3AF))),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: w['progress'] as double,
minHeight: 4,
backgroundColor: c.withValues(alpha: 0.06),
valueColor: AlwaysStoppedAnimation(c))),
])),
Text('${((w['progress'] as double) * 100).toInt()}%', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w900, color: c)),
]),
);
}),
]);
}
作品列表由标题行 + 卡片列表两部分组成。每个作品卡片采用横向 Row 布局,分为左中右三个区域:
- 左侧:56x56 圆角矩形图标区,使用作品主题色的 6% 透明度作为背景色,中心放置调色盘图标
- 中间(Expanded):垂直排列的作品名称(13号加粗)、副标题(风格+色块数,10号灰色)、线性进度条(4px 高度,颜色与主题色一致)
- 右侧:大号百分比数值,字体最粗(FontWeight.w900),颜色与主题色统一
进度条使用 LinearProgressIndicator 组件,通过 ClipRRect 裁剪为 2px 小圆角,轨道背景色同样使用主题色的低透明度版本,保持视觉一致性。

7. _CanvasPainter 自定义绘制类
这是整个应用技术含量最高的部分,负责在 Canvas 上绘制 30 个编号格子的完整逻辑:
class _CanvasPainter extends CustomPainter {
final double progress;
const _CanvasPainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
// 绘制浅灰背景
final bgPaint = Paint()..color = const Color(0xFFF3F4F6);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint);
// 定义 8 种填充色和对应的编号序列
final colors = [
const Color(0xFF1F2937), const Color(0xFF3B82F6), const Color(0xFFF59E0B),
const Color(0xFF10B981), const Color(0xFF8B5CF6), const Color(0xFFEF4444),
const Color(0xFFEC4899), const Color(0xFFF97316)
];
final rng = [3, 6, 2, 5, 1, 7, 0, 4]; // 编号映射表
// 循环绘制 30 个格子(6列 x 5行)
for (int i = 0; i < 30; i++) {
final x = (i % 6) * size.width / 6;
final y = (i ~/ 6) * size.height / 5;
final w = size.width / 6;
final h = size.height / 5;
// 根据 progress 判断该格子是否已填充
final filled = (i / 30.0) < progress;
final colorIdx = i % colors.length;
// 绘制格子背景(已填充用彩色,未填充用白色)
final paint = Paint()..color = filled ? colors[colorIdx] : Colors.white;
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(x + 1, y + 1, w - 2, h - 2),
const Radius.circular(4));
canvas.drawRRect(rect, paint);
// 未填充格子绘制编号文字
if (!filled) {
final tp = TextPainter(
text: TextSpan(text: '${rng[colorIdx]}',
style: TextStyle(color: colors[colorIdx].withValues(alpha: 0.3),
fontSize: 11, fontWeight: FontWeight.w700)),
textDirection: TextDirection.ltr)..layout();
tp.paint(canvas, Offset(x + w / 2 - tp.width / 2, y + h / 2 - tp.height / 2));
}
}
}
@override
bool shouldRepaint(covariant _CanvasPainter old) => old.progress != progress;
}
绘制流程解析:
第一步 — 背景填充:使用浅灰色(#F3F4F6)铺满整个画布区域,作为格子的底色衬托。
第二步 — 格子网格计算:总共 30 个格子按 6 列 5 行排列。通过取模运算 (i % 6) 计算列位置,整除运算 (i ~/ 6) 计算行位置,再乘以单格宽高得到绝对坐标。每个格子四周留出 1px 间距(x + 1, y + 1),宽高减去 2px(w - 2, h - 2)形成间隙效果。
第三步 — 填充状态判断:核心算法 (i / 30.0) < progress 将格子索引归一化后与进度值比较。当 progress 为 0.38 时,前 11 个格子(索引 0-10)会被判定为已填充,其余显示为空白待填色状态。这种顺序填充方式直观展示了数字油画的创作过程。
第四步 — 格子绘制:使用 drawRRect 绘制圆角矩形,半径 4px 保持柔和感。已填充格子使用 8 色循环中的对应颜色,未填充格子使用纯白背景。
第五步 — 编号文字绘制:对未填充的格子,使用 TextPainter 在格子中心绘制对应的编号数字。编号来自 rng 映射表,颜色使用对应颜色的 30% 透明度版本,字号 11px 加粗。文字居中对齐通过计算偏移量实现:x + w/2 - width/2 确保水平居中,y + h/2 - height/2 确保垂直居中。
第六步 — 重绘优化:shouldRepaint 方法仅在 progress 值变化时返回 true,避免不必要的重绘开销,保证性能。
技术要点总结
| 技术点 | 实现方式 | 应用场景 |
|---|---|---|
| 自定义绘制 | CustomPainter + Canvas.drawRRect |
画布格子渲染 |
| 文字绘制 | TextPainter + TextSpan |
编号数字显示 |
| 状态驱动的 UI | setState + 条件判断 |
进度填充/颜色选择 |
| 列表动态生成 | List.generate + .map() |
调色板色块/作品卡片 |
| 进度条 | LinearProgressIndicator |
作品完成度可视化 |
| 圆角裁剪 | ClipRRect + BorderRadius |
卡片/画布容器 |
心得
通过本次数字油画应用的开发实践,我对 Flutter 的 Canvas 绘图体系有了更深入的理解。CustomPainter 是 Flutter 中实现复杂视觉效果的核心手段,它赋予开发者在像素级别操控画布的能力,适用于图表绘制、游戏画面、艺术创作等场景。在本项目中,通过将业务逻辑(进度值)与渲染逻辑(格子填充)解耦,_CanvasPainter 只接收 progress 参数就能独立完成全部绘制工作,体现了良好的单一职责原则。
调色板的交互设计是另一个值得分享的经验点。通过同时改变尺寸、边框、阴影三个属性来标识选中状态,比单一的变色方案更具层次感和辨识度。这种"多维度状态反馈"的设计思路可以推广到其他选择器组件中,如主题切换、字体大小调节等。
性能优化方面,shouldRepaint 方法的精确控制尤为重要。在实际项目中,如果忽略这个方法导致每次 setState 都触发全量重绘,当画布格子数量增加到数百甚至上千个时,帧率会显著下降。此外,TextPainter 的实例化应放在循环内部而非外部复用,因为每个格子的文本内容不同;但 Paint 对象则可以在循环外创建并修改属性,减少 GC 压力。

总结
本文详细介绍了基于 Flutter 构建数字油画应用的完整技术方案。从页面布局搭建到 Canvas 自定义绘制,从调色板交互到作品进度管理,涵盖了 Flutter 开发中多个重要的技术领域。核心亮点在于 _CanvasPainter 类的实现——它巧妙地将进度数值转化为可视化的填色效果,配合编号提示机制,完整还原了数字油画的创作体验。
Flutter 的跨平台特性使得这套方案可以直接运行于 Android、iOS、Web 以及 HarmonyOS 等多端环境,一套代码即可触达全平台用户。对于有兴趣深入的开发者,可以考虑以下扩展方向:
- 支持 pinch-to-zoom 手势,实现画布缩放和平移
- 引入图片解析算法,将真实照片转化为带编号的数字油画模板
- 添加撤销/重做功能和本地持久化存储
- 接入社区分享功能,让用户展示自己的作品成果
- 点赞
- 收藏
- 关注作者
评论(0)