3.5 CustomPaint:把数据画成图


3.5 CustomPaint:把数据画成图

本节摘要:标准组件总有画不出的东西——轻记账统计页的分类占比图、预算环形进度、自定义折线,都不在控件目录里。本节用 CustomPaint 从零画一张占比图:Canvas 的坐标与画笔、Path 的弧线拼图、shouldRepaint 的重绘闸门。读完你将具备"没有现成组件也能画"的底气,并顺手理解第 6 章重绘优化的最小案例。

什么时候自己画:一个判断标准

组件库里找不到合适的、或者用组合硬凑会引入嵌套地狱的视觉,就该自己画。判断标准一句话:**这个视觉是"排布已有的东西"还是"定义新的形状"?**排布(卡片、列表、表单)永远用组件组合;形状(圆环、曲线、扇面、标线)用画笔。

自画的成本账也要算:Painter 代码不可维护性高于组件组合,可访问性要自己补(画布上的图形对读屏软件不可见,需配语义标注)。所以轻记账只对统计页图表走自绘,其余一律组件。占比图的数据结构先定下来:

class CategorySlice { final String name; final double value; final Color color; const CategorySlice(this.name, this.value, this.color); } const demoSlices = [ CategorySlice('餐饮', 1280, Color(0xFF00897B)), CategorySlice('交通', 640, Color(0xFF7E57C2)), CategorySlice('购物', 890, Color(0xFFFFB300)), CategorySlice('居住', 470, Color(0xFF42A5F5)), ];

Painter 的协议:一张画布、两道纪律

CustomPaint 的核心是 CustomPainter 子类,它有两个必须实现的成员。paint 领到画布(Canvas)与自己的尺寸(Size),把图形画上去;shouldRepaint 回答"旧数据画的图还作数吗"——返回 false 则上一帧的成果直接复用,返回 true 才重画。

两道纪律从第一个项目就该立:paint 必须无副作用(只画,不改外部状态,与 build 的纯函数纪律同源);shouldRepaint 必须比较数据(而不是无脑返回 true——那会让每帧都重画,图表再简单也架不住每帧都来)。

class CategoryPiePainter extends CustomPainter { final List<CategorySlice> slices; final double strokeWidth; CategoryPiePainter({required this.slices, this.strokeWidth = 26}); @override void paint(Canvas canvas, Size size) { final total = slices.fold<double>(0, (s, e) => s + e.value); final center = size.center(Offset.zero); final radius = size.shortestSide / 2 - strokeWidth; final rect = Rect.fromCircle(center: center, radius: radius); const start = -pi / 2; // 从正上方起笔,符合读表直觉 double cursor = start; for (final slice in slices) { final sweep = total <= 0 ? 0.0 : (slice.value / total) * 2 * pi; final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.butt ..color = slice.color; // 圆环的一段弧:描边式圆弧比实心扇面更现代,也省填充计算 canvas.drawArc(rect, cursor, sweep, false, paint); cursor += sweep; } } // 数据相同就不重画:图表静止时零开销 @override bool shouldRepaint(CategoryPiePainter oldDelegate) => oldDelegate.slices != slices || oldDelegate.strokeWidth != strokeWidth; }

代码里的几个坐标细节值得停一下。起点取负二分之π,让第一块扇面从正上方开始——看图的人对"从 12 点钟开始"有天然预期;半径留出描边宽度的一半,避免圆环被裁边;空数据时扫过角度为零,画布上什么都不画,交给上层组件显示占位文案,而不是在 Painter 里画"暂无数据"——职责分离:形状归 Painter,文案归组件

组装成组件:CustomPaint 与语义标注

Painter 本身不是 Widget,要套进 CustomPaint 使用。轻记账把它包成带图例与无障碍标注的完整统计卡:

class CategoryPieCard extends StatelessWidget { final List<CategorySlice> slices; const CategoryPieCard({super.key, required this.slices}); @override Widget build(BuildContext context) { final total = slices.fold<double>(0, (s, e) => s + e.value); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 180, height: 180, child: CustomPaint( painter: CategoryPiePainter(slices: slices), // 画布对读屏不可见,语义必须自己给:一句话概括全图 child: Semantics( label: '消费占比图:' + slices.map((s) => '${s.name}占${(s.value / total * 100).toStringAsFixed(0)}百分比').join(','), child: const SizedBox.expand(), ), ), ), const SizedBox(height: 12), Wrap( spacing: 12, children: [ for (final s in slices) Row(mainAxisSize: MainAxisSize.min, children: [ Container(width: 10, height: 10, color: s.color), const SizedBox(width: 4), Text(s.name), ]), ], ), ], ), ), ); } }

使用它只需 CategoryPieCard(slices: demoSlices)。三件事在这个组装层完成:给 CustomPaint 一个确定尺寸(无界约束里画布不知道该多大,尺寸错误是自绘组件最常见报错);图例用普通组件拼——文字排版交给 Text 永远比自己 fillText 省心;语义标注包住画布,让读屏用户拿到一句完整的图意描述。画布管形状,组件管排布与语义,这条分界让自绘代码保持在小而美的规模。

进阶三式:标注、动效与重绘范围

第一式,数据标注。扇面上标注金额,在 paint 里按弧心角算出中点坐标再画文字。要留心:Painter 里画文字用 TextPainter(它负责排版),并且把文本样式从构造参数传入而不是写死——深色主题切换时标注色要跟着走。

void _drawLabel(Canvas canvas, Offset at, String text, TextStyle style) { final tp = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, at - Offset(tp.width / 2, tp.height / 2)); }

第二式,入场动效。占比图从零长到满,是显式动画的又一次登场:AnimationController 从 0 推到 1,把进度值乘在每个 sweep 上。注意动效改的是 Painter 的输入值,每帧 shouldRepaint 都判 true——这正是"动效区域套 RepaintBoundary"的用武之地:圆环重画三百六十度,页面其余部分纹丝不动。

AnimatedBuilder( animation: _ctrl, builder: (_, __) => CustomPaint( painter: CategoryPiePainter( slices: slices, progress: _ctrl.value, // Painter 内部 sweep 乘以 progress ), child: const SizedBox(width: 180, height: 180), ), )

第三式,重绘闸门的正确姿势。shouldRepaint 比较引用即可成立的前提是:数据不可变(第 3.1 节的 final 字段与 const 构造在这里再次立功)。若 slices 是每次 build 新建的 List,引用必不相等,闸门形同虚设——要么保证同一份数据传同一引用,要么在 shouldRepaint 里逐项比较。这两种写法都在第 6 章的重建优化实验里量过收益。

本节要点回顾

  • 判断标准:排布用组件组合,定义新形状才自绘;自绘要补语义标注;
  • Painter 协议:paint 无副作用只画形状,shouldRepaint 比较数据决定重绘;
  • 起笔方位、半径留边、空数据留白给上层——图表细节有约定俗成;
  • 组装层管尺寸、图例与 Semantics;画布管形状,组件管排布;
  • 文字排版交给 TextPainter 且样式走参数;入场动效用控制器驱动输入值,配 RepaintBoundary 圈住重绘范围。

界面的结构、布局、动效、观感、自绘都已到位。但界面还是"死"的——数据变了没人通知它。下一章进入状态管理与导航,让轻记账真正"活"起来。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U