flutter仿微博贴纸(手势处理+canvas图片绘制)

702 阅读8分钟

当产品拿着微博app对着我说”和这个做成一样的就行“,彼时我内心有一种想要拿键盘拍他脑袋的冲动,但作为一名有职业素养的程序员,我忍住了这个冲动,并在心理劝着自己:不能对他们要求太高,而且我们自己也总是搬运别人的代码,又怎么能要求产品经理不搬运别人的需求呢?

先上效果图:

RPReplay_Final1657693381_.gif

1.需求关键点分析

· 贴纸图片的手势操作

1.贴纸图片本身的手势,包含单指移动,双指缩放+旋转。 在flutter中还是比较容易实现的,GestureDetector控件包含的onScaleUpdate方法回调中包含这些数据

onScaleUpdate: (d){
  //缩放比例
  double scale=d.scale;
  //旋转角度
  double rotation=d.rotation;
  //位置
  Offset offset= d.focalPoint;
}

2.贴纸图片右下角操作按钮的手势,包含拖动缩放+旋转。 需要根据手势当前位置向量和上一个位置向量,计算两个向量之间的夹角就是旋转角度,计算两个向量的长度比例就是缩放比例。

3.贴纸随手势改变大小、位置、旋转角度。 添加贴纸这个控件,肯定是通过Stack控件来完成的,贴纸图片的位置通过Positioned控件来实现,贴纸的大小和旋转角度,通过Matrix4变换和组件的宽高变化来配合实现。

· 贴纸图片和选择的图片的图层合成

这里需要将贴纸图片和选择的图片合成为一张图片,这里用canvas画布来实现,先绘制出图片,再通过贴纸的位置、大小、旋转角度信息绘制出所有贴纸,最后将画布的内容保存为File文件。

2.代码实现

先设计数据格式,每张图片对应的贴纸数据,形成一条数据。特殊字段说明如下:

topCoverHeight:我们期望在操作贴纸时,贴纸只能在图片范围内移动,所以需要给贴纸的操作空间设置宽高为图片组件的宽高。但是我们在操作贴纸时,因为是通过Matrix4来进行图片的旋转角度变换,而Matrix4的变换是不会真正改变控件的信息的,所以即使我们给贴纸操作空间设置了宽高,也可能会超出边界,所以这里我们在贴纸操作控件的上层再加一层遮挡层,保证我们在操作贴纸时,从视觉上不会超过图片的范围。这个参数就是手机顶部距离图片的遮挡高度。

bottomCoverHeight:这个参数就是手机底部距离图片的遮挡高度

stickerList:贴纸数据集合,StickerBean贴在了下面

assetEntity:图片信息,其中包含图片的路径,id等

class ImageStickerBean {
  //顶部遮挡高度
  late double topCoverHeight;

  //底部遮挡高度
  late double bottomCoverHeight;

  //贴纸数据集合
  late List<StickerBean> stickerList;

  //当前图片的宽度
  double? width;

  //当前图片的高度
  double? height;

  //图片信息
  late AssetEntity assetEntity;

  //贴纸的初始宽度
  late double stickerWidth;

  //贴纸的初始高度
  late double stickerHeight;

  //贴纸操作框的初始宽度
  late double stickerDecorationWidth;

  //贴纸操作框的初始高度
  late double stickerDecorationHeight;

  //贴纸操作框删除按钮和缩放按钮的尺寸
  late double iconSize;

  //贴纸和图片合成之后图片的存储路径
  String? filePath;

  //已选择的图片集合的下标
  late int index;

  ImageStickerBean(AssetEntity assetEntity, int index) {
    topCoverHeight = 0;
    bottomCoverHeight = 0;
    stickerList = [];
    this.assetEntity = assetEntity;
    stickerWidth = 116.wH();
    stickerHeight = 116.wH();
    stickerDecorationWidth = 116.wH();
    stickerDecorationHeight = 116.wH();
    iconSize = 32.wH();
    this.index = index;
  }
}

StickerBean:每张贴纸的信息

class StickerBean extends Comparable<StickerBean>{
  //上次结束拖动时的偏移量
  late Offset lastOffset;

  //当前偏移量
  late Offset positionOffset;

  //当前缩放比例
  late double scale;

  //上一次结束缩放时的比例
  late double lastScale;

  //当前旋转角度
  late double rotate;

  //上一次结束旋转时的角度
  late double lastRotate;

  //是否被选中
  late bool isSelected;

  //图片路径
  late String imagePath;

  //唯一id
  late String id;

