Android Flutter繪制扇形圖詳解

簡介

在開發過程中通常會遇到一些不規則的UI,比如不規則的線條,多邊形,統計圖表等等,用那些通用組件通過組合的方式無法進行實現,這就需要我們自己進行繪制。可以通過使用CuntomPaint組件並結合畫筆CustomPainter去進行手動繪制各種圖形。

CustomPaint介紹

CustomPaint是一個繼承SingleChildRenderObjectWidget的Widget,這裡主要介紹幾個重要參數:

child:CustomPaint的子組件。

painter: 畫筆,繪制的圖形會顯示在child後面。

foregroundPainter:前景畫筆,繪制的圖形會顯示在child前面。

size:繪制區域大小。

CustomPainter介紹

CustomPainter是一個抽象類,通過自定義一個類繼承自CustomPainter,重寫paintshouldRepaint方法,具體繪制主要在paint方法裡。

paint介紹

主要兩個參數:

Canvas:畫佈,可以用於繪制各種圖形。

Size:繪制區域的大小。

void paint(Canvas canvas, Size size)

shouldRepaint介紹

在Widget重繪前會調用該方法確定時候需要重繪,shouldRepaint返回ture表示需要重繪,返回false表示不需要重繪。

bool shouldRepaint(CustomPainter oldDelegate)

示例

這裡我們通過繪制一個餅狀圖來演示繪制的整體流程。

使用CustomPaint

首先,使用CustomPaint,繪制大小為父組件最大值,傳入自定義painter

@override
Widget build(BuildContext context) {
    return CustomPaint(
      size: Size.infinite,
      painter: PieChartPainter(),
    );
}

自定義Painter

自定義PieChartPainter繼承CustomPainter

class PieChartPainters extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return oldDelegate != this;
  }
}

繪制

接著我們來實現paint方法進行繪制

@override
void paint(Canvas canvas, Size size) {
    //移動到中心點
    canvas.translate(size.width / 2, size.height / 2);
    //繪制餅狀圖
    _drawPie(canvas, size);
    //繪制扇形分割線
    _drawSpaceLine(canvas);
    //繪制中心圓
    _drawHole(canvas, size);
}

繪制餅狀圖

我們以整個畫佈的中點為圓點,然後計算出每個扇形的角度區域,通過canvas.drawArc繪制扇形。

void _drawPie(Canvas canvas, Size size) {
    var startAngle = 0.0;
    var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
    for (var model in models) {
      Paint paint = Paint()
        ..style = PaintingStyle.fill
        ..color = model.color;
      var sweepAngle = model.value / sumValue * 360;
      canvas.drawArc(Rect.fromCircle(radius: model.radius, center: Offset.zero),
          startAngle * pi / 180, sweepAngle * pi / 180, true, paint);

      //為每一個區域繪制延長線和文字
      _drawLineAndText(
          canvas, size, model.radius, startAngle, sweepAngle, model);

      startAngle += sweepAngle;
    }
}

繪制延長線以及文本

延長線的起點為扇形區域邊緣中點位置,長度為一個固定的長度,轉折點坐標通過半徑加這個固定長度和三角函數進行計算,然後通過轉折點的位置決定橫線終點的方向,而橫線的長度則根據文字的寬度決定,然後通過canvas.drawLine進行繪制直線。

文本繪制使用TextPainter.paint進行繪制,paint方法裡面最終是通過canvas.drawParagraph進行繪制的。

最後再在文字的前面通過canvas.drawCircle繪制一個小圓點。

 void _drawLineAndText(Canvas canvas, Size size, double radius,
      double startAngle, double sweepAngle, PieChartModel model) {
    var ratio = (sweepAngle / 360.0 * 100).toStringAsFixed(2);

    var top = Text(model.name);
    var topTextPainter = getTextPainter(top);

    var bottom = Text("$ratio%");
    var bottomTextPainter = getTextPainter(bottom);

    // 繪制橫線
    // 計算開始坐標以及轉折點的坐標
    var startX = radius * (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
    var startY = radius * (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));

    var firstLine = radius / 5;
    var secondLine =
        max(bottomTextPainter.width, topTextPainter.width) + radius / 4;
    var pointX = (radius + firstLine) *
        (cos((startAngle + (sweepAngle / 2)) * (pi / 180)));
    var pointY = (radius + firstLine) *
        (sin((startAngle + (sweepAngle / 2)) * (pi / 180)));

    // 計算坐標在左邊還是在右邊
    // 並計算橫線結束坐標
    // 如果結束坐標超過瞭繪制區域,則改變結束坐標的值
    var marginOffset = 20.0; // 距離繪制邊界的偏移量
    var endX = 0.0;
    if (pointX - startX > 0) {
      endX = min(pointX + secondLine, size.width / 2 - marginOffset);
      secondLine = endX - pointX;
    } else {
      endX = max(pointX - secondLine, -size.width / 2 + marginOffset);
      secondLine = pointX - endX;
    }

    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..strokeWidth = 1
      ..color = Colors.grey;

    // 繪制延長線
    canvas.drawLine(Offset(startX, startY), Offset(pointX, pointY), paint);
    canvas.drawLine(Offset(pointX, pointY), Offset(endX, pointY), paint);

    // 文字距離中間橫線上下間距偏移量
    var offset = 4;
    var textWidth = bottomTextPainter.width;
    var textStartX = 0.0;
    textStartX =
        _calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
    bottomTextPainter.paint(canvas, Offset(textStartX, pointY + offset));

    textWidth = topTextPainter.width;
    var textHeight = topTextPainter.height;
    textStartX =
        _calculateTextStartX(pointX, startX, textWidth, secondLine, textStartX, offset);
    topTextPainter.paint(canvas, Offset(textStartX, pointY - offset - textHeight));

    // 繪制文字前面的小圓點
    paint.color = model.color;
    canvas.drawCircle(
        Offset(textStartX - 8, pointY - 4 - topTextPainter.height / 2),
        4,
        paint);
}

