简介

先来张图,看一下最终作用

Flutter 给图片添加涂鸦功用的完成

封闭和确认

  • 封闭和确认功用对应的是界面左下角叉号,和右下角对钩,封闭按钮仅做取消当次涂鸦,读者可自行设置点击后功用,也可自行更改相应UI。挑选功用点击后会履行一段把当时涂鸦后的图片组成并保存到本地的操作。详细请看示例代码。

色彩挑选

  • 色彩挑选功用可挑选和标识当时涂鸦色彩,和指示当时涂鸦色彩的选中状态(以白色外圈标识)。切换色彩后下一次涂鸦即会运用新的色彩。

吊销功用

  • 吊销功用可吊销最近的一次涂鸦。如没有涂鸦时显现置灰的吊销按钮。

清除功用

  • 清除功用可清除一切涂鸦,如当时没有任何涂鸦时显现置灰的清除按钮。

涂鸦图片的扩大和缩小

  • 可双指滑动切换涂鸦扩大缩小的作用。

扩大缩小后依照新的线条粗细持续涂鸦

  • 涂鸦扩大或缩小后,涂鸦线条会随之扩大和缩小,此时假如持续涂鸦,则新涂鸦显现的粗细程度与扩大或缩小后的线条粗细程度保持一致。

保存涂鸦图片到本地。

  • flutter涂鸦后的图片可组成新图片并保存到本地路径。

代码介绍

涂鸦色彩挑选组件。

首要是显现为可配置的圆点和外圈

circle_ring_widget.dart

import 'package:flutter/material.dart';
class CircleRingWidget extends StatelessWidget {
  late bool isShowRing;
  late Color dotColor;
  CircleRingWidget(this.isShowRing,this.dotColor, {super.key});
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: CircleAndRingPainter(isShowRing,dotColor),
      size: const Size(56.0, 81.0), // 调整尺度巨细
    );
  }
}
class CircleAndRingPainter extends CustomPainter {
  late bool isShowRing;
  late Color dotColor;
  CircleAndRingPainter(this.isShowRing,this.dotColor);
  @override
  void paint(Canvas canvas, Size size) {
    Paint circlePaint = Paint()
      ..color = dotColor // 设置圆点的色彩
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 1.0;
    Paint ringPaint = Paint()
      ..color = Colors.white // 设置圆环的色彩
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke;
    Offset center = size.center(Offset.zero);
    // 画一个半径为10的圆点
    canvas.drawCircle(center, 13.0, circlePaint);
    if(isShowRing){
      // 画一个半径为20的圆环
      canvas.drawCircle(center, 18.0, ringPaint);
    }
  }
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

存储色彩和食指划过点的数据类

color_offset.dart

import 'dart:ui';
class ColorOffset{
  late Color color;
  late List<Offset> offsets=[];
  ColorOffset(Color color,List<Offset> offsets){
    this.color=color;
    this.offsets.addAll(offsets);
  }
}

详细涂鸦点的制作

此处是详细制作涂鸦点的自定义view。大家是不是觉得哇,好简略呢。两个循环一嵌套,瞬间一切涂鸦就都出来了。其实做这个功用时,我参阅了其他各种涂鸦控件,可是总觉得流程非常复杂。难以理解。原因是他们的色彩和点在数据层面都是混合到一同的,而且还得判断哪里是新画的涂鸦线条,来回控制。用这个demo的结构,信任各位读者一看就能知道里边的思路

doodle_painter.dart

import 'package:flutter/cupertino.dart';
import 'color_offset.dart';
class DoodleImagePainter extends CustomPainter {
  late Map<int,ColorOffset> newPoints;
  DoodleImagePainter(this.newPoints);
  @override
  void paint(Canvas canvas, Size size) {
    newPoints.forEach((key, value) {
      Paint paint = _getPaint(value.color);
      for(int i=0;i<value.offsets.length - 1;i++){
        //最终一个画点,其他画线
        if(i==value.offsets.length-1){
          canvas.drawCircle(value.offsets[i], 2.0, paint);
        }else{
          canvas.drawLine(value.offsets[i], value.offsets[i + 1], paint);
        }
      }
    });
  }
  Paint _getPaint(Color color){
    return Paint()
      ..color = color
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 5.0;
  }
  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

涂鸦首要界面代码

  1. 包括全体涂鸦数据的构建
  2. 包括涂鸦图片的组成和本地存储
  3. 包括涂鸦色彩列表的自定义
  4. 包括涂鸦原图片的扩大缩小
  5. 包括吊销一步和清屏功用

下面这些就是全体涂鸦相关功用代码,其间一些资源图片未供给,请根据需要自己去规划处获取。

import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../player_base_control.dart';
import 'circle_ring_widget.dart';
import 'color_offset.dart';
import 'doodle_painter.dart';
class DoodleWidget extends PlayerBaseControllableWidget {
  final String snapShotPath;
  final ValueChanged<bool>? completed;
  const DoodleWidget(super.controller,
      {super.key, required this.snapShotPath, this.completed});
  @override
  State<StatefulWidget> createState() => _DoodleWidgetState();
}
class _DoodleWidgetState extends State<DoodleWidget> {
  Map<int, ColorOffset> newPoints = {};
  List<Offset> points = [];
  int lineIndex = 0;
  GlobalKey globalKey = GlobalKey();
  int currentSelect = 0;
  final double maxScale = 3.0;
  final double minScale = 1.0;
  List<Color> colors = const [
    Color(0xffff0000),
    Color(0xfffae03d),
    Color(0xff6f52ff),
    Color(0xffffffff),
    Color(0xff000000)
  ];
  TransformationController controller = TransformationController();
  double realScale = 1.0;
  Offset realTransLocation = Offset.zero;
  late Image currentImg;
  bool isSaved = false;
  @override
  void initState() {
    currentImg = Image.memory(File(widget.snapShotPath).readAsBytesSync());
    controller.addListener(() {
      ///获取矩阵里边的缩放详细值
      realScale = controller.value.entry(0, 0);
      ///获取矩阵里边的位置偏移量
      realTransLocation = Offset(controller.value.getTranslation().x,
          controller.value.getTranslation().y);
    });
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(child: LayoutBuilder(
            builder: (BuildContext context, BoxConstraints constraint) {
          return InteractiveViewer(
            panEnabled: false,
            scaleEnabled: true,
            maxScale: maxScale,
            minScale: minScale,
            transformationController: controller,
            onInteractionStart: (ScaleStartDetails details) {
              // print("--------------onInteractionStart履行了  dx=${details.focalPoint.dx} dy=${details.focalPoint.dy}");
            },
            onInteractionUpdate: (ScaleUpdateDetails details) {
              if (details.pointerCount == 1) {
                /// 获取 x,y 拿到值后进行缩放偏移等换算
                var x = details.focalPoint.dx;
                var y = details.focalPoint.dy;
                var point = Offset(
                    _getScaleTranslateValue(x, realScale, realTransLocation.dx),
                    _getScaleTranslateValue(
                        y, realScale, realTransLocation.dy));
                setState(() {
                  points.add(point);
                  newPoints[lineIndex] =
                      ColorOffset(colors[currentSelect], points);
                });
              }
            },
            onInteractionEnd: (ScaleEndDetails details) {
              // print("onInteractionEnd履行了");
              if (points.length > 5) {
                newPoints[lineIndex] =
                    ColorOffset(colors[currentSelect], points);
                lineIndex++;
              }
              // newPoints.addAll({lineIndex:ColorOffset(colors[currentSelect],points)});
              //清空原数组
              points.clear();
            },
            child: RepaintBoundary(
              key: globalKey,
              child: Stack(
                alignment: AlignmentDirectional.center,
                children: [
                  Positioned.fill(child: currentImg),
                  Positioned.fill(
                      child:
                          CustomPaint(painter: DoodleImagePainter(newPoints))),
                ],
              ),
            ),
          );
        })),
        Positioned(
          bottom: 0,
          left: 0,
          right: 0,
          child: _bottomActions(),
        )
      ],
    );
  }
  double _getScaleTranslateValue(
      double current, double scale, double translate) {
    return current / scale - translate / scale;
  }
  Widget _bottomActions() {
    return Container(
      height: 81,
      color: const Color(0xaa17161f),
      child: Row(
        children: [
          /// 封闭按钮
          SizedBox(
            width: 95,
            height: 81,
            child: Center(
              child: GestureDetector(
                onTap: () {
                  widget.completed?.call(false);
                },
                child: Image.asset(
                  "images/icon_close_white.webp",
                  width: 30,
                  height: 30,
                  scale: 3,
                  package: "koo_daxue_record_player",
                ),
              ),
            ),
          ),
          const VerticalDivider(
            thickness: 1,
            indent: 15,
            endIndent: 15,
            color: Colors.white,
          ),
          Row(
            children: _colorListWidget(),
          ),
          Expanded(child: Container()),
          /// 退一步按钮
          SizedBox(
            width: 66,
            height: 81,
            child: GestureDetector(
              onTap: () {
                setState(() {
                  if (lineIndex > 0) {
                    lineIndex--;
                    newPoints.remove(lineIndex);
                  }
                });
              },
              child: Center(
                  child: Image.asset(
                lineIndex == 0
                    ? "images/icon_undo.webp"
                    : "images/icon_undo_white.webp",
                width: 30,
                height: 30,
                scale: 3,
                package: "koo_daxue_record_player",
              )),
            ),
          ),
          /// 清除按钮
          SizedBox(
            width: 66,
            height: 81,
            child: Center(
                child: GestureDetector(
              onTap: () {
                setState(() {
                  lineIndex = 0;
                  newPoints.clear();
                });
              },
              child: Image.asset(
                lineIndex == 0
                    ? "images/icon_clear_doodle.webp"
                    : "images/icon_clear_doodle_white.webp",
                width: 30,
                height: 30,
                scale: 3,
                package: "koo_daxue_record_player",
              ),
            )),
          ),
          const VerticalDivider(
            thickness: 1,
            indent: 15,
            endIndent: 15,
            color: Colors.white,
          ),
          /// 确认按钮
          SizedBox(
            width: 85,
            height: 81,
            child: Center(
                child: GestureDetector(
              onTap: () {
                if (isSaved) return;
                isSaved = true;
                if (newPoints.isEmpty) {
                  widget.completed?.call(false);
                  return;
                }
                saveDoodle(widget.snapShotPath).then((value) {
                  if (value) {
                    widget.completed?.call(true);
                  } else {
                    widget.completed?.call(false);
                  }
                });
              },
              child: Image.asset(
                "images/icon_finish_white.webp",
                width: 30,
                height: 30,
                scale: 3,
                package: "koo_daxue_record_player",
              ),
            )),
          )
        ],
      ),
    );
  }
  List<Widget> _colorListWidget() {
    List<Widget> widgetList = [];
    for (int i = 0; i < colors.length; i++) {
      Color color = colors[i];
      widgetList.add(GestureDetector(
        onTap: () {
          setState(() {
            currentSelect = i;
          });
        },
        child: CircleRingWidget(i == currentSelect, color),
      ));
    }
    return widgetList;
  }
  Future<bool> saveDoodle(String imgPath) async {
    try {
      RenderRepaintBoundary boundary =
          globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
      ui.Image image = await boundary.toImage(pixelRatio: 3.0);
      ByteData? byteData =
          await image.toByteData(format: ui.ImageByteFormat.png);
      Uint8List pngBytes = byteData!.buffer.asUint8List();
      // 保存图片到文件
      File imgFile = File(imgPath);
      await imgFile.writeAsBytes(pngBytes);
      return true;
    } catch (e) {
      return false;
    }
  }
}