Flutter 数字油画应用开发实战:Canvas 绘制与调色板交互

举报
yd_263028836 发表于 2026/06/24 20:48:49 2026/06/24
【摘要】 Flutter 数字油画应用开发实战:Canvas 绘制与调色板交互 前言数字油画(Paint by Numbers)是一种将艺术创作数字化的休闲方式,用户根据编号区域选择对应颜色进行填色,最终完成一幅精美的画作。这种应用不仅能够帮助用户释放压力、培养耐心,还能让没有绘画基础的人体验到创作的乐趣。本文将基于 Flutter 框架,从零构建一个清新风格的「数字油画」应用,涵盖 Canvas ...

Flutter 数字油画应用开发实战:Canvas 绘制与调色板交互

前言

数字油画(Paint by Numbers)是一种将艺术创作数字化的休闲方式,用户根据编号区域选择对应颜色进行填色,最终完成一幅精美的画作。这种应用不仅能够帮助用户释放压力、培养耐心,还能让没有绘画基础的人体验到创作的乐趣。本文将基于 Flutter 框架,从零构建一个清新风格的「数字油画」应用,涵盖 Canvas 画布绘制、调色板交互、作品进度管理以及 CustomPainter 自定义绘制等核心技术点。

背景

Flutter 作为 Google 推出的跨平台 UI 框架,凭借其强大的渲染能力和丰富的组件生态,在创意工具类应用中表现出色。Canvas 绘图是 Flutter 中实现自定义视觉效果的核心机制,通过 CustomPainter 可以在像素级别控制画布内容。对于数字油画这类需要精细图形操作的应用,Flutter 的 Canvas API 能够高效地处理格子绘制、颜色填充、文字标注等复杂场景,同时保持 60fps 的流畅动画体验。本应用采用清新的艺术设计风格,通过圆角卡片、柔和阴影和协调的配色方案,打造沉浸式的填色创作体验。
image.png

核心功能概览

本应用包含四大核心模块:

  • 画布预览区域:基于 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 绘制的内容溢出容器边界。CustomPaintsize 设置为 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 小圆角,轨道背景色同样使用主题色的低透明度版本,保持视觉一致性。
image.png

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 压力。
image.png

总结

本文详细介绍了基于 Flutter 构建数字油画应用的完整技术方案。从页面布局搭建到 Canvas 自定义绘制,从调色板交互到作品进度管理,涵盖了 Flutter 开发中多个重要的技术领域。核心亮点在于 _CanvasPainter 类的实现——它巧妙地将进度数值转化为可视化的填色效果,配合编号提示机制,完整还原了数字油画的创作体验。

Flutter 的跨平台特性使得这套方案可以直接运行于 Android、iOS、Web 以及 HarmonyOS 等多端环境,一套代码即可触达全平台用户。对于有兴趣深入的开发者,可以考虑以下扩展方向:

  • 支持 pinch-to-zoom 手势,实现画布缩放和平移
  • 引入图片解析算法,将真实照片转化为带编号的数字油画模板
  • 添加撤销/重做功能和本地持久化存储
  • 接入社区分享功能,让用户展示自己的作品成果
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。