之前有遇到过一个需求,使用flutter的画布实现一个自定义图形的编辑功能,开发完后觉得挺有意思,其中还使用了一些数学知识,因此整理了一下,记录下来。
第一部分
1.基础图形-线段的移动效果图

2.关键部分代码实现
import 'package:flutter/material.dart';
class LineSegmentPainter extends CustomPainter {
LineSegmentPainter({
required this.offsetStart,
required this.offsetEnd,
this.paintColor = const Color(0xFF34C759),
this.endRadius = 4.5,
Listenable? repaint,
}) : super(repaint: repaint) {
linePaint
..color = paintColor
..strokeWidth = 3.0
..strokeCap = StrokeCap.square
..isAntiAlias = true
..style = PaintingStyle.fill;
}
final linePaint = Paint();
final ValueNotifier<Offset> offsetStart;
final ValueNotifier<Offset> offsetEnd;
final Color paintColor;
final double endRadius;
@override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
canvas.translate(size.width / 2, size.height / 2);
if (offsetStart.value == Offset.zero && offsetEnd.value == Offset.zero) {
return;
}
var lineStart = offsetStart.value;
var lineEnd = offsetEnd.value;
linePaint
..color = paintColor
..strokeWidth = 3.0;
canvas.drawLine(lineStart, lineEnd, linePaint);
canvas.drawCircle(lineStart, endRadius, linePaint);
canvas.drawCircle(lineEnd, endRadius, linePaint);
}
@override
bool shouldRepaint(LineSegmentPainter oldDelegate) {
return oldDelegate.offsetStart != offsetStart ||
oldDelegate.offsetEnd != offsetEnd ||
oldDelegate.paintColor != paintColor ||
oldDelegate.endRadius != endRadius;
}
}
import 'package:flutter/material.dart'
import 'package:flutter_custom_painter/entities/custom_point.dart'
import 'package:flutter_custom_painter/painter/line_segment_painter.dart'
import 'package:flutter_custom_painter/utils/spatial_relation_util.dart'
class LineSegmentPage extends StatefulWidget {
const LineSegmentPage({super.key})
@override
State<LineSegmentPage> createState() => _LineSegmentPageState()
}
class _LineSegmentPageState extends State<LineSegmentPage> {
final GlobalKey _globalKey = GlobalKey()
/// 线段相关的变量
final double _lineSegmentLength = 60
final _lineStartOffset = ValueNotifier(Offset.zero)
final _lineEndOffset = ValueNotifier(Offset.zero)
final _changedOffset = ValueNotifier(0)
/// 手势
static const moveAreaOutside = 0
static const moveAreaInside = 1
int _moveAreaType = moveAreaOutside
int _selectedIndex = -1
@override
void initState() {
_initialCanvas()
super.initState()
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("线段"),
),
body: GestureDetector(
onTapUp: onTapUp,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
child: Stack(
key: _globalKey,
children: [
CustomPaint(
foregroundPainter: LineSegmentPainter(
offsetStart: _lineStartOffset,
offsetEnd: _lineEndOffset,
repaint: _changedOffset,
),
child: Container(
color: Colors.grey,
),
),
],
),
))
}
void _initialCanvas({bool isVertical = false}) {
var lineSegmentLength = _lineSegmentLength
if (isVertical) {
_lineStartOffset.value = Offset(0, -lineSegmentLength / 2)
var dx = _lineStartOffset.value.dx
var dy = _lineStartOffset.value.dy + lineSegmentLength
_lineEndOffset.value = Offset(dx, dy)
} else {
_lineStartOffset.value = Offset(-lineSegmentLength / 2, 0)
var dx = _lineStartOffset.value.dx + lineSegmentLength
var dy = _lineStartOffset.value.dy
_lineEndOffset.value = Offset(dx, dy)
}
}
onTapUp(TapUpDetails details) {}
onScaleStart(ScaleStartDetails details) {
if (details.pointerCount == 1) {
var resizeOffset = _getResizeOffset(details.localFocalPoint)
var ret = _findTouchRespGraphType(resizeOffset)
print('onScaleStart->updateOperationType is $ret')
if (ret >= 0) {
_moveAreaType = moveAreaInside
} else {
_moveAreaType = moveAreaOutside
}
_selectedIndex = ret
}
}
onScaleEnd(ScaleEndDetails? details) {
_moveAreaType = moveAreaOutside
}
onScaleUpdate(ScaleUpdateDetails details) {
var pointIndex = _selectedIndex
var deltaOffset = details.focalPointDelta
print('onScaleUpdate->pointIndex=$pointIndex, moveAreaType=$_moveAreaType');
if (pointIndex >= 0 && _moveAreaType == moveAreaInside) {
if (0 == pointIndex) {
_lineStartOffset.value = _lineStartOffset.value.translate(deltaOffset.dx, deltaOffset.dy);
} else if (1 == pointIndex) {
_lineEndOffset.value = _lineEndOffset.value.translate(deltaOffset.dx, deltaOffset.dy);
} else if (2 == pointIndex) {
var startOffset = _lineStartOffset.value;
var endOffset = _lineEndOffset.value;
_lineStartOffset.value = startOffset.translate(deltaOffset.dx, deltaOffset.dy);
_lineEndOffset.value = endOffset.translate(deltaOffset.dx, deltaOffset.dy);
}
_changedOffset.value = DateTime.now().millisecondsSinceEpoch;
}
}
Offset _getResizeOffset(Offset offset) {
// 减去(_canvasWidth / 2)是因为画布中有平移左上角零点(0, 0)到画布中间的操作
RenderBox renderBox = _globalKey.currentContext?.findRenderObject() as RenderBox;
var renderSize = renderBox.size;
//print('getResizeOffset->width=${renderSize.width}, height=${renderSize.height}');
var resizeOffset = offset.translate(-renderSize.width / 2, -renderSize.height / 2);
return resizeOffset;
}
/// 通过点击位置匹配对应的图形
int _findTouchRespGraphType(Offset offset) {
// 本次点击的坐标点
var clickPoint = Point.translateFromOffset(offset);
var startPoint = Point.translateFromOffset(_lineStartOffset.value);
var endPoint = Point.translateFromOffset(_lineEndOffset.value);
var ret = _checkTapPointAtArch(startPoint, endPoint, clickPoint, radius: 30);
if (ret >= 0) {
return ret;
}
ret = _checkTapPointAtSide(startPoint, endPoint, clickPoint);
if (ret >= 0) {
return ret;
}
print('updateOperationType->不在任何编辑的图形上');
return -1;
}
/// 是否在点上
int _checkTapPointAtArch(Point start, Point end, Point clickPoint, {double radius = 15.0}) {
var isClickInner = SpatialRelationUtil.isPointInCircle(start, radius, clickPoint);
if (isClickInner) {
return 0;
}
isClickInner = SpatialRelationUtil.isPointInCircle(end, radius, clickPoint);
if (isClickInner) {
return 1;
}
return -1;
}
/// 是否在图形的边上
int _checkTapPointAtSide(Point start, Point end, Point clickPoint, {double radius = 5.0}) {
var isClickInner = SpatialRelationUtil.isPointInPolygon(start, end, clickPoint, lineHeight: radius);
if (isClickInner) {
return 2;
}
return -1;
}
}
- 其中用到了类Point和SpatialRelationUtil,后续直接在github上补充
至此基础部分的线段和手势功能基本完成,进入第二部分
第二部分,自定义画布中添加图片
1.画布中添加图片需要进行转换,不废话,直接添加代码(有多种方式实现,这里只贴出一种)
class CommonUtil {
/// ui image load by assets path
static Future<ui.Image> loadAssetsImage(String path, {int? targetWith, int? targetHeight}) async {
// 加载资源文件
final data = await rootBundle.load(path)
// 把资源文件转换成Uint8List类型
final bytes = data.buffer.asUint8List()
// 解析Uint8List类型的数据图片
//var retImage = decodeImageFromList(bytes)
ui.Codec codec = await ui.instantiateImageCodec(bytes, targetWidth: targetWith, targetHeight: targetHeight)
ui.FrameInfo frameInfo = await codec.getNextFrame()
var retImage = frameInfo.image
return retImage
}
}
2.由于图片加载使用了Future,那么页面的画布加载方式就发生了变化,需要使用FutureBuilder来实现,先看下效果图

