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