Flutter定义画布结合手势实现基础图形的拖拽和平移

447 阅读6分钟

之前有遇到过一个需求,使用flutter的画布实现一个自定义图形的编辑功能,开发完后觉得挺有意思,其中还使用了一些数学知识,因此整理了一下,记录下来。

第一部分

1.基础图形-线段的移动效果图

tline.gif

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) {
    // 裁剪画布为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;
    // line segment
    canvas.drawLine(lineStart, lineEnd, linePaint);

    // circle dot
    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来实现,先看下效果图

tline_icon.gif

  • 需要注意的是,这个icon一直是在线段的中心偏下的位置,这里用到了三角形已知2点坐标和边长求第三个点坐标的几何知识
  • 图片的加载时机,必须要在画布之前,否则就会出现画布中的图片不显示的情况

3.关键部分代码实现

  • 3.1画布中的代码
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为例。

  • 打开终端,并且cd到指定项目的目录下
 rm -r android
 flutter create --platform-android -a kotlin .
  • 注意上面第二命令后面的点,这个不能少,通过上面的命令就能实现生成当前sdk版本的文件,解决项目sdk版本不兼容的问题,当然这个并不完整,只是一个快速的处理方式。