- 需要注意的是,这个icon一直是在线段的中心偏下的位置,这里用到了三角形已知2点坐标和边长求第三个点坐标的几何知识
- 图片的加载时机,必须要在画布之前,否则就会出现画布中的图片不显示的情况
3.关键部分代码实现
import 'dart:math'
import 'dart:ui' as ui
import 'package:flutter/material.dart'
import 'package:flutter_custom_painter/entities/custom_point.dart'
import 'package:flutter_custom_painter/utils/spatial_relation_util.dart'
class LineSegmentIconPainter extends CustomPainter {
LineSegmentIconPainter({
required this.offsetStart,
required this.offsetEnd,
this.paintColor = const Color(0xFF34C759),
this.endRadius = 4.5,
this.imageIcon,
this.imageMargin = 30,
this.imageSize = 28,
Listenable? repaint,
}) : super(repaint: repaint) {
linePaint
..color = paintColor
..strokeWidth = 3.0
..strokeCap = StrokeCap.square
..isAntiAlias = true
..style = PaintingStyle.fill
}
final linePaint = Paint()
final ValueNotifier<Offset> offsetStart
final ValueNotifier<Offset> offsetEnd
final Color paintColor
final double endRadius
final ui.Image? imageIcon
final double imageMargin
final double imageSize
@override
void paint(Canvas canvas, Size size) {
// 裁剪画布为Size大小,使其不会超出区域显示
canvas.clipRect(Offset.zero & size)
// 平移画布 中心点
canvas.translate(size.width / 2, size.height / 2)
if (offsetStart.value == Offset.zero && offsetEnd.value == Offset.zero) {
return
}
linePaint
..color = paintColor
..strokeWidth = 3.0
var lineStart = offsetStart.value
var lineEnd = offsetEnd.value
// 需要放在线段绘制的前面,否则因为icon的大小会影响线段的点击事件
var closeIcon = imageIcon
if (null != closeIcon) {
var startPoint = Point(x: lineStart.dx, y: lineStart.dy)
var endPoint = Point(x: lineEnd.dx, y: lineEnd.dy)
var offsetIcon = _updateIconOffsetByLineSegment(startPoint, endPoint)
canvas.drawImage(closeIcon, offsetIcon, linePaint)
}
// line segment
canvas.drawLine(lineStart, lineEnd, linePaint)
// circle dot
canvas.drawCircle(lineStart, endRadius, linePaint)
canvas.drawCircle(lineEnd, endRadius, linePaint)
}
@override
bool shouldRepaint(LineSegmentIconPainter oldDelegate) {
return oldDelegate.offsetStart != offsetStart ||
oldDelegate.offsetEnd != offsetEnd ||
oldDelegate.paintColor != paintColor ||
oldDelegate.endRadius != endRadius
}
Offset _updateIconOffsetByLineSegment(Point pointA, Point pointB) {
var high = imageMargin
var iconRadius = imageSize / 2
// 由图片自身大小得出偏移量
var tmpIconOffset = Offset(iconRadius, iconRadius)
// 通过三角形边长和2个点的坐标求出第三个点的坐标
var lineAB = SpatialRelationUtil.getDistanceFromAB(pointA, pointB)
var lineBC = sqrt(pow(lineAB / 2, 2) + pow(high, 2))
var retPointBot = SpatialRelationUtil.getTrianglePoint(pointA, pointB, lineAB, lineBC, lineBC, isFirst: true)
var iconOffset = Offset(retPointBot.getX - tmpIconOffset.dx, retPointBot.getY - tmpIconOffset.dy)
return iconOffset
}
}
import 'dart:math'
import 'dart:ui' as ui
import 'package:flutter/material.dart'
import 'package:flutter_custom_painter/entities/custom_point.dart'
import 'package:flutter_custom_painter/utils/spatial_relation_util.dart'
class LineSegmentIconPainter extends CustomPainter {
LineSegmentIconPainter({
required this.offsetStart,
required this.offsetEnd,
this.paintColor = const Color(0xFF34C759),
this.endRadius = 4.5,
this.imageIcon,
this.imageMargin = 30,
this.imageSize = 28,
Listenable? repaint,
}) : super(repaint: repaint) {
linePaint
..color = paintColor
..strokeWidth = 3.0
..strokeCap = StrokeCap.square
..isAntiAlias = true
..style = PaintingStyle.fill
}
final linePaint = Paint()
final ValueNotifier<Offset> offsetStart
final ValueNotifier<Offset> offsetEnd
final Color paintColor
final double endRadius
final ui.Image? imageIcon
final double imageMargin
final double imageSize
@override
void paint(Canvas canvas, Size size) {
// 裁剪画布为Size大小,使其不会超出区域显示
canvas.clipRect(Offset.zero & size)
// 平移画布 中心点
canvas.translate(size.width / 2, size.height / 2)
if (offsetStart.value == Offset.zero && offsetEnd.value == Offset.zero) {
return
}
linePaint
..color = paintColor
..strokeWidth = 3.0
var lineStart = offsetStart.value
var lineEnd = offsetEnd.value
// 需要放在线段绘制的前面,否则因为icon的大小会影响线段的点击事件
var closeIcon = imageIcon
if (null != closeIcon) {
var startPoint = Point(x: lineStart.dx, y: lineStart.dy)
var endPoint = Point(x: lineEnd.dx, y: lineEnd.dy)
var offsetIcon = _updateIconOffsetByLineSegment(startPoint, endPoint)
canvas.drawImage(closeIcon, offsetIcon, linePaint)
}
// line segment
canvas.drawLine(lineStart, lineEnd, linePaint)
// circle dot
canvas.drawCircle(lineStart, endRadius, linePaint)
canvas.drawCircle(lineEnd, endRadius, linePaint)
}
@override
bool shouldRepaint(LineSegmentIconPainter oldDelegate) {
return oldDelegate.offsetStart != offsetStart ||
oldDelegate.offsetEnd != offsetEnd ||
oldDelegate.paintColor != paintColor ||
oldDelegate.endRadius != endRadius
}
Offset _updateIconOffsetByLineSegment(Point pointA, Point pointB) {
var high = imageMargin
var iconRadius = imageSize / 2
// 由图片自身大小得出偏移量
var tmpIconOffset = Offset(iconRadius, iconRadius)
// 通过三角形边长和2个点的坐标求出第三个点的坐标
var lineAB = SpatialRelationUtil.getDistanceFromAB(pointA, pointB)
var lineBC = sqrt(pow(lineAB / 2, 2) + pow(high, 2))
var retPointBot = SpatialRelationUtil.getTrianglePoint(pointA, pointB, lineAB, lineBC, lineBC, isFirst: true)
var iconOffset = Offset(retPointBot.getX - tmpIconOffset.dx, retPointBot.getY - tmpIconOffset.dy)
return iconOffset
}
}
至此带有icon的画布功能也已实现,当然,这里只包含了最基础的图形平移,整个画布的平移和缩放还没有整合进来,但这个不难,因为只要取临时变量保存画布整体的平移值,整体的缩放值,在绘制画布的时候,把平移和缩放添加到点的坐标上去就可以实现整体的平移和缩放了。顺带一提的是,这里使用了最基础的线段,如果是一个矩形图形呢?那么需要处理的点的坐标就会多一些了,图形单个点的平移还会影响到相邻点的坐标,这个需要注意。
上面没有贴出来的代码可以在我的github上查看 GitHub源码地址
PS:如果下载他人的flutter项目跑不起来,如果因为flutter sdk版本不一致的问题,并且这是一个纯flutter的项目,那么可以尝试使用如下指令,创建属于自己flutter sdk版本的平台文件,在此以kotlin语言平台为Android为例。
rm -r android
flutter create --platform-android -a kotlin .
- 注意上面第二命令后面的点,这个不能少,通过上面的命令就能实现生成当前sdk版本的文件,解决项目sdk版本不兼容的问题,当然这个并不完整,只是一个快速的处理方式。