本篇文章主要介绍以下几个内容:
- Flutter 自定义组件
- 与 Android/iOS 原生自定义组件对比
- 从零到一实现一个图表 ChartBar 组件示例
在 Flutter 开发中,虽然官方提供了丰富的 Widget 组件,但在实际项目中,我们经常会遇到需要自定义绘制的场景。
本文将以一个功能完整的 ChartBar 图表组件为例,从 0 到 1 展示 Flutter 自定义 View 的实现方法。
1. Flutter 自定义组件
1.1 场景
在以下场景中,通常需要自定义组件:
- 复杂图形绘制:如图表、仪表盘、自定义进度条等;
-
特殊交互效果:现有
Widget
无法满足的交互需求; -
性能优化:减少
Widget
树层级,提升渲染性能; - 品牌定制:实现独特的 UI 设计风格;
- 动画效果:复杂的自定义动画实现。
1.2 实现方式
Flutter 提供了多种自定义绘制的方式:
- CustomPainter + CustomPaint:最常用的方式,适合复杂绘制;
- RenderObject:更底层的实现,性能更好但复杂度更高;
- Canvas 直接绘制:在特定场景下使用。
本文主要讲解 CustomPainter
的实现方式。
2. Flutter vs 原生自定义组件对比
特性 | Flutter | Android View | Android Compose | iOS |
---|---|---|---|---|
绘制API | Canvas + Paint | Canvas + Paint | DrawScope + DrawContext | Core Graphics |
坐标系统 | 左上角原点 | 左上角原点 | 左上角原点 | 左下角原点 |
性能 | Impeller/Skia引擎,高性能 | 硬件加速 | 硬件加速 | Core Animation |
跨平台 | 一套代码多平台 | 仅Android | 仅Android | 仅iOS |
学习成本 | 中等 | 中等 | 较低 | 较高 |
开发方式 | 声明式 | 命令式 | 声明式 | 命令式 |
状态管理 | StatefulWidget | 手动管理 | 自动重组 | 手动管理 |
2.1 详细对比分析
以下通过一个简单的自定义圆形进度条示例,展示各平台的自定义绘制实现方式。
实现效果如下:
- Flutter CustomPainter
import 'dart:math' as math;
// 自定义圆形进度条组件
class CircularProgressPainter extends CustomPainter {
final double progress; // 进度值 0.0-1.0
CircularProgressPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
// 绘制背景圆环
final backgroundPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 8.0
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, backgroundPaint);
// 绘制进度圆弧
final progressPaint = Paint()
..color = Colors.blue
..strokeWidth = 8.0
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // 从顶部开始
2 * math.pi * progress, // 根据进度计算弧度
false,
progressPaint,
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
// 使用方式
CustomPaint(
size: Size(100, 100),
painter: CircularProgressPainter(0.7), // 70%进度
)
- Android 传统 View
// 自定义圆形进度条View
class CircularProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0f // 进度值 0.0-1.0
// 背景圆环画笔
private val backgroundPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 24f
style = Paint.Style.STROKE
isAntiAlias = true
}
// 进度圆弧画笔
private val progressPaint = Paint().apply {
color = Color.BLUE
strokeWidth = 24f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
isAntiAlias = true
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.let {
val centerX = width / 2f
val centerY = height / 2f
val radius = (width.coerceAtMost(height) / 2f) - 30f
// 绘制背景圆环
it.drawCircle(centerX, centerY, radius, backgroundPaint)
// 绘制进度圆弧
val rect = RectF(
centerX - radius, centerY - radius,
centerX + radius, centerY + radius
)
it.drawArc(rect, -90f, 360f * progress, false, progressPaint)
}
}
// 设置进度的方法
fun setProgress(progress: Float) {
this.progress = progress.coerceIn(0f, 1f)
invalidate() // 触发重绘
}
}
- Android Compose
// 自定义圆形进度条Composable
@Composable
fun CircularProgressIndicator(
progress: Float, // 进度值 0.0-1.0
modifier: Modifier = Modifier,
size: Dp = 100.dp
) {
Canvas(modifier = modifier.size(size)) {
val center = Offset(size.width / 2, size.height / 2)
val radius = size.width / 2 - 20.dp.toPx()
// 绘制背景圆环
drawCircle(
color = Color.LightGray,
radius = radius,
center = center,
style = Stroke(width = 8.dp.toPx())
)
// 绘制进度圆弧
drawArc(
color = Color.Blue,
startAngle = -90f, // 从顶部开始
sweepAngle = 360f * progress, // 根据进度计算角度
useCenter = false,
topLeft = Offset(center.x - radius, center.y - radius),
size = Size(radius * 2, radius * 2),
style = Stroke(
width = 8.dp.toPx(),
cap = StrokeCap.Round
)
)
}
}
// 使用方式
CircularProgressIndicator(progress = 0.7f) // 70%进度
- iOS Core Graphics
// 自定义圆形进度条UIView
class CircularProgressView: UIView {
var progress: CGFloat = 0.0 {
didSet {
setNeedsDisplay() // 触发重绘
}
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
let radius = min(rect.width, rect.height) / 2 - 10
// 绘制背景圆环
context.setStrokeColor(UIColor.lightGray.cgColor)
context.setLineWidth(8.0)
context.addArc(
center: center,
radius: radius,
startAngle: 0,
endAngle: CGFloat.pi * 2,
clockwise: false
)
context.strokePath()
// 绘制进度圆弧
context.setStrokeColor(UIColor.blue.cgColor)
context.setLineWidth(8.0)
context.setLineCap(.round)
let startAngle = -CGFloat.pi / 2 // 从顶部开始
let endAngle = startAngle + (CGFloat.pi * 2 * progress)
context.addArc(
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false
)
context.strokePath()
}
}
// 使用方式
let progressView = CircularProgressView()
progressView.progress = 0.7 // 设置70%进度
代码对比总结
平台 | 代码复杂度 | 状态管理 | 重绘机制 | 类型安全 |
---|---|---|---|---|
Flutter | 中等,声明式清晰 | 自动管理 |
shouldRepaint 控制 |
Dart 类型安全 |
Android View | 较复杂,需手动管理 | 手动 invalidate()
|
onDraw 重写 |
Kotlin 类型安全 |
Android Compose | 简洁,声明式 | 自动重组 | 状态变化自动重绘 | Kotlin 类型安全 |
iOS | 复杂,C风格API | 手动 setNeedsDisplay()
|
draw 方法重写 |
Swift 类型安全 |
2.2 各平台特色对比
Flutter 的优势
- 一致性:一套代码,多平台完全一致的渲染效果;
- 热重载:开发效率极高,实时预览效果;
- 丰富的动画 API:内置强大的动画系统;;
- 声明式 UI:代码更简洁,状态管理更清晰;
- Impeller 渲染引擎:新一代高性能 2D 图形渲染引擎(iOS默认,Android可选)。
Android Compose 的优势
- 现代化:Google 最新推荐的 UI 框架;
- 声明式:类似 Flutter 的开发体验;
- 与 Android 深度集成:可以无缝使用 Android 系统特性;
- 性能优化:智能重组,只更新变化的部分;
- 类型安全:Kotlin 的类型系统提供更好的安全性。
Android 传统 View 的优势
- 成熟稳定:经过多年验证,生态完善;
- 系统集成:与Android系统紧密集成;
- 性能可控:可以精确控制绘制过程;
- 丰富的系统API:可以直接使用所有 Android API。
iOS 的优势
- 系统原生:与 iOS 系统完美集成;
- 性能极致:充分利用硬件特性;
- 设计一致性:符合 Apple 设计规范;
- Core Animation:强大的动画和图形处理能力。
选择建议
场景 | 推荐方案 | 理由 |
---|---|---|
跨平台应用 | Flutter | 一套代码,维护成本低 |
Android 专属复杂 UI | Compose | 现代化,与系统深度集成 |
需要系统底层 API | 原生View | 可以使用所有平台特性 |
快速原型开发 | Flutter | 热重载,开发效率高 |
性能要求极致 | 原生 View | 可以做到极致优化 |
团队技术栈 | 根据团队熟悉度选择 | 学习成本和开发效率平衡 |
2.3 Flutter 渲染引擎演进
Flutter 的渲染引擎经历了重要演进:
- Skia 引擎时代(Flutter 1.0 - 3.x)
- 基于 Google 的 Skia 2D图形库;
- 运行时编译着色器,可能导致首次渲染卡顿;
- 成熟稳定,但在某些场景下性能有限制。
- Impeller 引擎时代(Flutter 3.10+)
- iOS平台:Flutter 3.10+ 默认启用 Impeller;
- Android平台:Flutter 3.16+ 可选启用,预计未来版本默认启用;
-
核心优势:
- 预编译着色器,消除
shader compilation jank
; - 更好的 GPU 利用率和渲染性能;
- 专为 Flutter 优化设计;
- 支持更复杂的视觉效果。
- 预编译着色器,消除
- 使用建议
# flutter/android/app/src/main/AndroidManifest.xml
# 在Android上启用Impeller(实验性)
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
对于自定义绘制而言,Impeller 带来的主要改进:
- 更流畅的动画:预编译着色器消除卡顿;
- 更好的复杂图形性能:优化的 GPU 渲染管线;
- 一致的跨平台体验:统一的渲染行为。
3. 自定义ChartBar 组件实战
想象一下,你是一名数据分析师,老板给你一堆销售数据,要求你做一个漂亮的图表展示。
下面跟随这个场景,一步步实现一个功能完整的 ChartBar 组件。
需求1:老板说"先给我画个坐标轴出来看看"
场景: 老板拿着一张白纸说:"你先给我画个坐标轴,X 轴放月份,Y 轴放销售额。"
好的,先搭建基础框架:
class ChartBar extends StatefulWidget {
final double? width;
final double? height;
final List<String> xData;
final List<double> yData;
const ChartBar({
super.key,
this.width,
this.height,
required this.xData,
required this.yData,
});
@override
State<ChartBar> createState() => _ChartBarState();
}
class _ChartBarState extends State<ChartBar> {
@override
Widget build(BuildContext context) {
return Container(
width: widget.width ?? 300,
height: widget.height ?? 200,
child: CustomPaint(
painter: ChartBarPainter(xData: widget.xData, yData: widget.yData),
),
);
}
}
然后创建我们的画师(CustomPainter
):
class ChartBarPainter extends CustomPainter {
final List<String> xData;
final List<double> yData;
ChartBarPainter({required this.xData, required this.yData});
late double chartWidth;
late double chartHeight;
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
// 老板的第一个需求:画坐标轴
_drawAxis(canvas, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
关键技巧:坐标系变换
Flutter 的坐标系原点在左上角,但我们的图表需要数学坐标系(左下角为原点):
/// 初始化坐标系
void _setupCoordinateSystem(Canvas canvas, Size size) {
// 🎯 关键操作:将坐标系原点移到左下角
canvas.translate(0, size.height);
// 预留边距,给文字标签留空间
final double margin = 30.0;
canvas.translate(margin, -margin);
// 计算实际绘制区域
chartWidth = size.width - 2 * margin;
chartHeight = size.height - 2 * margin;
}
/// 绘制坐标轴
void _drawAxis(Canvas canvas, Size size) {
final Paint axisPaint = Paint()
..color = Colors.grey[600]!
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final Path axisPath = Path();
// X轴:从左到右的水平线
axisPath.moveTo(0, 0);
axisPath.lineTo(chartWidth, 0);
// Y轴:从下到上的垂直线
axisPath.moveTo(0, 0);
axisPath.lineTo(0, -chartHeight);
canvas.drawPath(axisPath, axisPaint);
}
运行效果: 一个简洁的 L 形坐标轴出现了:
需求2:老板说"加点网格线,看起来专业点"
场景: 老板看了坐标轴后说:"嗯,不错,但是加点网格线吧,这样看数据更清楚。"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size); // 新增:绘制网格线
}
void _drawGrid(Canvas canvas, Size size) {
final Paint gridPaint = Paint()
..color = Colors.grey[300]!.withOpacity(0.5)
..strokeWidth = 0.5;
// Y轴网格线(水平线)
final int ySteps = 5;
for (int i = 1; i <= ySteps; i++) {
final double y = (chartHeight / ySteps) * i;
canvas.drawLine(
Offset(0, y),
Offset(chartWidth, y),
gridPaint,
);
}
// X轴网格线(垂直线)
final double xStep = chartWidth / xData.length;
for (int i = 1; i < xData.length; i++) {
final double x = xStep * i;
canvas.drawLine(Offset(x, 0), Offset(x, -chartHeight), gridPaint);
}
}
运行效果: 图表有了专业的网格背景!
需求3:老板说"把数据用柱子表示出来"
场景: 老板兴奋地说:"现在把我们的销售数据画成柱状图,我要看到每个月的业绩!"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size); // 新增:绘制柱状图
}
void _drawBars(Canvas canvas, Size size) {
if (yData.isEmpty) return;
final Paint barPaint = Paint()..style = PaintingStyle.fill;
// 找到最大值,用于计算比例
final double maxValue = yData.reduce((a, b) => a > b ? a : b);
final double barWidth = chartWidth / yData.length * 0.6; // 柱子占60%宽度
final double barSpacing = chartWidth / yData.length;
for (int i = 0; i < yData.length; i++) {
// 计算柱子高度(按比例)
final double barHeight = (yData[i] / maxValue) * chartHeight;
final double x = barSpacing * i + (barSpacing - barWidth) / 2;
// 🎨 根据数值大小设置不同颜色
barPaint.color = _getBarColor(yData[i], maxValue);
// 绘制顶部圆角矩形柱子
final RRect barRect = RRect.fromRectAndCorners(
Rect.fromLTWH(x, 0, barWidth, -barHeight),
topLeft: Radius.circular(4.0),
topRight: Radius.circular(4.0),
);
canvas.drawRRect(barRect, barPaint);
}
}
Color _getBarColor(double value, double maxValue) {
final double ratio = value / maxValue;
if (ratio > 0.8) return Colors.red[400]!; // 高业绩:红色
if (ratio > 0.5) return Colors.orange[400]!; // 中等业绩:橙色
return Colors.green[400]!; // 低业绩:绿色
}
运行效果: 彩色的柱状图出现了,不同高度的柱子代表不同的销售额!
需求4:老板说"我看不懂这些柱子代表什么,加上标签"
场景: 老板皱着眉头说:"这些柱子很漂亮,但我不知道哪个是1月,哪个是2月,还有具体数值是多少。"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size);
_drawLabels(canvas, size); // 新增:绘制标签
}
void _drawLabels(Canvas canvas, Size size) {
final TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
// X轴标签(月份,这里按 yData 下标来算)
final double barSpacing = chartWidth / yData.length;
for (int i = 0; i < yData.length; i++) {
textPainter.text = TextSpan(
text: "${i + 1}",
style: const TextStyle(
color: Colors.black87,
fontSize: 12,
fontWeight: FontWeight.w500,
),
);
textPainter.layout();
// 居中对齐
final double x = barSpacing * i + barSpacing / 2 - textPainter.width / 2;
textPainter.paint(canvas, Offset(x, 10));
}
// Y轴标签(销售额)
final double maxValue = yData.reduce((a, b) => a > b ? a : b);
final int ySteps = 5;
for (int i = 0; i <= ySteps; i++) {
final double value = (maxValue / ySteps) * i;
textPainter.text = TextSpan(
text: '${value.toStringAsFixed(0)}万',
style: const TextStyle(color: Colors.black54, fontSize: 10),
);
textPainter.layout();
final double y = -(chartHeight / ySteps) * i - textPainter.height / 2;
textPainter.paint(canvas, Offset(-textPainter.width - 8, y));
}
}
运行效果: 图表有了清晰的月份标签和销售额刻度!
需求5:老板说"能不能加点动画效果,显得高大上一些"
场景: 老板看着静态图表说:"现在看起来不错,但能不能让柱子从下往上长出来?这样演示给客户看会很酷!"
首先修改 State
类,添加动画控制器:
class _ChartBarState extends State<ChartBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1500), // 1.5秒动画
vsync: this,
);
_animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack, // 回弹效果
),
);
// 启动动画
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width ?? 300,
height: widget.height ?? 200,
child: CustomPaint(
painter: ChartBarPainter(
xData: widget.xData,
yData: widget.yData,
animationValue: _animation.value, // 传递动画值
),
),
);
},
);
}
}
然后在 ChartBarPainter 中使用动画值:
class ChartBarPainter extends CustomPainter {
final double animationValue;
ChartBarPainter({
required this.xData,
required this.yData,
this.animationValue = 1.0,
});
void _drawBars(Canvas canvas, Size size) {
// ... 其他代码保持不变
for (int i = 0; i < yData.length; i++) {
// 🎬 关键:柱子高度乘以动画值
final double barHeight = (yData[i] / maxValue) * chartHeight * animationValue;
// ... 绘制逻辑保持不变
}
}
}
运行效果: 柱子从底部优雅地长出来,带有回弹效果!
需求6:老板说"我想点击柱子看详细信息"
场景: 老板用手指点着屏幕说:"能不能让我点击某个柱子,然后高亮显示,这样我就能重点关注某个月的数据。"
class _ChartBarState extends State<ChartBar>
with SingleTickerProviderStateMixin {
// ... 其他代码保持不变
int? _touchedIndex; // 被点击的柱子索引
void _handleTouch(Offset localPosition) {
// 计算点击位置对应的柱子
final double adjustedX = localPosition.dx - 30; // 减去边距
final double barSpacing = (widget.width ?? 300 - 60) / widget.yData.length;
final int index = (adjustedX / barSpacing).floor();
if (index >= 0 && index < widget.yData.length) {
setState(() {
_touchedIndex = index;
});
// 🎉 可以在这里添加回调,通知外部组件
widget.onBarTap?.call(index);
//print('点击了${widget.yData[index]},销售额:${widget.yData[index]}万');
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (details) {
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset localPosition = box.globalToLocal(details.globalPosition);
_handleTouch(localPosition);
},
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width ?? 300,
height: widget.height ?? 200,
child: CustomPaint(
painter: ChartBarPainter(
xData: widget.xData,
yData: widget.yData,
animationValue: _animation.value, // 传递动画值
touchedIndex: _touchedIndex,
),
),
);
},
),
);
}
}
在 ChartBarPainter 中处理高亮效果:
class ChartBarPainter extends CustomPainter {
final int? touchedIndex;
ChartBarPainter({
required this.xData,
required this.yData,
this.animationValue = 1.0,
this.touchedIndex,
});
void _drawBars(Canvas canvas, Size size) {
// ... 其他代码保持不变
for (int i = 0; i < yData.length; i++) {
// 计算柱子高度(按比例)
final double barHeight =
(yData[i] / maxValue) * chartHeight * animationValue;
final double x = barSpacing * i + (barSpacing - barWidth) / 2;
RRect barRect;
// 🎨 被点击的柱子特殊处理
if (touchedIndex == i) {
barPaint.color = Colors.blue[600]!; // 高亮颜色
// 可以让被点击的柱子稍微宽一点
barRect = RRect.fromRectAndCorners(
Rect.fromLTWH(x - 2, 0, barWidth + 4, -barHeight),
topLeft: Radius.circular(4.0),
topRight: Radius.circular(4.0),
);
} else {
// 🎨 根据数值大小设置不同颜色
barPaint.color = _getBarColor(yData[i], maxValue);
// 绘制顶部圆角矩形柱子
barRect = RRect.fromRectAndCorners(
Rect.fromLTWH(x, 0, barWidth, -barHeight),
topLeft: Radius.circular(4.0),
topRight: Radius.circular(4.0),
);
}
canvas.drawRRect(barRect, barPaint);
}
}
运行效果: 点击柱子会高亮显示,用户体验有所提升!
需求7:老板说"我想要柱子随手指滑动更新"
场景: 老板看着柱状图说:"柱状图很好,但我还想随手指滑动显示详细点信息,另外手指抬起时就恢复之前的样子"
class _ChartBarState extends State<ChartBar>
with SingleTickerProviderStateMixin {
// ... 其他代码保持不变
/// 更新触摸的index
void _updateTouchedIndex(BuildContext context, Offset position) {
RenderBox box = context.findRenderObject() as RenderBox;
Offset localPosition = box.globalToLocal(position);
_handleTouch(localPosition);
}
/// 手指离开时: 点击、触摸结束
void _onBarTapFinish() {
setState(() {
_touchedIndex = null;
});
widget.onBarTap?.call(-1);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (details) =>
_updateTouchedIndex(context, details.globalPosition),
onTapUp: (details) => _onBarTapFinish(),
onTapCancel: () => _onBarTapFinish(),
onHorizontalDragUpdate: (details) =>
_updateTouchedIndex(context, details.globalPosition),
onHorizontalDragEnd: (details) => _onBarTapFinish(),
onHorizontalDragCancel: () => _onBarTapFinish(),
child: AnimatedBuilder(...),
);
}
}
运行效果: 柱子会随手指滑动高亮显示,用户体验大大提升!
需求8:老板说"我还想看趋势,加条折线图"
场景: 老板看着柱状图说:"柱状图很好,但我还想看销售趋势,能不能在上面加一条线?"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size);
_drawTrendLine(canvas, size); // 新增:绘制趋势线
_drawLabels(canvas, size);
}
void _drawTrendLine(Canvas canvas, Size size) {
if (yData.length < 2) return;
final Paint linePaint = Paint()
..color = Colors.deepPurple
..strokeWidth = 3.0
..style = PaintingStyle.stroke;
final Paint pointPaint = Paint()
..color = Colors.deepPurple
..style = PaintingStyle.fill;
final Path linePath = Path();
final double maxValue = yData.reduce((a, b) => a > b ? a : b);
final double barSpacing = chartWidth / yData.length;
// 计算第一个点的位置
final double firstX = barSpacing / 2;
final double firstY = (yData[0] / maxValue) * chartHeight * animationValue;
linePath.moveTo(firstX, -firstY);
// 连接其他点
for (int i = 1; i < yData.length; i++) {
final double x = barSpacing * i + barSpacing / 2;
final double y = (yData[i] / maxValue) * chartHeight * animationValue;
linePath.lineTo(x, -y);
}
// 绘制线条
canvas.drawPath(linePath, linePaint);
// 绘制数据点
for (int i = 0; i < yData.length; i++) {
final double x = barSpacing * i + barSpacing / 2;
final double y = (yData[i] / maxValue) * chartHeight * animationValue;
canvas.drawCircle(Offset(x, -y), 4.0, pointPaint);
// 白色内圈
canvas.drawCircle(Offset(x, -y), 2.0, Paint()..color = Colors.white);
}
}
运行效果: 图表既有柱状图显示具体数值,又有折线图显示趋势!
需求9:老板说"能不能让线条更平滑一些"
场景: 老板说:"折线图不错,但能不能让线条更平滑,像那些高端的商业图表一样?"
void _drawSmoothTrendLine(Canvas canvas, Size size) {
if (yData.length < 2) return;
final Paint linePaint = Paint()
..color = Colors.deepPurple
..strokeWidth = 3.0
..style = PaintingStyle.stroke;
// 收集所有数据点
final List<Offset> points = [];
final double maxValue = yData.reduce((a, b) => a > b ? a : b);
final double barSpacing = chartWidth / yData.length;
for (int i = 0; i < yData.length; i++) {
final double x = barSpacing * i + barSpacing / 2;
final double y = (yData[i] / maxValue) * chartHeight * animationValue;
points.add(Offset(x, -y));
}
// 使用贝塞尔曲线创建平滑路径
final Path smoothPath = Path();
_createSmoothPath(smoothPath, points);
canvas.drawPath(smoothPath, linePaint);
// 绘制数据点(保持不变)
for (final point in points) {
canvas.drawCircle(point, 4.0, Paint()..color = Colors.deepPurple);
canvas.drawCircle(point, 2.0, Paint()..color = Colors.white);
}
}
void _createSmoothPath(Path path, List<Offset> points) {
if (points.length < 2) return;
path.moveTo(points[0].dx, points[0].dy);
// 使用二次贝塞尔曲线连接点
for (int i = 1; i < points.length; i++) {
final Offset current = points[i];
final Offset previous = points[i - 1];
// 控制点在两点中间
final double controlPointX = previous.dx + (current.dx - previous.dx) / 2;
path.quadraticBezierTo(
controlPointX, previous.dy, // 控制点
current.dx, current.dy, // 终点
);
}
}
运行效果: 优雅的曲线让图表看起来更加专业!
需求10:老板说"我想看到数据的范围区间"
场景: 老板拿着更复杂的数据说:"有时候我们的数据不是单一值,而是一个范围,比如销售额在20-30万之间,能支持这种显示吗?"
// 首先定义范围数据结构
class RangeData {
final double min;
final double max;
const RangeData({required this.min, required this.max});
}
// 修改ChartBar支持范围数据
class ChartBar extends StatefulWidget {
final double? width;
final double? height;
final List<String> xData;
final List<double> yData; // 单一数据
final List<RangeData> yRangeData; // 范围数据
final bool isRangeMode; // 是否为范围模式
final Function(int index)? onBarTap; // 添加bar点击事件回调
const ChartBar({
super.key,
this.width,
this.height,
required this.xData,
this.yData = const [],
this.yRangeData = const [],
this.isRangeMode = false,
this.onBarTap,
});
@override
State<ChartBar> createState() => _ChartBarState();
}
// 在ChartPainter中绘制范围柱状图
void _drawRangeBars(Canvas canvas, Size size) {
if (yRangeData.isEmpty) return;
final Paint barPaint = Paint()..style = PaintingStyle.fill;
// 计算最大值(包括所有范围的最大值)
final double maxValue = yRangeData
.map((data) => data.max)
.reduce((a, b) => a > b ? a : b);
final double barWidth = chartWidth / yRangeData.length * 0.6;
final double barSpacing = chartWidth / yRangeData.length;
for (int i = 0; i < yRangeData.length; i++) {
final RangeData data = yRangeData[i];
final double minHeight =
-(data.min / maxValue) * chartHeight * animationValue;
final double maxHeight =
-(data.max / maxValue) * chartHeight * animationValue;
final double x = barSpacing * i + (barSpacing - barWidth) / 2;
// 绘制范围柱(从min到max)
barPaint.color = touchedIndex == i
? Colors.blue[600]!
: _getBarColor(data.max, maxValue);
final RRect rangeRect = RRect.fromRectAndRadius(
Rect.fromLTWH(x, minHeight, barWidth, maxHeight - minHeight),
const Radius.circular(4.0),
);
canvas.drawRRect(rangeRect, barPaint);
}
运行效果: 图表可以显示数据范围,每个柱子代表一个区间!
需求11:老板说"最后加个警戒线,超过目标就显示红色"
场景: 老板最后说:"我们有销售目标,比如40万,超过这个线就用红色警告,这样一眼就能看出哪个月超标了。"
class ChartBar extends StatefulWidget {
// ... 其他参数
final double? warningLine; // 警戒线数值
final Color warningColor; // 警戒线颜色
const ChartBar({
super.key,
// ... 其他参数
this.warningLine,
this.warningColor = Colors.red,
});
}
// 在ChartPainter中绘制警戒线
void _drawWarningLine(Canvas canvas, Size size) {
if (warningLine == null) return;
final double maxValue = yData.reduce((a, b) => a > b ? a : b) ?? 0;
if (warningLine! > maxValue) return; // 警戒线超出范围就不显示
final double warningY = (warningLine! / maxValue) * chartHeight;
// 绘制虚线警戒线
final Paint warningPaint = Paint()
..color = warningColor
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
_drawDashedLine(
canvas,
Offset(0, -warningY),
Offset(chartWidth, -warningY),
warningPaint,
);
// 绘制警戒线标签
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: '目标线: ${warningLine!.toStringAsFixed(0)}万',
style: TextStyle(
color: warningColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(chartWidth - textPainter.width, -warningY - 30),
);
}
void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
const double dashWidth = 5.0;
const double dashSpace = 3.0;
double distance = (end - start).distance;
double dashCount = (distance / (dashWidth + dashSpace)).floor().toDouble();
for (int i = 0; i < dashCount; i++) {
double startX =
start.dx +
(end.dx - start.dx) * (i * (dashWidth + dashSpace)) / distance;
double endX =
start.dx +
(end.dx - start.dx) *
(i * (dashWidth + dashSpace) + dashWidth) /
distance;
double startY =
start.dy +
(end.dy - start.dy) * (i * (dashWidth + dashSpace)) / distance;
double endY =
start.dy +
(end.dy - start.dy) *
(i * (dashWidth + dashSpace) + dashWidth) /
distance;
canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
}
}
// 修改柱子颜色逻辑,超过警戒线的显示警告色
Color _getBarColor(double value, double maxValue) {
if (warningLine != null && value > warningLine!) {
return warningColor; // 超过警戒线用警告色
}
final double ratio = value / maxValue;
if (ratio > 0.8) return Colors.red[400]!;
if (ratio > 0.5) return Colors.orange[400]!;
return Colors.green[400]!;
}
最终效果: 一个功能完整的图表组件诞生了!它有坐标轴、网格、柱状图、折线图、动画、交互、范围显示和警戒线。
完整的 paint
方法
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size);
if (isRangeMode) {
_drawRangeBars(canvas, size);
} else {
_drawBars(canvas, size);
}
_drawTrendLine(canvas, size);
_drawWarningLine(canvas, size);
_drawLabels(canvas, size);
}
通过这种需求驱动的方式,我们一步步构建了一个功能丰富的图表组件。
实际的业务中可以根据实际问题进行扩展,比如扩展成下面的具有分段式效果:
4. 注意事项
性能优化 & 内存管理
- 重绘优化
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) {
return oldDelegate.yData != yData ||
oldDelegate.xData != xData ||
oldDelegate.touchedIndex != touchedIndex ||
oldDelegate.animationValue != animationValue;
}
- 缓存优化
class ChartPainter extends CustomPainter {
Path? _cachedAxisPath;
Path? _cachedGridPath;
Path get axisPath {
if (_cachedAxisPath == null) {
_cachedAxisPath = Path();
_buildAxisPath(_cachedAxisPath!);
}
return _cachedAxisPath!;
}
void _buildAxisPath(Path path) {
// 构建坐标轴路径
}
}
- 分层绘制
class LayeredChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 使用 saveLayer 进行分层绘制
canvas.saveLayer(Offset.zero & size, Paint());
_drawBackground(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size);
_drawOverlay(canvas, size);
canvas.restore();
}
}
- 内存管理
class ChartPainter extends CustomPainter {
// 使用对象池避免频繁创建对象
static final Paint _paintPool = Paint();
static final Path _pathPool = Path();
@override
void paint(Canvas canvas, Size size) {
// 重用Paint对象
_paintPool
..color = Colors.blue
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
// 重用Path对象
_pathPool.reset();
_pathPool.moveTo(0, 0);
// ...
}
}
5. 小结
通过本文的介绍,从零开始实现了一个功能相对完整的 ChartBar 组件。
- 核心要点
-
坐标系统变换:理解 Flutter 坐标系统,掌握
canvas
变换方法; - 绘制基础:掌握 Paint、Path、Canvas 的使用;
- 文本绘制:学会使用 TextPainter 绘制文本;
- 动画集成:将动画与自定义绘制结合;
- 交互处理:实现触摸交互和手势识别。
- 最佳实践
-
性能优化:合理使用
shouldRepaint
,避免不必要的重绘; - 代码组织:将复杂的绘制逻辑拆分成小方法;
- 参数设计:提供丰富的配置选项,提高组件复用性;
- 错误处理:对异常数据进行处理,提高组件健壮性。
- 进阶方向
- 3D效果:使用 Matrix4 实现 3D 变换效果;
-
复杂动画:结合多个
AnimationController
实现复杂动画; - 数据驱动:支持实时数据更新和流式数据;
-
主题适配:支持
Material Design
和Cupertino
主题等。
Flutter 自定义 View 和 Android 中的 canvas 很相似,掌握后能够实现非常灵活和高性能的UI效果。
参考资料: