时间:2023-03-16 11:37:12 | 栏目:Android代码 | 点击:次
在开发过程中通常会遇到一些不规则的UI,比如不规则的线条,多边形,统计图表等等,用那些通用组件通过组合的方式无法进行实现,这就需要我们自己进行绘制。可以通过使用CuntomPaint
组件并结合画笔CustomPainter
去进行手动绘制各种图形。
CustomPaint是一个继承SingleChildRenderObjectWidget的Widget,这里主要介绍几个重要参数:
child:CustomPaint的子组件。
painter: 画笔,绘制的图形会显示在child后面。
foregroundPainter:前景画笔,绘制的图形会显示在child前面。
size:绘制区域大小。
CustomPainter
是一个抽象类,通过自定义一个类继承自CustomPainter
,重写paint
和shouldRepaint
方法,具体绘制主要在paint
方法里。
主要两个参数:
Canvas:画布,可以用于绘制各种图形。
Size:绘制区域的大小。
void paint(Canvas canvas, Size size)
在Widget重绘前会调用该方法确定时候需要重绘,shouldRepaint
返回ture
表示需要重绘,返回false
表示不需要重绘。
bool shouldRepaint(CustomPainter oldDelegate)
这里我们通过绘制一个饼状图来演示绘制的整体流程。
首先,使用CustomPaint
,绘制大小为父组件最大值,传入自定义painter
。
@override Widget build(BuildContext context) { return CustomPaint( size: Size.infinite, painter: PieChartPainter(), ); }
自定义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
那肯定还有一个State
,State
继承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; } }