本篇文章主要介紹以下幾個內(nèi)容:
- Flutter 自定義組件
- 與 Android/iOS 原生自定義組件對比
- 從零到一實現(xiàn)一個圖表 ChartBar 組件示例

在 Flutter 開發(fā)中,雖然官方提供了豐富的 Widget 組件,但在實際項目中,我們經(jīng)常會遇到需要自定義繪制的場景。
本文將以一個功能完整的 ChartBar 圖表組件為例,從 0 到 1 展示 Flutter 自定義 View 的實現(xiàn)方法。
1. Flutter 自定義組件
1.1 場景
在以下場景中,通常需要自定義組件:
- 復(fù)雜圖形繪制:如圖表、儀表盤、自定義進(jìn)度條等;
-
特殊交互效果:現(xiàn)有
Widget無法滿足的交互需求; -
性能優(yōu)化:減少
Widget樹層級,提升渲染性能; - 品牌定制:實現(xiàn)獨特的 UI 設(shè)計風(fēng)格;
- 動畫效果:復(fù)雜的自定義動畫實現(xiàn)。
1.2 實現(xiàn)方式
Flutter 提供了多種自定義繪制的方式:
- CustomPainter + CustomPaint:最常用的方式,適合復(fù)雜繪制;
- RenderObject:更底層的實現(xiàn),性能更好但復(fù)雜度更高;
- Canvas 直接繪制:在特定場景下使用。
本文主要講解 CustomPainter 的實現(xiàn)方式。
2. Flutter vs 原生自定義組件對比
| 特性 | Flutter | Android View | Android Compose | iOS |
|---|---|---|---|---|
| 繪制API | Canvas + Paint | Canvas + Paint | DrawScope + DrawContext | Core Graphics |
| 坐標(biāo)系統(tǒng) | 左上角原點 | 左上角原點 | 左上角原點 | 左下角原點 |
| 性能 | Impeller/Skia引擎,高性能 | 硬件加速 | 硬件加速 | Core Animation |
| 跨平臺 | 一套代碼多平臺 | 僅Android | 僅Android | 僅iOS |
| 學(xué)習(xí)成本 | 中等 | 中等 | 較低 | 較高 |
| 開發(fā)方式 | 聲明式 | 命令式 | 聲明式 | 命令式 |
| 狀態(tài)管理 | StatefulWidget | 手動管理 | 自動重組 | 手動管理 |
2.1 詳細(xì)對比分析
以下通過一個簡單的自定義圓形進(jìn)度條示例,展示各平臺的自定義繪制實現(xiàn)方式。
實現(xiàn)效果如下:

- Flutter CustomPainter
import 'dart:math' as math;
// 自定義圓形進(jìn)度條組件
class CircularProgressPainter extends CustomPainter {
final double progress; // 進(jìn)度值 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;
// 繪制背景圓環(huán)
final backgroundPaint = Paint()
..color = Colors.grey[300]!
..strokeWidth = 8.0
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, backgroundPaint);
// 繪制進(jìn)度圓弧
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, // 根據(jù)進(jìn)度計算弧度
false,
progressPaint,
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
// 使用方式
CustomPaint(
size: Size(100, 100),
painter: CircularProgressPainter(0.7), // 70%進(jìn)度
)
- Android 傳統(tǒng) View
// 自定義圓形進(jìn)度條View
class CircularProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0f // 進(jìn)度值 0.0-1.0
// 背景圓環(huán)畫筆
private val backgroundPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 24f
style = Paint.Style.STROKE
isAntiAlias = true
}
// 進(jìn)度圓弧畫筆
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
// 繪制背景圓環(huán)
it.drawCircle(centerX, centerY, radius, backgroundPaint)
// 繪制進(jìn)度圓弧
val rect = RectF(
centerX - radius, centerY - radius,
centerX + radius, centerY + radius
)
it.drawArc(rect, -90f, 360f * progress, false, progressPaint)
}
}
// 設(shè)置進(jìn)度的方法
fun setProgress(progress: Float) {
this.progress = progress.coerceIn(0f, 1f)
invalidate() // 觸發(fā)重繪
}
}
- Android Compose
// 自定義圓形進(jìn)度條Composable
@Composable
fun CircularProgressIndicator(
progress: Float, // 進(jìn)度值 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()
// 繪制背景圓環(huán)
drawCircle(
color = Color.LightGray,
radius = radius,
center = center,
style = Stroke(width = 8.dp.toPx())
)
// 繪制進(jìn)度圓弧
drawArc(
color = Color.Blue,
startAngle = -90f, // 從頂部開始
sweepAngle = 360f * progress, // 根據(jù)進(jìn)度計算角度
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%進(jìn)度
- iOS Core Graphics
// 自定義圓形進(jìn)度條UIView
class CircularProgressView: UIView {
var progress: CGFloat = 0.0 {
didSet {
setNeedsDisplay() // 觸發(fā)重繪
}
}
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
// 繪制背景圓環(huán)
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()
// 繪制進(jìn)度圓弧
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 // 設(shè)置70%進(jìn)度
代碼對比總結(jié)
| 平臺 | 代碼復(fù)雜度 | 狀態(tài)管理 | 重繪機制 | 類型安全 |
|---|---|---|---|---|
| Flutter | 中等,聲明式清晰 | 自動管理 |
shouldRepaint 控制 |
Dart 類型安全 |
| Android View | 較復(fù)雜,需手動管理 | 手動 invalidate()
|
onDraw 重寫 |
Kotlin 類型安全 |
| Android Compose | 簡潔,聲明式 | 自動重組 | 狀態(tài)變化自動重繪 | Kotlin 類型安全 |
| iOS | 復(fù)雜,C風(fēng)格API | 手動 setNeedsDisplay()
|
draw 方法重寫 |
Swift 類型安全 |
2.2 各平臺特色對比
Flutter 的優(yōu)勢
- 一致性:一套代碼,多平臺完全一致的渲染效果;
- 熱重載:開發(fā)效率極高,實時預(yù)覽效果;
- 豐富的動畫 API:內(nèi)置強大的動畫系統(tǒng);;
- 聲明式 UI:代碼更簡潔,狀態(tài)管理更清晰;
- Impeller 渲染引擎:新一代高性能 2D 圖形渲染引擎(iOS默認(rèn),Android可選)。
Android Compose 的優(yōu)勢
- 現(xiàn)代化:Google 最新推薦的 UI 框架;
- 聲明式:類似 Flutter 的開發(fā)體驗;
- 與 Android 深度集成:可以無縫使用 Android 系統(tǒng)特性;
- 性能優(yōu)化:智能重組,只更新變化的部分;
- 類型安全:Kotlin 的類型系統(tǒng)提供更好的安全性。
Android 傳統(tǒng) View 的優(yōu)勢
- 成熟穩(wěn)定:經(jīng)過多年驗證,生態(tài)完善;
- 系統(tǒng)集成:與Android系統(tǒng)緊密集成;
- 性能可控:可以精確控制繪制過程;
- 豐富的系統(tǒng)API:可以直接使用所有 Android API。
iOS 的優(yōu)勢
- 系統(tǒng)原生:與 iOS 系統(tǒng)完美集成;
- 性能極致:充分利用硬件特性;
- 設(shè)計一致性:符合 Apple 設(shè)計規(guī)范;
- Core Animation:強大的動畫和圖形處理能力。
選擇建議
| 場景 | 推薦方案 | 理由 |
|---|---|---|
| 跨平臺應(yīng)用 | Flutter | 一套代碼,維護(hù)成本低 |
| Android 專屬復(fù)雜 UI | Compose | 現(xiàn)代化,與系統(tǒng)深度集成 |
| 需要系統(tǒng)底層 API | 原生View | 可以使用所有平臺特性 |
| 快速原型開發(fā) | Flutter | 熱重載,開發(fā)效率高 |
| 性能要求極致 | 原生 View | 可以做到極致優(yōu)化 |
| 團(tuán)隊技術(shù)棧 | 根據(jù)團(tuán)隊熟悉度選擇 | 學(xué)習(xí)成本和開發(fā)效率平衡 |
2.3 Flutter 渲染引擎演進(jìn)
Flutter 的渲染引擎經(jīng)歷了重要演進(jìn):
- Skia 引擎時代(Flutter 1.0 - 3.x)
- 基于 Google 的 Skia 2D圖形庫;
- 運行時編譯著色器,可能導(dǎo)致首次渲染卡頓;
- 成熟穩(wěn)定,但在某些場景下性能有限制。
- Impeller 引擎時代(Flutter 3.10+)
- iOS平臺:Flutter 3.10+ 默認(rèn)啟用 Impeller;
- Android平臺:Flutter 3.16+ 可選啟用,預(yù)計未來版本默認(rèn)啟用;
-
核心優(yōu)勢:
- 預(yù)編譯著色器,消除
shader compilation jank; - 更好的 GPU 利用率和渲染性能;
- 專為 Flutter 優(yōu)化設(shè)計;
- 支持更復(fù)雜的視覺效果。
- 預(yù)編譯著色器,消除
- 使用建議
# flutter/android/app/src/main/AndroidManifest.xml
# 在Android上啟用Impeller(實驗性)
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
對于自定義繪制而言,Impeller 帶來的主要改進(jìn):
- 更流暢的動畫:預(yù)編譯著色器消除卡頓;
- 更好的復(fù)雜圖形性能:優(yōu)化的 GPU 渲染管線;
- 一致的跨平臺體驗:統(tǒng)一的渲染行為。
3. 自定義ChartBar 組件實戰(zhàn)
想象一下,你是一名數(shù)據(jù)分析師,老板給你一堆銷售數(shù)據(jù),要求你做一個漂亮的圖表展示。
下面跟隨這個場景,一步步實現(xiàn)一個功能完整的 ChartBar 組件。
需求1:老板說"先給我畫個坐標(biāo)軸出來看看"
場景: 老板拿著一張白紙說:"你先給我畫個坐標(biāo)軸,X 軸放月份,Y 軸放銷售額。"
好的,先搭建基礎(chǔ)框架:
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),
),
);
}
}
然后創(chuàng)建我們的畫師(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);
// 老板的第一個需求:畫坐標(biāo)軸
_drawAxis(canvas, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
關(guān)鍵技巧:坐標(biāo)系變換
Flutter 的坐標(biāo)系原點在左上角,但我們的圖表需要數(shù)學(xué)坐標(biāo)系(左下角為原點):
/// 初始化坐標(biāo)系
void _setupCoordinateSystem(Canvas canvas, Size size) {
// ?? 關(guān)鍵操作:將坐標(biāo)系原點移到左下角
canvas.translate(0, size.height);
// 預(yù)留邊距,給文字標(biāo)簽留空間
final double margin = 30.0;
canvas.translate(margin, -margin);
// 計算實際繪制區(qū)域
chartWidth = size.width - 2 * margin;
chartHeight = size.height - 2 * margin;
}
/// 繪制坐標(biāo)軸
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 形坐標(biāo)軸出現(xiàn)了:

需求2:老板說"加點網(wǎng)格線,看起來專業(yè)點"
場景: 老板看了坐標(biāo)軸后說:"嗯,不錯,但是加點網(wǎng)格線吧,這樣看數(shù)據(jù)更清楚。"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size); // 新增:繪制網(wǎng)格線
}
void _drawGrid(Canvas canvas, Size size) {
final Paint gridPaint = Paint()
..color = Colors.grey[300]!.withOpacity(0.5)
..strokeWidth = 0.5;
// Y軸網(wǎng)格線(水平線)
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軸網(wǎng)格線(垂直線)
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);
}
}
運行效果: 圖表有了專業(yè)的網(wǎng)格背景!

需求3:老板說"把數(shù)據(jù)用柱子表示出來"
場景: 老板興奮地說:"現(xiàn)在把我們的銷售數(shù)據(jù)畫成柱狀圖,我要看到每個月的業(yè)績!"
@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;
// ?? 根據(jù)數(shù)值大小設(shè)置不同顏色
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]!; // 高業(yè)績:紅色
if (ratio > 0.5) return Colors.orange[400]!; // 中等業(yè)績:橙色
return Colors.green[400]!; // 低業(yè)績:綠色
}
運行效果: 彩色的柱狀圖出現(xiàn)了,不同高度的柱子代表不同的銷售額!

需求4:老板說"我看不懂這些柱子代表什么,加上標(biāo)簽"
場景: 老板皺著眉頭說:"這些柱子很漂亮,但我不知道哪個是1月,哪個是2月,還有具體數(shù)值是多少。"
@override
void paint(Canvas canvas, Size size) {
_setupCoordinateSystem(canvas, size);
_drawAxis(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size);
_drawLabels(canvas, size); // 新增:繪制標(biāo)簽
}
void _drawLabels(Canvas canvas, Size size) {
final TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
);
// X軸標(biāo)簽(月份,這里按 yData 下標(biāo)來算)
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軸標(biāo)簽(銷售額)
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));
}
}
運行效果: 圖表有了清晰的月份標(biāo)簽和銷售額刻度!