  //用户最后操作的时间戳
  late int time;

  late String stickerName;
  StickerBean.newSticker(
      {required Offset offset, required String imagePath, required id,required stickerName}) {
    positionOffset = offset;
    lastOffset = Offset.zero;
    scale = 1.0;
    lastScale = 1.0;
    rotate = 0;
    lastRotate = 0;
    isSelected = true;
    this.imagePath = imagePath;
    this.id = id;
    time=DateTime.now().millisecondsSinceEpoch;
    this.stickerName=stickerName;
  }

  @override
  int compareTo(StickerBean other) {
    //重写比较方法,最近操作的贴纸在列表的最后(stack的最顶部)
    return time.compareTo(other.time);
  }
}

贴纸图片本身的手势处理如下:

位置:根据focalPoint来计算位置

缩放比例 = 上一次的缩放比例 * 本次的缩放比例

旋转角度 = 上一次的旋转角度 + 本次的旋转角度

onScaleStart: (d) {
  //记录触摸点
  stickerBean.lastOffset = d.focalPoint;
},
onScaleUpdate: (d) {
  //计算位置信息
  stickerBean.positionOffset = stickerBean.positionOffset +
      d.focalPoint -
      stickerBean.lastOffset;
  //计算缩放比例
  stickerBean.scale = stickerBean.lastScale * d.scale;
  //计算旋转角度
  stickerBean.rotate = stickerBean.lastRotate + d.rotation;
  setState(() {});
  //重置最后的触摸点
  stickerBean.lastOffset = d.focalPoint;
},
onScaleEnd: (d) {
  //记录手势结束时的缩放比例和旋转角度,用来进行下一次缩放操作
  stickerBean.lastScale = stickerBean.scale;
  stickerBean.lastRotate = stickerBean.rotate;
}

贴纸右下角的操作按钮手势处理如下:

旋转角度 angle = currentVector2.angleToSigned(lastVector2);这里要注意向量计算的原点坐标,应该是贴纸本身的中心点坐标,而不是手机的左上角坐标。

onPanStart: (d) {
  //记录触摸点向量(向量计算的原点坐标为绝对位置原点坐标(0,0),因为图片是相对自己旋转和缩放,所以这里要把向量计算的
  // 原点坐标转化为图片中心点坐标)
  lastVector2 = Vector2(
          d.globalPosition.dx, d.globalPosition.dy) -
      getImageCenterVector(stickerBean);
},
onPanUpdate: (d) {
  //当前位置向量
  Vector2 currentVector2 = Vector2(
          d.globalPosition.dx, d.globalPosition.dy) -
      getImageCenterVector(stickerBean);
  //计算两个向量之间的角度,然后计算图片旋转角度
  double angle =
      currentVector2.angleToSigned(lastVector2);
  stickerBean.rotate = stickerBean.lastRotate - angle;
  //计算两个点相对原点坐标的距离,距离的比例即为缩放比例
  double distance1 =
      Vector2(0, 0).distanceTo(lastVector2);
  double distance2 =
      Vector2(0, 0).distanceTo(currentVector2);
  //图片缩放比例计算
  stickerBean.scale =
      stickerBean.lastScale * distance2 / distance1;
  setState(() {});
  lastVector2 = currentVector2;
  //记录缩放和旋转
  stickerBean.lastRotate = stickerBean.rotate;
  stickerBean.lastScale = stickerBean.scale;
},

贴纸随手势变化的处理如下:

Positioned(
  left: stickerBean.positionOffset.dx,
  top: stickerBean.positionOffset.dy,
  child: Transform(
    alignment: Alignment.center,
    transform: Matrix4.identity()..rotateZ(stickerBean.rotate),
    child: Container(
      width: imageStickerBean.stickerDecorationWidth * stickerBean.scale +
          imageStickerBean.iconSize,
      height: imageStickerBean.stickerDecorationHeight * stickerBean.scale +
          imageStickerBean.iconSize,
      child: Stack(
        alignment: Alignment.center,
        children: [
          Container(
            height:
                imageStickerBean.stickerDecorationWidth * stickerBean.scale,
            width: imageStickerBean.stickerDecorationHeight *
                stickerBean.scale,
            decoration: stickerBean.isSelected
                ? BoxDecoration(
                    border: Border.all(
                        color: const Color(0xfff9f9f9), width: 1.wH()))
                : null,
            child: onlyStickerView(stickerBean),
          ),
          stickerBean.isSelected
              ? Positioned(
                  top: 0,
                  left: 0,
                  child: GestureDetector(
                    onTap: () {
                      deleteSticker(stickerBean.id);
                    },
                    child: Container(
                      width: imageStickerBean.iconSize,
                      height: imageStickerBean.iconSize,
                      alignment: Alignment.center,
                      child: Image.asset(
                        ImageUtils.getImagePath(
                            "cecem_record_delete_sticker.png"),
                        width: 20.wH(),
                        height: 20.wH(),
                      ),
                    ),
                  ))
              : SizedBox(),
          stickerBean.isSelected
              ? Positioned(
                  bottom: 0,
                  right: 0,
                  child: GestureDetector(
                    onPanStart: (d) {
                      //记录触摸点向量(向量计算的原点坐标为绝对位置原点坐标(0,0),因为图片是相对自己旋转和缩放,所以这里要把向量计算的
                      // 原点坐标转化为图片中心点坐标)
                      lastVector2 = Vector2(
                              d.globalPosition.dx, d.globalPosition.dy) -
                          getImageCenterVector(stickerBean);
                    },
                    onPanUpdate: (d) {
                      //当前位置向量
                      Vector2 currentVector2 = Vector2(
                              d.globalPosition.dx, d.globalPosition.dy) -
                          getImageCenterVector(stickerBean);
                      //计算两个向量之间的角度,然后计算图片旋转角度
                      double angle =
                          currentVector2.angleToSigned(lastVector2);
                      stickerBean.rotate = stickerBean.lastRotate - angle;
                      //计算两个点相对原点坐标的距离,距离的比例即为缩放比例
                      double distance1 =
                          Vector2(0, 0).distanceTo(lastVector2);
                      double distance2 =
                          Vector2(0, 0).distanceTo(currentVector2);
                      //图片缩放比例计算
                      stickerBean.scale =
                          stickerBean.lastScale * distance2 / distance1;
                      setState(() {});
                      lastVector2 = currentVector2;
                      //记录缩放和旋转
                      stickerBean.lastRotate = stickerBean.rotate;
                      stickerBean.lastScale = stickerBean.scale;
                    },
                    child: Container(
                      width: imageStickerBean.iconSize,
                      height: imageStickerBean.iconSize,
                      alignment: Alignment.center,
                      child: Image.asset(
                        ImageUtils.getImagePath(
                            "cecem_record_modify_sticker.png"),
                        width: 20.wH(),
                        height: 20.wH(),
                      ),
                    ),
                  ))
              : SizedBox(),
        ],
      ),
    ),
  ),
)
onlyStickerView(StickerBean stickerBean) {
    return Stack(
      children: [
        Transform(
          transform: Matrix4.identity()..scale(stickerBean.scale),
          alignment: Alignment.center,
          child: GestureDetector(
            behavior: HitTestBehavior.translucent,
            onTap: () {
              selectSticker(stickerBean);
            },
            onScaleStart: (d) {
              selectSticker(stickerBean);
              //记录触摸点
              stickerBean.lastOffset = d.focalPoint;
            },
            onScaleUpdate: (d) {
              //因为在手势开始时进行了重新排序,所以要重置stickerBean的指向
              if (!stickerBean.isSelected) {
                stickerBean =
                    stickerList!.singleWhere((element) => element.isSelected);
              }
              // if(!stickerBean.isSelected){
              //   selectSticker(stickerBean);
              // }
              //计算位置信息
              stickerBean.positionOffset = stickerBean.positionOffset +
                  d.focalPoint -
                  stickerBean.lastOffset;
              //计算缩放比例
              stickerBean.scale = stickerBean.lastScale * d.scale;
              //计算旋转角度
              stickerBean.rotate = stickerBean.lastRotate + d.rotation;
              setState(() {});
              //重置最后的触摸点
              stickerBean.lastOffset = d.focalPoint;
            },
            onScaleEnd: (d) {
              //记录手势结束时的缩放比例和旋转角度,用来进行下一次缩放操作
              stickerBean.lastScale = stickerBean.scale;
              stickerBean.lastRotate = stickerBean.rotate;
            },
            child: Center(
              child: Image.asset(
                stickerBean.imagePath,
                width: imageStickerBean.stickerWidth,
                height: imageStickerBean.stickerHeight,
                fit: BoxFit.fill,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

图片的合成处理:

需要根据记录的贴纸信息stickerBean来绘制到canvas上面,绘制时需要根据贴纸的缩放比例,位置坐标,旋转角度,来计算冲贴纸在绘制中的真正位置,具体代码如下。

//图片贴纸合成
//[bean] 贴纸数据
//return 合成后的图片路径
static Future<String?> imageStickerSynthesis(ImageStickerBean bean) async {
  //如果没有添加贴纸,则直接返回原图路径
  if (ObjectUtil.isEmpty(bean.stickerList)) {
    File? file = await bean.assetEntity.loadFile();
    bean.filePath = file == null ? "" : file.path;
    return bean.filePath;
  }
  var pictureRecorder = ui.PictureRecorder();
  Canvas canvas = Canvas(pictureRecorder);
  Paint paint = Paint();
  //=======开始绘制底部图片==========
  File? file = await bean.assetEntity.loadFile(isOrigin: true);
  if (file == null) {
    return null;
  }
  ui.Image backImage = await loadFileImage(file);
  Rect backSrc = Rect.fromLTWH(
      0, 0, backImage.width.toDouble(), backImage.height.toDouble());
  Rect backDst = Rect.fromLTWH(
      0, 0, backImage.width.toDouble(), backImage.height.toDouble());
  canvas.drawImageRect(backImage, backSrc, backDst, paint);
  //=======绘制底部图片结束==========
  //=======开始绘制贴纸=======
  //计算出图片的真实宽高与计算宽高的比例(绘制是通过真实宽高计算缩放和偏移)
  double imageScale = backImage.width.toDouble() / bean.width!;
  //循环绘制出所有贴纸
  for (StickerBean stickerBean in bean.stickerList) {
    canvas.save();
    ui.Image stickerImage = await loadAssetImage(stickerBean.imagePath);
    //计算出当前贴纸的中心坐标
    double centerDx = (stickerBean.positionOffset.dx +
            (bean.iconSize +
                    bean.stickerDecorationWidth * stickerBean.scale) /
                2) *
        imageScale;
    double centerDy = (stickerBean.positionOffset.dy +
            (bean.iconSize +
                    bean.stickerDecorationHeight * stickerBean.scale) /
                2) *
        imageScale;
    //将画布的中心移动到贴纸的中心
    canvas.translate(centerDx, centerDy);
    //旋转画布,角度为贴纸旋转的角度
    canvas.rotate(stickerBean.rotate);
    //计算绘制贴纸的坐标(贴纸左上角)
    double stickerDx =
        -bean.stickerWidth * stickerBean.scale * imageScale / 2;
    double stickerDy =
        -bean.stickerHeight * stickerBean.scale * imageScale / 2;
    Rect src = Rect.fromLTWH(
        0, 0, stickerImage.width.toDouble(), stickerImage.height.toDouble());
    //绘制图片rect
    Rect dst = Rect.fromLTWH(
        stickerDx,
        stickerDy,
        bean.stickerWidth * stickerBean.scale * imageScale,
        bean.stickerHeight * stickerBean.scale * imageScale);
    //绘制贴纸
    canvas.drawImageRect(stickerImage, src, dst, paint);
    //保存此次绘制图层并重置画布
    canvas.restore();
  }
  //将绘制内容转为Uint8List数据
  ui.Image showImage = await pictureRecorder
      .endRecording()
      .toImage(backImage.width, backImage.height);
  ByteData? pngImageBytes =
      await showImage.toByteData(format: ui.ImageByteFormat.png);
  Uint8List pngBytes = pngImageBytes!.buffer.asUint8List();
  //将Uint8List数据写入文件
  bean.filePath = await writeToFile(pngBytes);
  return bean.filePath;
}

//将Uint8List写入file
static Future<String> writeToFile(Uint8List bytes) async {
  Directory directory = await FileUtil.getAppTemporaryDirectory();
  String path = directory.path +
      "/cece_sticker_${DateTime.now().millisecondsSinceEpoch}.png";
  File(path).writeAsBytes(bytes);
  return path;
}

//加载资源图片到内存中
static Future<ui.Image> loadAssetImage(String path) async {
  // 加载资源文件
  final data = await rootBundle.load(path);
  // 把资源文件转换成Uint8List类型
  final bytes = data.buffer.asUint8List();
  // 解析Uint8List类型的数据图片
  final image = await decodeImageFromList(bytes);
  return image;
}

//加载file图片到内存中
static Future<ui.Image> loadFileImage(File file) async {
  // 通过字节的方式读取本地文件
  final bytes = await file.readAsBytes();
  // 解析图片资源
  final image = await decodeImageFromList(bytes);
  return image;
}

以上就是贴纸实现的关键代码,已经基本实现了微博贴纸的功能。

github传送门:github.com/fish89757/a…