Showing
1 changed file
with
213 additions
and
15 deletions
| @@ -52,6 +52,27 @@ class ArcMultiProgressWidget extends StatefulWidget { | @@ -52,6 +52,27 @@ class ArcMultiProgressWidget extends StatefulWidget { | ||
| 52 | /// 动画曲线 | 52 | /// 动画曲线 |
| 53 | final Curve curve; | 53 | final Curve curve; |
| 54 | 54 | ||
| 55 | + /// 超过 1 圈时端头阴影颜色 (默认半透明黑) | ||
| 56 | + final Color shadowColor; | ||
| 57 | + | ||
| 58 | + /// 阴影模糊半径 (越大越发散,越小越清晰,默认 2.2) | ||
| 59 | + final double shadowBlur; | ||
| 60 | + | ||
| 61 | + /// 阴影顺时针偏移距离 (像素,默认 0.8) | ||
| 62 | + final double shadowOffset; | ||
| 63 | + | ||
| 64 | + /// 合环三角形宽度占 size 的比例 (默认 15.27 / 50 ≈ 0.3054) | ||
| 65 | + final double triangleWidthRatio; | ||
| 66 | + | ||
| 67 | + /// 合环三角形高度占 size 的比例 (默认 8.27 / 50 ≈ 0.1654) | ||
| 68 | + final double triangleHeightRatio; | ||
| 69 | + | ||
| 70 | + /// 合环三角形向下溢出底部的比例 (占 size 的比例,默认 0.0) | ||
| 71 | + final double triangleBottomOverflowRatio; | ||
| 72 | + | ||
| 73 | + /// 合环三角形圆角半径占 size 的比例 (默认 1.4 / 50 ≈ 0.028) | ||
| 74 | + final double triangleCornerRadiusRatio; | ||
| 75 | + | ||
| 55 | const ArcMultiProgressWidget({ | 76 | const ArcMultiProgressWidget({ |
| 56 | super.key, | 77 | super.key, |
| 57 | required this.items, | 78 | required this.items, |
| @@ -63,6 +84,13 @@ class ArcMultiProgressWidget extends StatefulWidget { | @@ -63,6 +84,13 @@ class ArcMultiProgressWidget extends StatefulWidget { | ||
| 63 | this.animate = true, | 84 | this.animate = true, |
| 64 | this.duration = const Duration(milliseconds: 1000), | 85 | this.duration = const Duration(milliseconds: 1000), |
| 65 | this.curve = Curves.easeOutCubic, | 86 | this.curve = Curves.easeOutCubic, |
| 87 | + this.shadowColor = const Color.fromARGB(173, 0, 0, 0), // alpha ~ 0.35 | ||
| 88 | + this.shadowBlur = 2.2, | ||
| 89 | + this.shadowOffset = 0.8, | ||
| 90 | + this.triangleWidthRatio = 0.42, | ||
| 91 | + this.triangleHeightRatio = 0.22, | ||
| 92 | + this.triangleBottomOverflowRatio = 0.04, | ||
| 93 | + this.triangleCornerRadiusRatio = 0.02, | ||
| 66 | }); | 94 | }); |
| 67 | 95 | ||
| 68 | @override | 96 | @override |
| @@ -81,8 +109,7 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | @@ -81,8 +109,7 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | ||
| 81 | if (item.ratio == null || item.ratio! <= 0) { | 109 | if (item.ratio == null || item.ratio! <= 0) { |
| 82 | return 0.0; | 110 | return 0.0; |
| 83 | } | 111 | } |
| 84 | - final clampedRatio = min(item.ratio!, 1.0); | ||
| 85 | - return clampedRatio * totalRad; | 112 | + return item.ratio! * totalRad; |
| 86 | }).toList(); | 113 | }).toList(); |
| 87 | } | 114 | } |
| 88 | 115 | ||
| @@ -118,7 +145,6 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | @@ -118,7 +145,6 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | ||
| 118 | } | 145 | } |
| 119 | 146 | ||
| 120 | final startList = List<double>.from(fromAngles); | 147 | final startList = List<double>.from(fromAngles); |
| 121 | - // 确保长度一致 | ||
| 122 | while (startList.length < toAngles.length) { | 148 | while (startList.length < toAngles.length) { |
| 123 | startList.add(0.0); | 149 | startList.add(0.0); |
| 124 | } | 150 | } |
| @@ -167,6 +193,13 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | @@ -167,6 +193,13 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> | ||
| 167 | gap: gap, | 193 | gap: gap, |
| 168 | startAngleRad: startAngleRad, | 194 | startAngleRad: startAngleRad, |
| 169 | totalSweepRad: totalSweepRad, | 195 | totalSweepRad: totalSweepRad, |
| 196 | + shadowColor: widget.shadowColor, | ||
| 197 | + shadowBlur: widget.shadowBlur, | ||
| 198 | + shadowOffset: widget.shadowOffset, | ||
| 199 | + triangleWidthRatio: widget.triangleWidthRatio, | ||
| 200 | + triangleHeightRatio: widget.triangleHeightRatio, | ||
| 201 | + triangleBottomOverflowRatio: widget.triangleBottomOverflowRatio, | ||
| 202 | + triangleCornerRadiusRatio: widget.triangleCornerRadiusRatio, | ||
| 170 | ), | 203 | ), |
| 171 | ), | 204 | ), |
| 172 | ); | 205 | ); |
| @@ -180,6 +213,13 @@ class _ArcMultiProgressPainter extends CustomPainter { | @@ -180,6 +213,13 @@ class _ArcMultiProgressPainter extends CustomPainter { | ||
| 180 | final double gap; | 213 | final double gap; |
| 181 | final double startAngleRad; | 214 | final double startAngleRad; |
| 182 | final double totalSweepRad; | 215 | final double totalSweepRad; |
| 216 | + final Color shadowColor; | ||
| 217 | + final double shadowBlur; | ||
| 218 | + final double shadowOffset; | ||
| 219 | + final double triangleWidthRatio; | ||
| 220 | + final double triangleHeightRatio; | ||
| 221 | + final double triangleBottomOverflowRatio; | ||
| 222 | + final double triangleCornerRadiusRatio; | ||
| 183 | 223 | ||
| 184 | _ArcMultiProgressPainter({ | 224 | _ArcMultiProgressPainter({ |
| 185 | required this.animatedProgresses, | 225 | required this.animatedProgresses, |
| @@ -188,6 +228,13 @@ class _ArcMultiProgressPainter extends CustomPainter { | @@ -188,6 +228,13 @@ class _ArcMultiProgressPainter extends CustomPainter { | ||
| 188 | required this.gap, | 228 | required this.gap, |
| 189 | required this.startAngleRad, | 229 | required this.startAngleRad, |
| 190 | required this.totalSweepRad, | 230 | required this.totalSweepRad, |
| 231 | + required this.shadowColor, | ||
| 232 | + required this.shadowBlur, | ||
| 233 | + required this.shadowOffset, | ||
| 234 | + required this.triangleWidthRatio, | ||
| 235 | + required this.triangleHeightRatio, | ||
| 236 | + required this.triangleBottomOverflowRatio, | ||
| 237 | + required this.triangleCornerRadiusRatio, | ||
| 191 | }); | 238 | }); |
| 192 | 239 | ||
| 193 | @override | 240 | @override |
| @@ -202,7 +249,7 @@ class _ArcMultiProgressPainter extends CustomPainter { | @@ -202,7 +249,7 @@ class _ArcMultiProgressPainter extends CustomPainter { | ||
| 202 | 249 | ||
| 203 | final rect = Rect.fromCircle(center: center, radius: radius); | 250 | final rect = Rect.fromCircle(center: center, radius: radius); |
| 204 | 251 | ||
| 205 | - // 1. 绘制底轨 | 252 | + // 1. 绘制底轨 (Track) |
| 206 | final hasData = item.ratio != null && item.ratio! > 0; | 253 | final hasData = item.ratio != null && item.ratio! > 0; |
| 207 | final bgColor = (!hasData && item.noDataBackgroundColor != null) | 254 | final bgColor = (!hasData && item.noDataBackgroundColor != null) |
| 208 | ? item.noDataBackgroundColor! | 255 | ? item.noDataBackgroundColor! |
| @@ -216,19 +263,170 @@ class _ArcMultiProgressPainter extends CustomPainter { | @@ -216,19 +263,170 @@ class _ArcMultiProgressPainter extends CustomPainter { | ||
| 216 | 263 | ||
| 217 | canvas.drawArc(rect, startAngleRad, totalSweepRad, false, trackPaint); | 264 | canvas.drawArc(rect, startAngleRad, totalSweepRad, false, trackPaint); |
| 218 | 265 | ||
| 219 | - // 2. 绘制进度 | ||
| 220 | - final progressSweep = | ||
| 221 | - i < animatedProgresses.length ? animatedProgresses[i] : 0.0; | ||
| 222 | - if (progressSweep > 0.001) { | ||
| 223 | - final progressPaint = Paint() | ||
| 224 | - ..color = item.color | ||
| 225 | - ..strokeWidth = strokeWidth | ||
| 226 | - ..style = PaintingStyle.stroke | ||
| 227 | - ..strokeCap = StrokeCap.round; | ||
| 228 | - | ||
| 229 | - canvas.drawArc(rect, startAngleRad, progressSweep, false, progressPaint); | 266 | + // 2. 绘制进度 (Progress) |
| 267 | + final sweep = i < animatedProgresses.length ? animatedProgresses[i] : 0.0; | ||
| 268 | + if (sweep <= 0.001) continue; | ||
| 269 | + | ||
| 270 | + final fullLaps = (sweep / totalSweepRad).floor(); | ||
| 271 | + final remainingSweep = sweep - fullLaps * totalSweepRad; | ||
| 272 | + | ||
| 273 | + final progressPaint = Paint() | ||
| 274 | + ..color = item.color | ||
| 275 | + ..strokeWidth = strokeWidth | ||
| 276 | + ..style = PaintingStyle.stroke | ||
| 277 | + ..strokeCap = StrokeCap.round; | ||
| 278 | + | ||
| 279 | + // ① 如果至少满一圈,先绘制完整的底层圆弧 | ||
| 280 | + if (fullLaps >= 1) { | ||
| 281 | + canvas.drawArc( | ||
| 282 | + rect, startAngleRad, totalSweepRad, false, progressPaint); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + // ② 绘制超出的部分(或第一圈未满的部分) | ||
| 286 | + if (fullLaps >= 1) { | ||
| 287 | + if (remainingSweep > 0.01) { | ||
| 288 | + final currentEndAngle = startAngleRad + remainingSweep; | ||
| 289 | + | ||
| 290 | + // 沿切线前进方向微小偏移 | ||
| 291 | + final tangentOffsetX = -shadowOffset * sin(currentEndAngle); | ||
| 292 | + final tangentOffsetY = shadowOffset * cos(currentEndAngle); | ||
| 293 | + | ||
| 294 | + final shadowCenter = Offset( | ||
| 295 | + center.dx + radius * cos(currentEndAngle) + tangentOffsetX, | ||
| 296 | + center.dy + radius * sin(currentEndAngle) + tangentOffsetY, | ||
| 297 | + ); | ||
| 298 | + | ||
| 299 | + final capCenter = Offset( | ||
| 300 | + center.dx + radius * cos(currentEndAngle), | ||
| 301 | + center.dy + radius * sin(currentEndAngle), | ||
| 302 | + ); | ||
| 303 | + | ||
| 304 | + // 构造当前圆环轨道的裁剪区域,严格限制阴影只在环槽内显示,绝不溢出到外圈或内圈 | ||
| 305 | + final trackClipPath = Path() | ||
| 306 | + ..addOval(Rect.fromCircle( | ||
| 307 | + center: center, radius: radius + strokeWidth / 2)) | ||
| 308 | + ..addOval(Rect.fromCircle( | ||
| 309 | + center: center, radius: radius - strokeWidth / 2)) | ||
| 310 | + ..fillType = PathFillType.evenOdd; | ||
| 311 | + | ||
| 312 | + // 绘制被精准裁剪的自然阴影 | ||
| 313 | + canvas.save(); | ||
| 314 | + canvas.clipPath(trackClipPath); | ||
| 315 | + final shadowPaint = Paint() | ||
| 316 | + ..color = shadowColor | ||
| 317 | + ..style = PaintingStyle.fill | ||
| 318 | + ..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur); | ||
| 319 | + canvas.drawCircle(shadowCenter, strokeWidth / 2, shadowPaint); | ||
| 320 | + canvas.restore(); | ||
| 321 | + | ||
| 322 | + // 绘制第二层圆弧(带圆角端头,压在阴影上) | ||
| 323 | + canvas.drawArc( | ||
| 324 | + rect, startAngleRad, remainingSweep, false, progressPaint); | ||
| 325 | + | ||
| 326 | + // 显式绘制顶层端帽实心圆 | ||
| 327 | + final capFillPaint = Paint() | ||
| 328 | + ..color = item.color | ||
| 329 | + ..style = PaintingStyle.fill; | ||
| 330 | + canvas.drawCircle(capCenter, strokeWidth / 2, capFillPaint); | ||
| 331 | + } | ||
| 332 | + } else { | ||
| 333 | + // 第一圈未满,直接绘制 | ||
| 334 | + canvas.drawArc(rect, startAngleRad, sweep, false, progressPaint); | ||
| 230 | } | 335 | } |
| 231 | } | 336 | } |
| 337 | + | ||
| 338 | + // 3. 当所有环都合环(>= 100%)时,在底部中央开口处绘制圆润的合环指示三角形 | ||
| 339 | + final isAllCompleted = items.isNotEmpty && | ||
| 340 | + List.generate(items.length, (idx) { | ||
| 341 | + final sw = | ||
| 342 | + idx < animatedProgresses.length ? animatedProgresses[idx] : 0.0; | ||
| 343 | + return sw >= totalSweepRad - 0.01; | ||
| 344 | + }).every((completed) => completed); | ||
| 345 | + | ||
| 346 | + if (isAllCompleted && count > 0) { | ||
| 347 | + // 按照 size 的比例计算宽度、高度、溢出与圆角 | ||
| 348 | + final triangleWidth = size.width * triangleWidthRatio; | ||
| 349 | + final triangleHeight = size.height * triangleHeightRatio; | ||
| 350 | + final halfWidth = triangleWidth / 2; | ||
| 351 | + | ||
| 352 | + final outerRadius = (size.width - strokeWidth) / 2; | ||
| 353 | + // 以最外环端点下边缘(合环底部最低点)为 0 基准 | ||
| 354 | + final ringBottomY = | ||
| 355 | + center.dy + outerRadius * sin(startAngleRad) + strokeWidth / 2; | ||
| 356 | + | ||
| 357 | + final overflow = size.height * triangleBottomOverflowRatio; | ||
| 358 | + final bottomY = ringBottomY + overflow; | ||
| 359 | + final topY = bottomY - triangleHeight; | ||
| 360 | + | ||
| 361 | + final topPoint = Offset(center.dx, topY); | ||
| 362 | + final rightPoint = Offset(center.dx + halfWidth, bottomY); | ||
| 363 | + final leftPoint = Offset(center.dx - halfWidth, bottomY); | ||
| 364 | + | ||
| 365 | + final cornerRadius = | ||
| 366 | + size.width * (triangleCornerRadiusRatio ?? (1.4 / 50.0)); | ||
| 367 | + | ||
| 368 | + final roundedTrianglePath = _createRoundedTrianglePath( | ||
| 369 | + top: topPoint, | ||
| 370 | + right: rightPoint, | ||
| 371 | + left: leftPoint, | ||
| 372 | + cornerRadius: cornerRadius, | ||
| 373 | + ); | ||
| 374 | + | ||
| 375 | + final trianglePaint = Paint() | ||
| 376 | + ..color = items.first.color | ||
| 377 | + ..style = PaintingStyle.fill; | ||
| 378 | + | ||
| 379 | + canvas.drawPath(roundedTrianglePath, trianglePaint); | ||
| 380 | + } | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + /// 绘制平滑圆角三角形 | ||
| 384 | + Path _createRoundedTrianglePath({ | ||
| 385 | + required Offset top, | ||
| 386 | + required Offset right, | ||
| 387 | + required Offset left, | ||
| 388 | + required double cornerRadius, | ||
| 389 | + }) { | ||
| 390 | + final path = Path(); | ||
| 391 | + final points = [top, right, left]; | ||
| 392 | + final n = points.length; | ||
| 393 | + | ||
| 394 | + for (int i = 0; i < n; i++) { | ||
| 395 | + final p0 = points[(i - 1 + n) % n]; | ||
| 396 | + final p1 = points[i]; | ||
| 397 | + final p2 = points[(i + 1) % n]; | ||
| 398 | + | ||
| 399 | + final v1 = Offset(p0.dx - p1.dx, p0.dy - p1.dy); | ||
| 400 | + final v2 = Offset(p2.dx - p1.dx, p2.dy - p1.dy); | ||
| 401 | + | ||
| 402 | + final d1 = sqrt(v1.dx * v1.dx + v1.dy * v1.dy); | ||
| 403 | + final d2 = sqrt(v2.dx * v2.dx + v2.dy * v2.dy); | ||
| 404 | + | ||
| 405 | + final u1 = Offset(v1.dx / d1, v1.dy / d1); | ||
| 406 | + final u2 = Offset(v2.dx / d2, v2.dy / d2); | ||
| 407 | + | ||
| 408 | + final dot = (u1.dx * u2.dx + u1.dy * u2.dy).clamp(-1.0, 1.0); | ||
| 409 | + final angle = acos(dot); | ||
| 410 | + final cutDist = cornerRadius / tan(angle / 2); | ||
| 411 | + | ||
| 412 | + final cut1 = Offset(p1.dx + u1.dx * cutDist, p1.dy + u1.dy * cutDist); | ||
| 413 | + final cut2 = Offset(p1.dx + u2.dx * cutDist, p1.dy + u2.dy * cutDist); | ||
| 414 | + | ||
| 415 | + if (i == 0) { | ||
| 416 | + path.moveTo(cut1.dx, cut1.dy); | ||
| 417 | + } else { | ||
| 418 | + path.lineTo(cut1.dx, cut1.dy); | ||
| 419 | + } | ||
| 420 | + | ||
| 421 | + path.arcToPoint( | ||
| 422 | + cut2, | ||
| 423 | + radius: Radius.circular(cornerRadius), | ||
| 424 | + clockwise: true, | ||
| 425 | + ); | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + path.close(); | ||
| 429 | + return path; | ||
| 232 | } | 430 | } |
| 233 | 431 | ||
| 234 | @override | 432 | @override |
-
Please register or login to post a comment