需求5:老板說"能不能加點動畫效果,顯得高大上一些"
場景: 老板看著靜態(tài)圖表說:"現(xiàn)在看起來不錯,但能不能讓柱子從下往上長出來?這樣演示給客戶看會很酷!"
首先修改 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++) {
// ?? 關(guān)鍵:柱子高度乘以動畫值
final double barHeight = (yData[i] / maxValue) * chartHeight * animationValue;
// ... 繪制邏輯保持不變
}
}
}
運行效果: 柱子從底部優(yōu)雅地長出來,帶有回彈效果!

需求6:老板說"我想點擊柱子看詳細(xì)信息"
場景: 老板用手指點著屏幕說:"能不能讓我點擊某個柱子,然后高亮顯示,這樣我就能重點關(guān)注某個月的數(shù)據(jù)。"
class _ChartBarState extends State<ChartBar>
with SingleTickerProviderStateMixin {
// ... 其他代碼保持不變
int? _touchedIndex; // 被點擊的柱子索引
void _handleTouch(Offset localPosition) {
// 計算點擊位置對應(yīng)的柱子
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;
});
// ?? 可以在這里添加回調(diào),通知外部組件
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 {
// ?? 根據(jù)數(shù)值大小設(shè)置不同顏色
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:老板說"我想要柱子隨手指滑動更新"
場景: 老板看著柱狀圖說:"柱狀圖很好,但我還想隨手指滑動顯示詳細(xì)點信息,另外手指抬起時就恢復(fù)之前的樣子"
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);
}
/// 手指離開時: 點擊、觸摸結(jié)束
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);
// 繪制數(shù)據(jù)點
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);
// 白色內(nèi)圈
canvas.drawCircle(Offset(x, -y), 2.0, Paint()..color = Colors.white);
}
}
運行效果: 圖表既有柱狀圖顯示具體數(shù)值,又有折線圖顯示趨勢!

需求9:老板說"能不能讓線條更平滑一些"
場景: 老板說:"折線圖不錯,但能不能讓線條更平滑,像那些高端的商業(yè)圖表一樣?"
void _drawSmoothTrendLine(Canvas canvas, Size size) {
if (yData.length < 2) return;
final Paint linePaint = Paint()
..color = Colors.deepPurple
..strokeWidth = 3.0
..style = PaintingStyle.stroke;
// 收集所有數(shù)據(jù)點
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));
}
// 使用貝塞爾曲線創(chuàng)建平滑路徑
final Path smoothPath = Path();
_createSmoothPath(smoothPath, points);
canvas.drawPath(smoothPath, linePaint);
// 繪制數(shù)據(jù)點(保持不變)
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, // 終點
);
}
}
運行效果: 優(yōu)雅的曲線讓圖表看起來更加專業(yè)!

需求10:老板說"我想看到數(shù)據(jù)的范圍區(qū)間"
場景: 老板拿著更復(fù)雜的數(shù)據(jù)說:"有時候我們的數(shù)據(jù)不是單一值,而是一個范圍,比如銷售額在20-30萬之間,能支持這種顯示嗎?"
// 首先定義范圍數(shù)據(jù)結(jié)構(gòu)
class RangeData {
final double min;
final double max;
const RangeData({required this.min, required this.max});
}
// 修改ChartBar支持范圍數(shù)據(jù)
class ChartBar extends StatefulWidget {
final double? width;
final double? height;
final List<String> xData;
final List<double> yData; // 單一數(shù)據(jù)
final List<RangeData> yRangeData; // 范圍數(shù)據(jù)
final bool isRangeMode; // 是否為范圍模式
final Function(int index)? onBarTap; // 添加bar點擊事件回調(diào)
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);
}
運行效果: 圖表可以顯示數(shù)據(jù)范圍,每個柱子代表一個區(qū)間!

