arc_progress_widget.dart 6.63 KB
import 'dart:math';
import 'package:flutter/material.dart';

/// 弧形进度单环配置
class ArcProgressItem {
  /// 进度比例 (0.0 ~ 1.0,支持超过 1.0,<= 0 或 null 表示无数据/未开始)
  final double? ratio;

  /// 高亮进度颜色
  final Color color;

  /// 底轨背景色
  final Color backgroundColor;

  /// 无数据时的底轨背景色 (若为空则使用 [backgroundColor])
  final Color? noDataBackgroundColor;

  const ArcProgressItem({
    required this.ratio,
    required this.color,
    required this.backgroundColor,
    this.noDataBackgroundColor,
  });
}

/// 华为版开口朝下的彩虹弧形多环进度 Widget(支持平滑动画,无中心文字)
class ArcMultiProgressWidget extends StatefulWidget {
  /// 尺寸宽高 (正方形)
  final double size;

  /// 环形线宽 (如果为 null,将根据环数自适应计算)
  final double? strokeWidth;

  /// 环与环之间的间距 (如果为 null,将根据环数自适应计算)
  final double? gap;

  /// 起始角度 (以度为单位,150° 表示从左下方开始)
  final double startAngleDeg;

  /// 总扫过角度 (以度为单位,240° 表示从左下顺时针扫至右下,底部开口 120°)
  final double sweepTotalDeg;

  /// 环项列表 (从最外环到最内环排序)
  final List<ArcProgressItem> items;

  /// 是否开启动画
  final bool animate;

  /// 动画时长
  final Duration duration;

  /// 动画曲线
  final Curve curve;

  const ArcMultiProgressWidget({
    super.key,
    required this.items,
    this.size = 50,
    this.strokeWidth,
    this.gap,
    this.startAngleDeg = 150,
    this.sweepTotalDeg = 240,
    this.animate = true,
    this.duration = const Duration(milliseconds: 1000),
    this.curve = Curves.easeOutCubic,
  });

  @override
  State<ArcMultiProgressWidget> createState() => _ArcMultiProgressWidgetState();
}

class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  late List<double> _currentProgressAngles;

  List<double> get _targetProgressAngles {
    final totalRad = widget.sweepTotalDeg * pi / 180.0;
    return widget.items.map((item) {
      if (item.ratio == null || item.ratio! <= 0) {
        return 0.0;
      }
      final clampedRatio = min(item.ratio!, 1.0);
      return clampedRatio * totalRad;
    }).toList();
  }

  @override
  void initState() {
    super.initState();
    _currentProgressAngles = List.filled(widget.items.length, 0.0);
    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    final targets = _targetProgressAngles;
    _updateAnimation(_currentProgressAngles, targets);
  }

  @override
  void didUpdateWidget(ArcMultiProgressWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    final oldTargets = oldWidget.items.map((e) => e.ratio ?? 0.0).toList();
    final newTargets = widget.items.map((e) => e.ratio ?? 0.0).toList();
    if (oldTargets.toString() != newTargets.toString()) {
      _updateAnimation(_currentProgressAngles, _targetProgressAngles);
    }
  }

  void _updateAnimation(List<double> fromAngles, List<double> toAngles) {
    if (!widget.animate) {
      setState(() {
        _currentProgressAngles = List.from(toAngles);
      });
      return;
    }

    final startList = List<double>.from(fromAngles);
    // 确保长度一致
    while (startList.length < toAngles.length) {
      startList.add(0.0);
    }

    _controller.duration = widget.duration;
    _animation = CurvedAnimation(parent: _controller, curve: widget.curve);

    _controller.addListener(() {
      final t = _animation.value;
      setState(() {
        _currentProgressAngles = List.generate(toAngles.length, (i) {
          final start = i < startList.length ? startList[i] : 0.0;
          final end = toAngles[i];
          return start + (end - start) * t;
        });
      });
    });

    _controller.forward(from: 0);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final count = widget.items.length;
    // 自动计算粗细和间距
    final strokeWidth = widget.strokeWidth ?? (count <= 2 ? 4.5 : 3.6);
    final gap = widget.gap ?? (count <= 2 ? 2.5 : 1.8);

    final startAngleRad = widget.startAngleDeg * pi / 180.0;
    final totalSweepRad = widget.sweepTotalDeg * pi / 180.0;

    return SizedBox(
      width: widget.size,
      height: widget.size,
      child: CustomPaint(
        painter: _ArcMultiProgressPainter(
          animatedProgresses: _currentProgressAngles,
          items: widget.items,
          strokeWidth: strokeWidth,
          gap: gap,
          startAngleRad: startAngleRad,
          totalSweepRad: totalSweepRad,
        ),
      ),
    );
  }
}

class _ArcMultiProgressPainter extends CustomPainter {
  final List<double> animatedProgresses;
  final List<ArcProgressItem> items;
  final double strokeWidth;
  final double gap;
  final double startAngleRad;
  final double totalSweepRad;

  _ArcMultiProgressPainter({
    required this.animatedProgresses,
    required this.items,
    required this.strokeWidth,
    required this.gap,
    required this.startAngleRad,
    required this.totalSweepRad,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final count = items.length;

    for (int i = 0; i < count; i++) {
      final item = items[i];
      final radius = (size.width - strokeWidth) / 2 - i * (strokeWidth + gap);
      if (radius <= 0) continue;

      final rect = Rect.fromCircle(center: center, radius: radius);

      // 1. 绘制底轨
      final hasData = item.ratio != null && item.ratio! > 0;
      final bgColor = (!hasData && item.noDataBackgroundColor != null)
          ? item.noDataBackgroundColor!
          : item.backgroundColor;

      final trackPaint = Paint()
        ..color = bgColor
        ..strokeWidth = strokeWidth
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round;

      canvas.drawArc(rect, startAngleRad, totalSweepRad, false, trackPaint);

      // 2. 绘制进度
      final progressSweep =
          i < animatedProgresses.length ? animatedProgresses[i] : 0.0;
      if (progressSweep > 0.001) {
        final progressPaint = Paint()
          ..color = item.color
          ..strokeWidth = strokeWidth
          ..style = PaintingStyle.stroke
          ..strokeCap = StrokeCap.round;

        canvas.drawArc(rect, startAngleRad, progressSweep, false, progressPaint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant _ArcMultiProgressPainter oldDelegate) {
    return true;
  }
}