Flutter之旅 -- 自定義組件

本篇文章主要介紹以下幾個內(nèi)容:

  • Flutter 自定義組件
  • 與 Android/iOS 原生自定義組件對比
  • 從零到一實現(xiàn)一個圖表 ChartBar 組件示例
Flutter之旅

在 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 提供了多種自定義繪制的方式:

  1. CustomPainter + CustomPaint:最常用的方式,適合復(fù)雜繪制;
  2. RenderObject:更底層的實現(xiàn),性能更好但復(fù)雜度更高;
  3. 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)效果如下:

圓形進(jì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):

  1. Skia 引擎時代(Flutter 1.0 - 3.x)
  • 基于 Google 的 Skia 2D圖形庫;
  • 運行時編譯著色器,可能導(dǎo)致首次渲染卡頓;
  • 成熟穩(wěn)定,但在某些場景下性能有限制。
  1. 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ù)雜的視覺效果。
  1. 使用建議
# 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)了:

坐標(biāo)軸

需求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)格背景!

網(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è)!

折線圖2

需求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 組件。

  • 核心要點
  1. 坐標(biāo)系統(tǒng)變換:理解 Flutter 坐標(biāo)系統(tǒng),掌握 canvas 變換方法;
  2. 繪制基礎(chǔ):掌握 Paint、Path、Canvas 的使用;
  3. 文本繪制:學(xué)會使用 TextPainter 繪制文本;
  4. 動畫集成:將動畫與自定義繪制結(jié)合;
  5. 交互處理:實現(xiàn)觸摸交互和手勢識別。
  • 最佳實踐
  1. 性能優(yōu)化:合理使用 shouldRepaint,避免不必要的重繪;
  2. 代碼組織:將復(fù)雜的繪制邏輯拆分成小方法;
  3. 參數(shù)設(shè)計:提供豐富的配置選項,提高組件復(fù)用性;
  4. 錯誤處理:對異常數(shù)據(jù)進(jìn)行處理,提高組件健壯性。
  • 進(jìn)階方向
  1. 3D效果:使用 Matrix4 實現(xiàn) 3D 變換效果;
  2. 復(fù)雜動畫:結(jié)合多個 AnimationController 實現(xiàn)復(fù)雜動畫;
  3. 數(shù)據(jù)驅(qū)動:支持實時數(shù)據(jù)更新和流式數(shù)據(jù);
  4. 主題適配:支持 Material DesignCupertino 主題等。

Flutter 自定義 View 和 Android 中的 canvas 很相似,掌握后能夠?qū)崿F(xiàn)非常靈活和高性能的UI效果。


參考資料:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容