繪制扇形分割線

在繪制完扇形之後,然後在扇形的開始的那條邊上繪制一條直線,起點為圓點,長度為扇形半徑,終點的位置根據半徑和扇形開始的那條邊的角度用三角函數進行計算,然後通過canvas.drawLine進行繪制。

void _drawSpaceLine(Canvas canvas) {
    var sumValue = models.fold<double>(0.0, (sum, model) => sum + model.value);
    var startAngle = 0.0;
    for (var model in models) {
      _drawLine(canvas, startAngle, model.radius);
      startAngle += model.value / sumValue * 360;
    }
}

void _drawLine(Canvas canvas, double angle, double radius) {
    var endX = cos(angle * pi / 180) * radius;
    var endY = sin(angle * pi / 180) * radius;
    Paint paint = Paint()
      ..style = PaintingStyle.fill
      ..color = Colors.white
      ..strokeWidth = spaceWidth;
    canvas.drawLine(Offset.zero, Offset(endX, endY), paint);
  }

繪制內部中心圓

這裡可以通過傳入的參數判斷是否需要繪制這個圓,使用canvas.drawCircle進行繪制一個與背景色一致的圓。

void _drawHole(Canvas canvas, Size size) {
    if (isShowHole) {
      holePath.reset();
      Paint paint = Paint()
        ..style = PaintingStyle.fill
        ..color = Colors.white;
      canvas.drawCircle(Offset.zero, holeRadius, paint);
    }
}

觸摸事件處理

接下來我們來處理點擊事件,當我們點擊某一個扇形區域時,此扇形需要突出顯示,如下圖:

重寫hitTest方法

註意這個方法的返回值決定是否響應事件。

默認情況下返回null,事件不會向下傳遞,也不會進行處理; 如果返回true則當前組件進行處理事件; 如果返回false則當前組件不會響應點擊事件,會向下一層傳遞;

我直接在這裡處理點擊事件,通過該方法傳入的offset確定點擊的位置,如果點擊位置是在圓形區域內並且不在中心圓內則處理事件同時判斷所點擊的具體是哪個扇形,反之則恢復默認狀態。

@override
bool? hitTest(Offset offset) {
    if (oldTapOffset.dx==offset.dx && oldTapOffset.dy==offset.dy) {
      return false;
    }
    oldTapOffset = offset;
    for (int i = 0; i < paths.length; i++) {
      if (paths[i].contains(offset) &&
          !holePath.contains(offset)) {
        onTap?.call(i);
        oldTapOffset = offset;
        return true;
      }
    }
    onTap?.call(-1);
    return false;
}

至此,我們通過onTap向上傳遞出點擊的是第幾個扇形,然後進行處理,更新UI就可以瞭。

動畫實現

這裡通過Widget繼承ImplicitlyAnimatedWidget來實現,ImplicitlyAnimatedWidget是一個抽象類,繼承自StatefulWidget,既然是StatefulWidget那肯定還有一個StateState繼承AnimatedWidgetBaseState(此類繼承自ImplicitlyAnimatedWidgetState),感興趣的小夥伴可以直接去看源碼

實現AnimatedWidgetBaseState裡面的forEachTween方法,主要是用於來更新Tween的初始值。

@override
void forEachTween(TweenVisitor<dynamic>visitor) {
   customPieTween = visitor(customPieTween, end, (dynamic value) {
      return CustomPieTween(begin: value, end: end);
    }) as CustomPieTween;
}

自定義CustomPieTween繼承自Tween,重寫lerp方法,對需要做動畫的參數進行處理

class CustomPieTween extends Tween<List<PieChartModel>> {
  CustomPieTween({List<PieChartModel>? begin, List<PieChartModel>? end})
      : super(begin: begin, end: end);

  @override
  List<PieChartModel> lerp(double t) {
    List<PieChartModel> list = [];
    begin?.asMap().forEach((index, model) {
      list.add(model
        ..radius = lerpDouble(model.radius, end?[index].radius ?? 100.0, t));
    });
    return list;
  }

  double lerpDouble(double radius, double radius2, double t) {
    if (radius == radius2) {
      return radius;
    }
    var d = (radius2 - radius) * t;
    var value = radius + d;
    return value;
  }
}

以上就是Android Flutter繪制扇形圖詳解的詳細內容,更多關於Android Flutter扇形圖的資料請關註WalkonNet其它相關文章!

推薦閱讀: