相关阅读
- Flutter 什么功能都有的Image
- Flutter 可以缩放拖拽的图片
- Flutter 仿掘金微信图片滑动退出页面效果
- Flutter 图片裁剪旋转翻转编辑器
- Flutter 图片全家桶
- Flutter 什么,微信现在才支持实况图!?
在线演示: fluttercandies.github.io/extended_im…
关注微信公众号 糖果代码铺 ,获取 Flutter 最新动态。
前言
1000万点赞以内最好的图片裁剪组件,冰箱彩电都有才是最大的豪华!
经过 5 年 140 次的更新迭代,组件版本号已经来到了 9 字开头,修复 版本号过低的 bug 。
但是一些需求,我一直都记在心中,虽然还是迟了很久。
- 编辑模式任意角度旋转
- 编辑模式裁剪框自动旋转可选
- 编译模式翻转旋转动画
- 编辑模式支持撤消、重做
最新的版本 extended_image | Flutter package (flutter-io.cn) 对上面的需求进行了支持,编辑模式功能得到进一步增强。
更多细节可查看在线演示: fluttercandies.github.io/extended_im…
原理
相比之前只支持 90 度旋转,自由角度旋转几乎推翻了之前的代码逻辑。
绘制
计算图片应该绘制到的区域(destinationRect)
在官方的 paintImage 方法中,
github.com/flutter/flu…
通过一系列的计算,我们能得到图片需要绘制到什么区域(destinationRect).
而编辑模式影响 destinationRect 的主要是用户对图片进行了移动和缩放。
我们通过 destinationRect 加工,附加上对应移动的距离和宽高的缩放,即可得到图片最终在屏幕上面的位置和区域。
翻转和旋转
当然,上一步的前提是没有做翻转和旋转。如果有设置,那么我们在绘制图片之前需要对 Canvas 做对应的翻转和旋转。
Matrix4 getTransform({Offset? center}) {
final Offset origin =
center ?? _layoutRect?.center ?? _screenDestinationRect!.center;
final Matrix4 result = Matrix4.identity();
result.translate(
origin.dx,
origin.dy,
);
if (rotationYRadians != 0) {
result.multiply(Matrix4.rotationY(rotationYRadians));
}
if (hasRotateDegrees) {
result.multiply(Matrix4.rotationZ(rotateRadians));
}
result.translate(-origin.dx, -origin.dy);
return result;
}
canvas.transform(editActionDetails.getTransform().storage);
实际上,通过这步处理之后,在屏幕上面,你可以理解图片的轮廓其实一个 Path,这个 Path 就是你屏幕上面能看到图片,得到这个 Path 就可以很容易的跟裁剪框进行联动了。
Path getImagePath({Rect? rect}) {
rect ??= _screenDestinationRect!;
final Matrix4 result = getTransform();
final List<Offset> corners = <Offset>[
rect.topLeft,
rect.topRight,
rect.bottomRight,
rect.bottomLeft,
];
final List<Offset> rotatedCorners = corners.map((Offset corner) {
final Vector4 cornerVector = Vector4(corner.dx, corner.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
return Path()
..moveTo(rotatedCorners[0].dx, rotatedCorners[0].dy)
..lineTo(rotatedCorners[1].dx, rotatedCorners[1].dy)
..lineTo(rotatedCorners[2].dx, rotatedCorners[2].dy)
..lineTo(rotatedCorners[3].dx, rotatedCorners[3].dy)
..close();
}
边界处理
由于裁剪框和图片已经不是 2 个单纯的矩形关系,对于边界的判断,需要一些技巧。
旋转边界处理
当对图片进行旋转的时候,你需要考虑旋转之后图片是否超出了裁剪框(你也可以认为裁剪框是否在图片的外面),如果有超出裁剪框,我们可以通过放大图片,来使图片来完全包含裁剪框。
至于怎么来计算,我先画个图。
红色的框代表裁剪框,黑色的框代表图片。可以看到裁剪框的右上角和右下角超出了图片的区域。
通过图片的中心到右下角点连线,已经平行于图片长宽的2条线,组成直角三角形, L1 为图片的长/宽的一半,L2 为图片中心到直角三角形顶点的距离。
很容易得出结论,如果图片想要包含裁剪框的右下角,图片需要放大 L2/L1 。 但是这个 L2 我们怎么计算呢?我旋转一下。你就明白了。
旋转之后,图片的区域即 destinationRect,这是我们前面步骤得到的,而裁剪框,其实做了跟图片相同的旋转。所以我们可以将裁剪框的 4 个顶点做旋转,得到图片包含顶点所需要的缩放比例,取它们当中最大的即可。
double scaleToFitCropRect() {
final Matrix4 result = getTransform();
result.invert();
final Rect rect = _screenDestinationRect!;
final List<Offset> rectVertices = <Offset>[
screenCropRect!.topLeft,
screenCropRect!.topRight,
screenCropRect!.bottomRight,
screenCropRect!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
final double scaleDelta = scaleToFit(
rectVertices,
rect,
rect.center,
);
return scaleDelta;
}
判断它们是否在图片的区域,如果没有的话,根据顶点到中心的距离计算出来缩放,取最大的值。
double scaleToFit(
List<Offset> rectVertices,
Rect rect,
Offset center,
) {
double scaleDelta = 0.0;
int contains = 0;
for (final Offset element in rectVertices) {
if (rect.containsOffset(element)) {
contains++;
continue;
}
final double x = (element.dx - center.dx).abs();
final double y = (element.dy - center.dy).abs();
final double halfWidth = rect.width / 2;
final double halfHeight = rect.height / 2;
if (x > halfWidth || y > halfHeight) {
scaleDelta = max(scaleDelta, max(x / halfWidth, y / halfHeight));
}
}
if (contains == 4) {
return -1;
}
return scaleDelta;
}
如果缩放比例大于 0 ,判断是否缩放之后,超过了最大缩放限制,如果超过了,就通过缩小裁剪框来满足条件。
void updateRotateRadians(double rotateRadians, double maxScale) {
this.rotateRadians = rotateRadians;
final double scaleDelta = scaleToFitCropRect();
if (scaleDelta > 0) {
// can't scale image, so we move image to fit crop rect
if (totalScale * scaleDelta > maxScale) {
// can't scale image
// so we should scale the crop rect
if (totalScale * scaleDelta > maxScale) {
screenFocalPoint = null;
preTotalScale = totalScale;
totalScale = maxScale;
getFinalDestinationRect();
scaleDelta = scaleToFitImageRect();
if (scaleDelta > 0) {
cropRect = Rect.fromCenter(
center: cropRect!.center,
width: cropRect!.width * scaleDelta,
height: cropRect!.height * scaleDelta,
);
} else {
updateDelta(Offset.zero);
}
} else {
screenFocalPoint = _screenDestinationRect!.center;
preTotalScale = totalScale;
totalScale = totalScale * scaleDelta;
}
}
}
scaleToFitImageRect 是用来计算需要缩小多少裁剪框才能满足边界条件的方法。比如裁剪框的顶点 A 超出了图片的区域,那么连接顶点 A 到裁剪框的中心点的这一条线与图片区域的交点 A1,即为对于顶点 A 来说,裁剪框需要缩小多少才能满足顶点 A 包含在图片区域里面。_scaleToFitImageRect 即完成该算法,A1 到中心的距离除以 A 到中心的距离就是需要缩小的比例
double scaleToFitImageRect() {
final Matrix4 result = getTransform();
result.invert();
final Rect rect = _screenDestinationRect!;
final List<Offset> rectVertices = <Offset>[
screenCropRect!.topLeft,
screenCropRect!.topRight,
screenCropRect!.bottomRight,
screenCropRect!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
final double scaleDelta = _scaleToFitImageRect(
rectVertices,
rect,
rect.center,
);
return scaleDelta;
}
double _scaleToFitImageRect(
List<Offset> rectVertices,
Rect rect,
Offset center,
) {
double scaleDelta = double.maxFinite;
final Offset cropRectCenter = (rectVertices[0] + (rectVertices[2])) / 2;
int contains = 0;
for (final Offset element in rectVertices) {
if (rect.containsOffset(element)) {
contains++;
continue;
}
final List<Offset> list =
getLineRectIntersections(rect, element, cropRectCenter);
if (list.isNotEmpty) {
scaleDelta = min(
scaleDelta,
sqrt(pow(list[0].dx - cropRectCenter.dx, 2) +
pow(list[0].dy - cropRectCenter.dy, 2)) /
sqrt(pow(element.dx - cropRectCenter.dx, 2) +
pow(element.dy - cropRectCenter.dy, 2)));
}
}
if (contains == 4) {
return -1;
}
return scaleDelta;
}
移动图片边界处理
当移动图片的时候,我们要确保,图片始终包含裁剪框。
值得注意的是从手势获取到的平移量 Offset ,会受到翻转和旋转的影响,我们需要首先对输入的平移量做处理。
double dx = delta.dx;
final double dy = delta.dy;
if (rotationYRadians == pi) {
dx = -dx;
}
final double transformedDx =
dx * cos(rotateRadians) + dy * sin(rotateRadians);
final double transformedDy =
dy * cos(rotateRadians) - dx * sin(rotateRadians);
Offset offset = Offset(transformedDx, transformedDy);
接下来就是计算,我还是先画个图,红色的框代表裁剪框,黑色的框代表图片。可以看到裁剪框的右上角和右下角超出了图片的区域。
如果是图片位置不动的话,如果我们以右上角跟到图片边最近的点做一条连线(垂直线),这个最近的点,即裁剪框右上角最多能达到的位置。图中黑色的裁剪框,即为裁剪框和图片最多能达到的状态。图中的 L 距离,即图片需要回退的距离。
我们首先还是先旋转裁剪框。
Rect rect = _screenDestinationRect!.shift(offset);
final Matrix4 result = getTransform();
result.invert();
final List<Offset> rectVertices = <Offset>[
screenCropRect!.topLeft,
screenCropRect!.topRight,
screenCropRect!.bottomRight,
screenCropRect!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
判断点是否在图片内部,即为点垂直于图片边上的那个点(即为最近的点)。将图片平移量减去 最近点 - 裁剪框顶点。
for (final Offset element in rectVertices) {
if (rect.containsOffset(element)) {
continue;
}
// find nearest point on rect
final double nearestX = element.dx.clamp(rect.left, rect.right);
final double nearestY = element.dy.clamp(rect.top, rect.bottom);
final Offset nearestOffset = Offset(nearestX, nearestY);
if (nearestOffset != element) {
offset -= nearestOffset - element;
rect = _screenDestinationRect = _screenDestinationRect!.shift(offset);
// clear
offset = Offset.zero;
}
}
this.delta += offset;
完整的计算代码如下:
void updateDelta(Offset delta) {
double dx = delta.dx;
final double dy = delta.dy;
if (rotationYRadians == pi) {
dx = -dx;
}
final double transformedDx =
dx * cos(rotateRadians) + dy * sin(rotateRadians);
final double transformedDy =
dy * cos(rotateRadians) - dx * sin(rotateRadians);
Offset offset = Offset(transformedDx, transformedDy);
Rect rect = _screenDestinationRect!.shift(offset);
final Matrix4 result = getTransform();
result.invert();
final List<Offset> rectVertices = <Offset>[
screenCropRect!.topLeft,
screenCropRect!.topRight,
screenCropRect!.bottomRight,
screenCropRect!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
for (final Offset element in rectVertices) {
if (rect.containsOffset(element)) {
continue;
}
// find nearest point on rect
final double nearestX = element.dx.clamp(rect.left, rect.right);
final double nearestY = element.dy.clamp(rect.top, rect.bottom);
final Offset nearestOffset = Offset(nearestX, nearestY);
if (nearestOffset != element) {
offset -= nearestOffset - element;
rect = _screenDestinationRect = _screenDestinationRect!.shift(offset);
// clear
offset = Offset.zero;
}
}
this.delta += offset;
}
缩放图片边界处理
缩放和平移类似,不过是多一步计算 destinationRect 的步骤。
通过缩放比例的差值,计算出来如果按照这个缩放比例,计算出来图片最终的 destinationRect。
final double scaleDelta = totalScale / preTotalScale;
if (scaleDelta == 1.0) {
return;
}
Offset focalPoint = screenFocalPoint ?? _screenDestinationRect!.center;
focalPoint = Offset(
focalPoint.dx
.clamp(_screenDestinationRect!.left, _screenDestinationRect!.right)
.toDouble(),
focalPoint.dy
.clamp(_screenDestinationRect!.top, _screenDestinationRect!.bottom)
.toDouble(),
);
Rect rect = Rect.fromLTWH(
focalPoint.dx -
(focalPoint.dx - _screenDestinationRect!.left) * scaleDelta,
focalPoint.dy -
(focalPoint.dy - _screenDestinationRect!.top) * scaleDelta,
_screenDestinationRect!.width * scaleDelta,
_screenDestinationRect!.height * scaleDelta);
接着还是翻转裁剪框的 4 个顶点,判断它们是否在图片的区域,不在的话,还是找出顶点到图片矩形的最小距离,找到裁剪框在图片矩形上的点。
final Matrix4 result = getTransform();
result.invert();
final List<Offset> rectVertices = <Offset>[
screenCropRect!.topLeft,
screenCropRect!.topRight,
screenCropRect!.bottomRight,
screenCropRect!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
bool fixed = false;
for (final Offset element in rectVertices) {
if (rect.containsOffset(element)) {
continue;
}
// find nearest point on rect
final double nearestX = element.dx.clamp(rect.left, rect.right);
final double nearestY = element.dy.clamp(rect.top, rect.bottom);
final Offset nearestOffset = Offset(nearestX, nearestY);
if (nearestOffset != element) {
fixed = true;
rect = rect.shift(-(nearestOffset - element));
}
}
for (final Offset element in rectVertices) {
if (!rect.containsOffset(element)) {
return;
}
}
if (fixed == true) {
_screenDestinationRect = rect;
// scale has already apply
preTotalScale = totalScale;
}
this.totalScale = totalScale;
}
拖动裁剪框大小时边界处理
当我们拖动裁剪框四个顶点的时候,要确保 4 个顶点不能超出图片区域。
如果我们把一个角拖出了图片的区域,我们应该怎么恢复它。
还是先画个图,红色的框代表裁剪框,黑色的框代表图片。可以看到 B 和 D 超出了图片区域。我们以 B 为例子。
由于裁剪框大部分情况是按照一定的长宽比例来进行拖动的(除了 custom 外,这种情况只需要满足 4 个点在图片区域即可,这种情况也包含在了下面算法中),这就是说,对于 B 点来说,我只需要把 B 移动到图片区域中,并且保持裁剪框的比例即可。
class CropAspectRatios {
/// No aspect ratio for crop; free-form cropping is allowed.
static const double? custom = null;
/// The same as the original aspect ratio of the image.
/// if it's equal or less than 0, it will be treated as original.
static const double original = 0.0;
/// Aspect ratio of 1:1 (square).
static const double ratio1_1 = 1.0;
/// Aspect ratio of 3:4 (portrait).
static const double ratio3_4 = 3.0 / 4.0;
/// Aspect ratio of 4:3 (landscape).
static const double ratio4_3 = 4.0 / 3.0;
/// Aspect ratio of 9:16 (portrait).
static const double ratio9_16 = 9.0 / 16.0;
/// Aspect ratio of 16:9 (landscape).
static const double ratio16_9 = 16.0 / 9.0;
}
我们以 B 和 裁剪框的中心连线,它们和图片区域相交的那个点 B1 就是我们要找的点。这个时候,我们就得到另一个新的裁剪框, A1,B1,D1,C,对于这个矩形,我们其实只要知道 C 和 B1 就可以了。
首先,我们还是先翻转裁剪框的 4 个顶点。
Rect screenCropRect = cropRect.shift(layoutTopLeft!);
final Matrix4 result = getTransform();
result.invert();
final Rect rect = _screenDestinationRect!;
final List<Offset> rectVertices = <Offset>[
screenCropRect.topLeft,
screenCropRect.topRight,
screenCropRect.bottomRight,
screenCropRect.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
查看是否在图片区域,如果不在,找到 2 个顶点之间的中心,计算出 2 点和图片矩形的交点。
final List<Offset> list = rectVertices.toList();
bool hasOffsetOutSide = false;
for (int i = 0; i < rectVertices.length; i++) {
final Offset element = rectVertices[i];
if (rect.containsOffset(element)) {
continue;
}
late final Offset other = rectVertices[(i + 2) % 4];
final Offset center = (element + other) / 2;
final List<Offset> lineRectIntersections =
getLineRectIntersections(_screenDestinationRect!, element, center);
if (lineRectIntersections.isNotEmpty) {
hasOffsetOutSide = true;
list[i] = lineRectIntersections.first;
}
}
下面是获得交点的算法。
Offset? getIntersection(Offset p1, Offset p2, Offset p3, Offset p4) {
final double s1X = p2.dx - p1.dx;
final double s1Y = p2.dy - p1.dy;
final double s2X = p4.dx - p3.dx;
final double s2Y = p4.dy - p3.dy;
final double s = (-s1Y * (p1.dx - p3.dx) + s1X * (p1.dy - p3.dy)) /
(-s2X * s1Y + s1X * s2Y);
final double t = (s2X * (p1.dy - p3.dy) - s2Y * (p1.dx - p3.dx)) /
(-s2X * s1Y + s1X * s2Y);
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
final double intersectionX = p1.dx + (t * s1X);
final double intersectionY = p1.dy + (t * s1Y);
return Offset(intersectionX, intersectionY);
}
return null;
}
List<Offset> getLineRectIntersections(Rect rect, Offset p1, Offset p2) {
final List<Offset> intersections = <Offset>[];
final Offset topLeft = Offset(rect.left, rect.top);
final Offset topRight = Offset(rect.right, rect.top);
final Offset bottomLeft = Offset(rect.left, rect.bottom);
final Offset bottomRight = Offset(rect.right, rect.bottom);
final Offset? topIntersection = getIntersection(p1, p2, topLeft, topRight);
if (topIntersection != null) {
intersections.add(topIntersection);
}
final Offset? bottomIntersection =
getIntersection(p1, p2, bottomLeft, bottomRight);
if (bottomIntersection != null) {
intersections.add(bottomIntersection);
}
final Offset? leftIntersection =
getIntersection(p1, p2, topLeft, bottomLeft);
if (leftIntersection != null) {
intersections.add(leftIntersection);
}
final Offset? rightIntersection =
getIntersection(p1, p2, topRight, bottomRight);
if (rightIntersection != null) {
intersections.add(rightIntersection);
}
return intersections;
}
如果有超出图片区域的点,那么我们将计算出来新的 4 个顶点通过逆转换,转换成正常坐标系的点。因为这 4 个点都是满足在图片区域的,我们通过 Rect.fromPoints(newOffsets[0], newOffsets[2]) 和 Rect.fromPoints(newOffsets[1], newOffsets[3]) 得到 2 个新的裁剪框,面积更小的就是我们需要新的裁剪框(因为它们组成的矩形的中心是相同的,所以面积小的能满足 4 个顶点都在图片区域的最终条件)。
if (hasOffsetOutSide) {
result.invert();
final List<Offset> newOffsets = list.map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
final Rect rect1 = Rect.fromPoints(newOffsets[0], newOffsets[2]);
final Rect rect2 = Rect.fromPoints(newOffsets[1], newOffsets[3]);
if (rect1.size < rect2.size) {
screenCropRect = rect1;
} else {
screenCropRect = rect2;
}
}
改变裁剪框长宽比例边界处理
当动态改变裁剪框的长宽比例的时候,需要考虑当前图片的状态。
_getNewCropRect 方法根据 InitCropRectType 参数,最初的图片 layoutRect 区域以及图片的最初渲染位置 destinationRect 获取出来最初的裁剪框在屏幕上面的位置。
enum InitCropRectType {
/// Crop rectangle is based on the image's original boundaries.
imageRect,
/// Crop rectangle is based on the image's layout dimensions
layoutRect,
}
ui.Rect _getNewCropRect(
ui.Rect layoutRect,
BuildContext context, {
bool autoScale = true,
InitCropRectType? initCropRectType,
}) {
final EdgeInsets padding = _editorConfig!.cropRectPadding;
layoutRect = padding.deflateRect(layoutRect);
if (_editActionDetails!.cropRect == null) {
final AlignmentGeometry alignment =
widget.extendedImageState.imageWidget.alignment;
//matchTextDirection: extendedImage.matchTextDirection,
//don't support TextDirection for editor
final TextDirection? textDirection =
//extendedImage.matchTextDirection ||
alignment is! Alignment ? Directionality.of(context) : null;
final Alignment resolvedAlignment = alignment.resolve(textDirection);
final Rect destinationRect = getDestinationRect(
rect: layoutRect,
inputSize: Size(
widget.extendedImageState.extendedImageInfo!.image.width
.toDouble(),
widget.extendedImageState.extendedImageInfo!.image.height
.toDouble()),
flipHorizontally: false,
fit: widget.extendedImageState.imageWidget.fit,
centerSlice: widget.extendedImageState.imageWidget.centerSlice,
alignment: resolvedAlignment,
scale: widget.extendedImageState.extendedImageInfo!.scale);
Rect cropRect = _initCropRect(destinationRect);
initCropRectType ??= _editorConfig!.initCropRectType;
if (initCropRectType == InitCropRectType.layoutRect &&
_editActionDetails!.cropAspectRatio != null &&
_editActionDetails!.cropAspectRatio! > 0) {
final Rect rect = _initCropRect(layoutRect);
// layout rect is bigger than image rect
// it should scale the image to conver crop rect
if (autoScale) {
_editActionDetails!.totalScale = _editActionDetails!.preTotalScale =
destinationRect.width.greaterThan(destinationRect.height)
? rect.height / cropRect.height
: rect.width / cropRect.width;
}
cropRect = rect;
}
_editActionDetails!.cropRect = cropRect;
}
return layoutRect;
}
利用之前旋转图片时候边界的计算算法,保证裁剪框在图片的区域。
ui.Rect _recalculateCropRect() {
if (_editActionDetails == null) {
return Rect.zero;
}
_editActionDetails?.cropRect = null;
// re-init crop rect
Rect layoutRect = Offset.zero & _editActionDetails!.layoutRect!.size;
final ui.Rect sreenImageRect =
_editActionDetails!.getImagePath().getBounds();
layoutRect = _getNewCropRect(
layoutRect,
context,
autoScale: false,
initCropRectType:
_editActionDetails!.layoutRect!.containsRect(sreenImageRect)
? InitCropRectType.imageRect
: InitCropRectType.layoutRect,
);
_editActionDetails!.scaleToFitRect(_editorConfig!.maxScale);
return _editActionDetails!.cropRect!;
}
旋转裁剪框边界处理
有一种特殊的情况,当以 90 度进行图片旋转,裁剪框默认是跟着旋转的,这个时候裁剪框旋转要注意边界处理。
而这个时候我们是对裁剪框的区域进行限制,而它的限制是 layoutRect (该区域是去掉 cropRectPadding)的。我们旋转裁剪框,如果发现裁剪框有超出 layoutRect 的部分,那么我们需要缩小裁剪框。
之前我们利用 scaleToFit 计算来了当图片旋转的时候,图片需要放大多少才能完全包含裁剪框。这里我们可以利用这个方法,来计算裁剪框需要缩小多少,才能完全包含在 layoutRect 当中。
最终需要缩小的比例为 1 / (scaleToFit 计算出的值)。
double scaleDelta = widget.editActionDetails.scaleToFit(
rectVertices,
layoutRect,
layoutRect.center,
);
if (scaleDelta <= 0) {
return;
}
// not out of layout rect
scaleDelta = 1 / scaleDelta;
完整代码如下:
void rotateCropRect(double rotateRadiansDelta) {
setState(() {
if (widget.editActionDetails.flipY) {
rotateRadiansDelta = -rotateRadiansDelta;
}
final Offset origin = _cropRectStart!.center;
final Matrix4 result = Matrix4.identity();
_rotateRadians += rotateRadiansDelta;
result.translate(
origin.dx,
origin.dy,
);
if (widget.editActionDetails.flipY) {
result.multiply(
Matrix4.rotationY(widget.editActionDetails.rotationYRadians));
}
result.multiply(Matrix4.rotationZ(_rotateRadians));
result.translate(-origin.dx, -origin.dy);
final List<Offset> rectVertices = <Offset>[
_cropRectStart!.topLeft,
_cropRectStart!.topRight,
_cropRectStart!.bottomRight,
_cropRectStart!.bottomLeft,
].map((Offset element) {
final Vector4 cornerVector = Vector4(element.dx, element.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
double scaleDelta = widget.editActionDetails.scaleToFit(
rectVertices,
layoutRect,
layoutRect.center,
);
if (scaleDelta <= 0) {
return;
}
// not out of layout rect
scaleDelta = 1 / scaleDelta;
cropRect = Rect.fromCenter(
center: _cropRectStart!.center,
width: _cropRectStart!.width * scaleDelta,
height: _cropRectStart!.height * scaleDelta,
);
widget.editActionDetails.screenFocalPoint =
widget.editActionDetails.screenCropRect?.center;
widget.editActionDetails.totalScale = _totalScale * scaleDelta;
});
}
裁剪框自动调整到中心处理
到通过拖动修改裁剪框之后,会将裁剪框移动等比例移动到中心,并且图片也跟随平移和缩放。
裁剪框 A 会移动到 A1,A 对应的图片的状态是黑色的框。那么你觉得 A1 对应的新的图片的位置是怎么样的呢?
根据我们画的辅助线,裁剪框 A 到 A1 是等比例缩放平移的,那么对应到图片上面它应该也是等比例缩放平移。我们从 A 中心画 4 条线到图片的 4 个顶点,很容易想到, A 中心到图片 4 个顶点的距离和 A1 中心到新图片 4 个顶点的距离也是成等比例缩放的。
首先,我们还是利用 paintImage 里面的方法,获取到裁剪框如果居中,应该在哪里。
final Rect centerCropRect = getDestinationRect(
rect: layoutRect, inputSize: result.size, fit: widget.fit);
final Rect newScreenCropRect =
centerCropRect.shift(widget.editActionDetails.layoutTopLeft!);
新旧裁剪框的缩放比例为两者宽相比。
final double scale = newScreenCropRect!.width / oldScreenCropRect.width;
通过翻转旋转得到旧图片在屏幕上面的 4 个顶点。
final Matrix4 result = widget.editActionDetails.getTransform();
final List<Offset> corners = <Offset>[
oldScreenDestinationRect.topLeft,
oldScreenDestinationRect.topRight,
oldScreenDestinationRect.bottomRight,
oldScreenDestinationRect.bottomLeft,
];
// get image corners on screen
final List<Offset> rotatedCorners = corners.map((Offset corner) {
final Vector4 cornerVector = Vector4(corner.dx, corner.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
4 个顶点到到旧裁剪框中心的距离乘以缩放比例再加上新裁剪框的中心就得到了新的图片在屏幕上面的 4 个顶点。
(corner - oldScreenCropRect.center) * scale + newScreenCropRect!.center)
再通过逆转换将屏幕上面的 4 个顶点转换回原始的正常坐标系的图片区域矩形。
// rock back to image rect
result.invert();
final List<Offset> list = rotatedCorners
.map((Offset corner) =>
// The distance from the four corners of the image to the center of the cropping box
// old distance is scale to new distance
// So we can find the new four corners
(corner - oldScreenCropRect.center) * scale +
newScreenCropRect!.center)
.map((Offset corner) {
// rock back to image rect
final Vector4 cornerVector = Vector4(corner.dx, corner.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
完整代码:
void _doCropAutoCenterAnimation({Rect? newScreenCropRect}) {
if (mounted) {
setState(() {
final Rect oldScreenCropRect = widget.editActionDetails.screenCropRect!;
final Rect oldScreenDestinationRect =
widget.editActionDetails.screenDestinationRect!;
newScreenCropRect ??= _rectAnimation!.value;
final double scale = newScreenCropRect!.width / oldScreenCropRect.width;
final Matrix4 result = widget.editActionDetails.getTransform();
final List<Offset> corners = <Offset>[
oldScreenDestinationRect.topLeft,
oldScreenDestinationRect.topRight,
oldScreenDestinationRect.bottomRight,
oldScreenDestinationRect.bottomLeft,
];
// get image corners on screen
final List<Offset> rotatedCorners = corners.map((Offset corner) {
final Vector4 cornerVector = Vector4(corner.dx, corner.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
// rock back to image rect
result.invert();
final List<Offset> list = rotatedCorners
.map((Offset corner) =>
// The distance from the four corners of the image to the center of the cropping box
// old distance is scale to new distance
// So we can find the new four corners
(corner - oldScreenCropRect.center) * scale +
newScreenCropRect!.center)
.map((Offset corner) {
// rock back to image rect
final Vector4 cornerVector = Vector4(corner.dx, corner.dy, 0.0, 1.0);
final Vector4 newCornerVector = result.transform(cornerVector);
return Offset(newCornerVector.x, newCornerVector.y);
}).toList();
// new image rect
final Rect newScreenDestinationRect = Rect.fromPoints(list[0], list[2]);
cropRect =
newScreenCropRect!.shift(-widget.editActionDetails.layoutTopLeft!);
final double totalScale = widget.editActionDetails.totalScale * scale;
widget.editActionDetails
.setScreenDestinationRect(newScreenDestinationRect);
widget.editActionDetails.totalScale = totalScale;
widget.editActionDetails.preTotalScale = totalScale;
if (_rectTweenController.isCompleted) {
widget.cropAutoCenterAnimationIsCompleted();
}
});
}
}
动画
这次主要增加了旋转和翻转的时候的动画。
翻转
即 rotationY 从 0 到 pi 或者 从 pi 到 0 的过程,使用 AnimationController 做动画即可。
if (rotationYRadians != 0) {
result.multiply(Matrix4.rotationY(rotationYRadians));
}
旋转
对 rotateRadians 的新值旧值做动画即可。
if (hasRotateDegrees) {
result.multiply(Matrix4.rotationZ(rotateRadians));
}
裁剪框自动移动到中心
在拖动裁剪框结束之后,对新旧裁剪框的矩形做动画即可。
_rectAnimation = _rectTweenController.drive<Rect?>(
RectTween(begin: oldScreenCropRect, end: newScreenCropRect));
撤销、重做
对它们的支持,主要是支持保存历史。即在每个动作之后,做保存当前状态的操作。
这里要注意的是不管是什么动画,都应该只保存动画结束的时候那个状态。
int _currentIndex = -1;
void _saveCurrentState() {
final Offset? screenCropRectCenter =
_editActionDetails?.screenCropRect?.center;
final Offset? rawDestinationRectCenter =
_editActionDetails?.rawDestinationRect?.center;
// crop rect auto center isAnimating
if (!screenCropRectCenter.isSame(rawDestinationRectCenter)) {
return;
}
// new edit action details
// clear redo history
//
if (_currentIndex + 1 < _history.length) {
_history.removeRange(_currentIndex + 1, _history.length);
}
_history.add(_editActionDetails!.copyWith());
_currentIndex = _history.length - 1;
_safeUpdate(() {
_editorConfig!.controller?._notifyListeners();
});
}
屏幕上裁剪状态对应到图片物理状态的处理
我们做这么多事情,最终还是希望能把实际的图片按照屏幕上面的状态裁剪出来。那么我们应该怎么做呢?
通过屏幕上面的图片和裁剪框,我们可以得到 2 个数据,一个是代表图片的 Path ,一个是裁剪框的 Rect。我们需要将两者的关系跟实际物理图片关联起来。
获取图片在屏幕上面的图形位置
通过 getImagePath 获取到屏幕上面的实际位置, path.getBounds() 获取真实的区域,即图中虚线画的矩形。这样子就变成 2 个矩形相对位置的计算,是不是就觉得简单多了。
获取之后,平移 -imageScreenRect.topLeft ,即 2 个矩形相对于图片的 Offset(0,0) 来的。
final Path path = _editActionDetails!.getImagePath();
imageScreenRect = path.getBounds();
// move to zero
cropScreen = cropScreen.shift(-imageScreenRect.topLeft);
imageScreenRect = imageScreenRect.shift(-imageScreenRect.topLeft);
获取物理图片旋转翻转之后的矩形
接下来,我们对物理图片的矩形也做和屏幕图片上一样的旋转翻转,最终也得到物理图片的矩形。
// rotate Physical rect
Rect physicalimageRect = Offset.zero &
Size(
image.width.toDouble(),
image.height.toDouble(),
);
final Path physicalimagePath =
_editActionDetails!.getImagePath(rect: physicalimageRect);
physicalimageRect = physicalimagePath.getBounds();
获得物理图片的裁剪框的位置
最终对应物理图片的裁剪框,即根据它们的等比例关系获得。
final double ratioX = physicalimageRect.width / imageScreenRect.width;
final double ratioY = physicalimageRect.height / imageScreenRect.height;
final Rect cropImageRect = Rect.fromLTWH(
cropScreen.left * ratioX,
cropScreen.top * ratioY,
cropScreen.width * ratioX,
cropScreen.height * ratioY,
);
完整代码:
Rect? getCropRect() {
if (widget.extendedImageState.extendedImageInfo?.image == null ||
_editActionDetails == null) {
return null;
}
Rect? cropScreen = _editActionDetails!.screenCropRect;
Rect? imageScreenRect = _editActionDetails!.screenDestinationRect;
if (cropScreen == null || imageScreenRect == null) {
return null;
}
final Path path = _editActionDetails!.getImagePath();
imageScreenRect = path.getBounds();
// move to zero
cropScreen = cropScreen.shift(-imageScreenRect.topLeft);
imageScreenRect = imageScreenRect.shift(-imageScreenRect.topLeft);
final ui.Image image = widget.extendedImageState.extendedImageInfo!.image;
// rotate Physical rect
Rect physicalimageRect = Offset.zero &
Size(
image.width.toDouble(),
image.height.toDouble(),
);
final Path physicalimagePath =
_editActionDetails!.getImagePath(rect: physicalimageRect);
physicalimageRect = physicalimagePath.getBounds();
final double ratioX = physicalimageRect.width / imageScreenRect.width;
final double ratioY = physicalimageRect.height / imageScreenRect.height;
final Rect cropImageRect = Rect.fromLTWH(
cropScreen.left * ratioX,
cropScreen.top * ratioY,
cropScreen.width * ratioX,
cropScreen.height * ratioY,
);
return cropImageRect;
}
一些注意点
比较精度
double 有精度问题,所以计算当中的比较,我是通过下面扩展处理的, 两者的差值的绝对值小于 precisionErrorTolerance(1e-10),即认为它们相等。
extension DoubleExtension on double {
bool get isZero => abs() < precisionErrorTolerance;
int compare(double other, {double precision = precisionErrorTolerance}) {
if (isNaN || other.isNaN) {
throw UnsupportedError('Compared with Infinity or NaN');
}
final double n = this - other;
if (n.abs() < precision) {
return 0;
}
return n < 0 ? -1 : 1;
}
bool greaterThan(double other, {double precision = precisionErrorTolerance}) {
return compare(other, precision: precision) > 0;
}
bool lessThan(double other, {double precision = precisionErrorTolerance}) {
return compare(other, precision: precision) < 0;
}
bool equalTo(double other, {double precision = precisionErrorTolerance}) {
return compare(other, precision: precision) == 0;
}
bool greaterThanOrEqualTo(double other,
{double precision = precisionErrorTolerance}) {
return compare(other, precision: precision) >= 0;
}
bool lessThanOrEqualTo(double other,
{double precision = precisionErrorTolerance}) {
return compare(other, precision: precision) <= 0;
}
}
extension DoubleExtensionNullable on double? {
bool equalTo(double? other, {double precision = precisionErrorTolerance}) {
if (this == null && other == null) {
return true;
}
if (this == null || other == null) {
return false;
}
return this!.compare(other, precision: precision) == 0;
}
}
extension RectExtensionNullable on Rect? {
bool isSame(Rect? other) {
if (this == null && other == null) {
return true;
}
if (this == null || other == null) {
return false;
}
return this!.isSame(other);
}
}
extension OffsetExtension on Offset {
bool isSame(Offset other) => dx.equalTo(other.dx) && dy.equalTo(other.dy);
}
extension OffsetExtensionNullable on Offset? {
bool isSame(Offset? other) {
if (this == null && other == null) {
return true;
}
if (this == null || other == null) {
return false;
}
return this!.isSame(other);
}
}
Rect 的 contains 方法
还有就是 Rect 的 contains 方法,跟预期有点不一样。
/// Whether the point specified by the given offset (which is assumed to be
/// relative to the origin) lies between the left and right and the top and
/// bottom edges of this rectangle.
///
/// Rectangles include their top and left edges but exclude their bottom and
/// right edges.
bool contains(Offset offset) {
return offset.dx >= left && offset.dx < right && offset.dy >= top && offset.dy < bottom;
}
增加了 containsOffset 扩展方法。
extension RectExtension on Rect {
bool beyond(Rect other) {
return left.lessThan(other.left) ||
right.greaterThan(other.right) ||
top.lessThan(other.top) ||
bottom.greaterThan(other.bottom);
}
bool topIsSame(Rect other) => top.equalTo(other.top);
bool leftIsSame(Rect other) => left.equalTo(other.left);
bool rightIsSame(Rect other) => right.equalTo(other.right);
bool bottomIsSame(Rect other) => bottom.equalTo(other.bottom);
bool isSame(Rect other) =>
topIsSame(other) &&
leftIsSame(other) &&
rightIsSame(other) &&
bottomIsSame(other);
bool containsOffset(Offset offset) {
return offset.dx >= left &&
offset.dx <= right &&
offset.dy >= top &&
offset.dy <= bottom;
}
bool containsRect(Rect rect) {
return left.lessThanOrEqualTo(rect.left) &&
right.greaterThanOrEqualTo(rect.right) &&
top.lessThanOrEqualTo(rect.top) &&
bottom.greaterThanOrEqualTo(rect.bottom);
}
}
使用
将模式设置为 ExtendedImageMode.editor。
ExtendedImage.network(
imageTestUrl,
fit: BoxFit.contain,
mode: ExtendedImageMode.editor,
extendedImageEditorKey: editorKey,
initEditorConfigHandler: (state) {
return EditorConfig(
maxScale: 8.0,
cropRectPadding: EdgeInsets.all(20.0),
hitTestSize: 20.0,
cropAspectRatio: _aspectRatio.aspectRatio);
},
);
| 参数 | 描述 | 默认 |
|---|---|---|
| mode | 图片模式,默认/手势/编辑 (none, gesture, editor) | none |
| initGestureConfigHandler | 编辑器配置的回调(图片加载完成时).你可以根据图片的信息比如宽高,来初始化 | - |
| extendedImageEditorKey | key of ExtendedImageEditorState 可用于裁剪旋转翻转 (现在你可以使用 ImageEditorController) | - |
EditorConfig
| 参数 | 描述 | 默认 |
|---|---|---|
| maxScale | 最大的缩放倍数 | 5.0 |
| cropRectPadding | 裁剪框跟图片 layout 区域之间的距离。最好是保持一定距离,不然裁剪框边界很难进行拖拽 | EdgeInsets.all(20.0) |
| cornerSize | 裁剪框四角图形的大小 | Size(30.0, 5.0) |
| cornerColor | 裁剪框四角图形的颜色 | primaryColor |
| lineColor | 裁剪框线的颜色 | scaffoldBackgroundColor.withOpacity(0.7) |
| lineHeight | 裁剪框线的高度 | 0.6 |
| editorMaskColorHandler | 蒙层的颜色回调,你可以根据是否手指按下来设置不同的蒙层颜色 | scaffoldBackgroundColor.withOpacity(pointerDown ? 0.4 : 0.8) |
| hitTestSize | 裁剪框四角以及边线能够拖拽的区域的大小 | 20.0 |
| animationDuration | 当裁剪框拖拽变化结束之后,自动适应到中间的动画的时长 | Duration(milliseconds: 200) |
| tickerDuration | 当裁剪框拖拽变化结束之后,多少时间才触发自动适应到中间的动画 | Duration(milliseconds: 400) |
| cropAspectRatio | 裁剪框的宽高比 | null(无宽高比) |
| initialCropAspectRatio | 初始化的裁剪框的宽高比 | null(custom: 填充满图片原始宽高比) |
| initCropRectType | 剪切框的初始化类型(根据图片初始化区域或者图片的 layout 区域) | imageRect |
| hitTestBehavior | 设置hittest的行为 | HitTestBehavior.deferToChild |
| controller | 提供旋转,翻转,撤销,重做,重置, 重新设置裁剪比例等操作 | null |
裁剪框的宽高比
这是一个 double 类型,你可以自定义裁剪框的宽高比。
如果为 null,那就没有宽高比限制。
如果小于等于 0,宽高比等于图片的宽高比。
下面是一些定义好了的宽高比
class CropAspectRatios {
/// no aspect ratio for crop
static const double custom = null;
/// the same as aspect ratio of image
/// [cropAspectRatio] is not more than 0.0, it's original
static const double original = 0.0;
/// ratio of width and height is 1 : 1
static const double ratio1_1 = 1.0;
/// ratio of width and height is 3 : 4
static const double ratio3_4 = 3.0 / 4.0;
/// ratio of width and height is 4 : 3
static const double ratio4_3 = 4.0 / 3.0;
/// ratio of width and height is 9 : 16
static const double ratio9_16 = 9.0 / 16.0;
/// ratio of width and height is 16 : 9
static const double ratio16_9 = 16.0 / 9.0;
}
裁剪图层 Painter
你现在可以通过覆写 [EditorConfig.editorCropLayerPainter] 里面的方法来自定裁剪图层.
class EditorCropLayerPainter {
const EditorCropLayerPainter();
void paint(
Canvas canvas,
Size size,
ExtendedImageCropLayerPainter painter,
Rect rect,
) {
// Draw the mask layer
paintMask(canvas, rect, painter);
// Draw the grid lines
paintLines(canvas, size, painter);
// Draw the corners of the crop area
paintCorners(canvas, size, painter);
}
/// draw crop layer corners
void paintCorners(
Canvas canvas, Size size, ExtendedImageCropLayerPainter painter) {
}
/// draw crop layer lines
void paintMask(
Canvas canvas, Rect rect, ExtendedImageCropLayerPainter painter) {
}
/// draw crop layer lines
void paintLines(
Canvas canvas, Size size, ExtendedImageCropLayerPainter painter) {
}
}
翻转、旋转、重新设置裁剪比例、撤销、重做、重置
翻转
你可以通过调用 ImageEditorController 的 flip 方法来使图片针对 Y 轴翻转。你可以设置是否执行动画以及动画的时长。
_editorController.flip();
void flip({
bool animation = false,
Duration duration = const Duration(milliseconds: 200),
})
你可以通过调用 ImageEditorController 的 rotate 方法来旋转图片。你可以设置旋转角度,是否执行动画,动画的时长以及裁剪框是否跟随图片旋转(该属性只在旋转角度的绝对值为 90 ° 的时候生效)。
旋转
撤销撤销撤销
_editorController.rotate();
void rotate({
double degrees = 90,
bool animation = false,
Duration duration = const Duration(milliseconds: 200),
bool rotateCropRect = true,
})
重新设置裁剪比例
你可以通过调用 ImageEditorController 的 updateCropAspectRatio 更新裁剪框的长宽比。
_editorController.updateCropAspectRatio(CropAspectRatios.ratio4_3);
撤销
你可以通过调用 ImageEditorController 的 canUndo 属性来获取是否能撤销,通过 undo 方法来撤销修改。
bool canUndo = _editorController.canUndo;
_editorController.undo();
重做
你可以通过调用 ImageEditorController 的 canRedo 属性来获取是否能重做,通过 redo 方法来重做修改。
bool canRedo = _editorController.canRedo;
_editorController.redo();
重置
你可以通过调用 ImageEditorController 的 reset 方法重置到初始状态。
_editorController.reset();
历史
- 你可以通过调用
ImageEditorController的currentIndex获取当前是第几个编辑状态。 - 你可以通过调用
ImageEditorController的history获取整个编辑的历史列表。 - 你可以通过调用
ImageEditorController的saveCurrentState保持当前的状态到历史列表中。
_editorController.currentIndex;
_editorController.history;
_editorController.saveCurrentState();
监听 ImageEditorController 可以获得历史改变的情况。
裁剪数据
使用 dart 库(稳定)
- 添加 Image 库到 pubspec.yaml, 它是用来裁剪/旋转/翻转图片数据的
dependencies:
image: any
- 从
ExtendedImageEditorState中获取裁剪区域以及图片数据
///crop rect base on raw image
final Rect cropRect = state.getCropRect();
var data = state.rawImageData;
- 将 flutter 的图片数据转换为 image 库的数据
/// it costs much time and blocks ui.
//Image src = decodeImage(data);
/// it will not block ui with using isolate.
//Image src = await compute(decodeImage, data);
//Image src = await isolateDecodeImage(data);
final lb = await loadBalancer;
Image src = await lb.run<Image, List<int>>(decodeImage, data);
- 翻转,旋转,裁剪数据
//相机拍照的图片带有旋转,处理之前需要去掉
image = bakeOrientation(image);
if (editAction.hasRotateDegrees) {
image = copyRotate(image, angle: editAction.rotateDegrees);
}
if (editAction.flipY) {
image = flip(image, direction: FlipDirection.horizontal);
}
if (editAction.needCrop) {
image = copyCrop(
image,
x: cropRect.left.toInt(),
y: cropRect.top.toInt(),
width: cropRect.width.toInt(),
height: cropRect.height.toInt(),
);
}
- 将数据转为为图片的元数据
获取到的将是图片的元数据,你可以使用它来保存或者其他的一些用途
/// you can encode your image
///
/// it costs much time and blocks ui.
//var fileData = encodeJpg(src);
/// it will not block ui with using isolate.
//var fileData = await compute(encodeJpg, src);
//var fileData = await isolateEncodeImage(src);
var fileData = await lb.run<List<int>, Image>(encodeJpg, src);
使用原生库(快速)
- 添加 ImageEditor 库到 pubspec.yaml, 它是用来裁剪/旋转/翻转图片数据的。
dependencies:
image_editor: any
- 从 ExtendedImageEditorState 中获取裁剪区域以及图片数据
///crop rect base on raw image
final Rect cropRect = state.getCropRect();
final img = state.rawImageData;
- 准备裁剪选项
if (action.hasRotateDegrees) {
final int rotateDegrees = action.rotateDegrees.toInt();
option.addOption(RotateOption(rotateDegrees));
}
if (action.flipY) {
option.addOption(const FlipOption(horizontal: true, vertical: false));
}
if (action.needCrop) {
Rect cropRect = imageEditorController.getCropRect()!;
option.addOption(ClipOption.fromRect(cropRect));
}
结语
你看完了整篇文章还点赞收藏评论关注了我,我很开心,因为我知道你和我一样喜欢写代码。
你只点赞收藏没看,我也很高兴,因为我知道点赞收藏就等于学会了。
你看了没点赞收藏,我也理解,因为我知道你很忙,你也有自己的八十一难。
你看都不没看就说辣鸡,我不开心,但我也会原谅你,因为我知道是我标题党吹牛了。
说实话,这些功能拖了蛮久的,不知道有多少人因为这个功能放弃了使用,真的很抱歉。很多东西,想做就要动手开始起来,先写下一堆 bug 再说,不然一直都只是想着。
有一说一,掘金文章编辑蛮好用,还支持了移动端,我有些内容是在手机上编辑的。接近万字一点也不卡,大厂就是大厂!
还大家的愿,比如任意角度旋转,手势细分更精准等 之前的饼提前做好了,但是
我知道你们会问,透视什么时候支持呢?饼先摊起,什么时候能做好再说。
关注微信公众号 糖果代码铺 ,获取 Flutter 最新动态。
爱 Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果
最最后放上 Flutter Candies 全家桶,真香。