需求11:老板說"最后加個警戒線,超過目標(biāo)就顯示紅色"
場景: 老板最后說:"我們有銷售目標(biāo),比如40萬,超過這個線就用紅色警告,這樣一眼就能看出哪個月超標(biāo)了。"
class ChartBar extends StatefulWidget {
// ... 其他參數(shù)
final double? warningLine; // 警戒線數(shù)值
final Color warningColor; // 警戒線顏色
const ChartBar({
super.key,
// ... 其他參數(shù)
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,
);
// 繪制警戒線標(biāo)簽
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: '目標(biāo)線: ${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]!;
}
最終效果: 一個功能完整的圖表組件誕生了!它有坐標(biāo)軸、網(wǎng)格、柱狀圖、折線圖、動畫、交互、范圍顯示和警戒線。

完整的 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);
}
通過這種需求驅(qū)動的方式,我們一步步構(gòu)建了一個功能豐富的圖表組件。
實際的業(yè)務(wù)中可以根據(jù)實際問題進(jìn)行擴展,比如擴展成下面的具有分段式效果:

4. 注意事項
性能優(yōu)化 & 內(nèi)存管理
- 重繪優(yōu)化
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) {
return oldDelegate.yData != yData ||
oldDelegate.xData != xData ||
oldDelegate.touchedIndex != touchedIndex ||
oldDelegate.animationValue != animationValue;
}
- 緩存優(yōu)化
class ChartPainter extends CustomPainter {
Path? _cachedAxisPath;
Path? _cachedGridPath;
Path get axisPath {
if (_cachedAxisPath == null) {
_cachedAxisPath = Path();
_buildAxisPath(_cachedAxisPath!);
}
return _cachedAxisPath!;
}
void _buildAxisPath(Path path) {
// 構(gòu)建坐標(biāo)軸路徑
}
}
- 分層繪制
class LayeredChartPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 使用 saveLayer 進(jìn)行分層繪制
canvas.saveLayer(Offset.zero & size, Paint());
_drawBackground(canvas, size);
_drawGrid(canvas, size);
_drawBars(canvas, size);
_drawOverlay(canvas, size);
canvas.restore();
}
}
- 內(nèi)存管理
class ChartPainter extends CustomPainter {
// 使用對象池避免頻繁創(chuàng)建對象
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. 小結(jié)
通過本文的介紹,從零開始實現(xiàn)了一個功能相對完整的 ChartBar 組件。
- 核心要點
-
坐標(biāo)系統(tǒng)變換:理解 Flutter 坐標(biāo)系統(tǒng),掌握
canvas變換方法; - 繪制基礎(chǔ):掌握 Paint、Path、Canvas 的使用;
- 文本繪制:學(xué)會使用 TextPainter 繪制文本;
- 動畫集成:將動畫與自定義繪制結(jié)合;
- 交互處理:實現(xiàn)觸摸交互和手勢識別。
- 最佳實踐
-
性能優(yōu)化:合理使用
shouldRepaint,避免不必要的重繪; - 代碼組織:將復(fù)雜的繪制邏輯拆分成小方法;
- 參數(shù)設(shè)計:提供豐富的配置選項,提高組件復(fù)用性;
- 錯誤處理:對異常數(shù)據(jù)進(jìn)行處理,提高組件健壯性。
- 進(jìn)階方向
- 3D效果:使用 Matrix4 實現(xiàn) 3D 變換效果;
-
復(fù)雜動畫:結(jié)合多個
AnimationController實現(xiàn)復(fù)雜動畫; - 數(shù)據(jù)驅(qū)動:支持實時數(shù)據(jù)更新和流式數(shù)據(jù);
-
主題適配:支持
Material Design和Cupertino主題等。
Flutter 自定義 View 和 Android 中的 canvas 很相似,掌握后能夠?qū)崿F(xiàn)非常靈活和高性能的UI效果。
參考資料: