Flutter 给图片添加涂鸦功能的实现

简介

先来张图,看一下最终效果

关闭和确定

  • 关闭和确定功能对应的是界面左下角叉号,和右下角对钩,关闭按钮仅做取消当次涂鸦,读者可自行设置点击后功能,也可自行更改相应UI。选择功能点击后会执行一段把当前涂鸦后的图片合成并保存到本地的操作。具体请看示例代码。

颜色选择

  • 颜色选择功能可选择和标识当前涂鸦颜色,和指示当前涂鸦颜色的选中状态(以白色外圈标识)。切换颜色后下一次涂鸦即会使用新的颜色。

撤销功能

  • 撤销功能可撤销最近的一次涂鸦。如没有涂鸦时显示置灰的撤销按钮。

清除功能

  • 清除功能可清除所有涂鸦,如当前没有任何涂鸦时显示置灰的清除按钮。

涂鸦图片的放大和缩小

  • 可双指滑动切换涂鸦放大缩小的效果。

放大缩小后按照新的线条粗细继续涂鸦

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

保存涂鸦图片到本地。

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

代码介绍

涂鸦颜色选择组件。

主要是显示为可配置的圆点和外圈

circle_ring_widget.dart

scala 复制代码
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

ini 复制代码
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

ini 复制代码
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. 包含撤销一步和清屏功能

下面这些就是整体涂鸦相关功能代码,其中一些资源图片未提供,请根据需要自己去设计处获取。

less 复制代码
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;
    }
  }
}
相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
茶底世界之下4 天前
为什么预览、录制、离线导出不能共享同一个背压策略?
ios·swift
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen4 天前
属性动画原理与高级动画实现
android·kotlin·canvas