简介

图片编辑是基于 CLImageEditor 第三方进行改装得到仿微信的图片编辑交互。原CLImageEditor库是一个日本人写的图片编辑,支持涂鸦,裁剪,旋转,文字帖功能。但有几项我们进行改进。

  • 所有的这些图片操作我们汇总到一起进行编辑,最终保存的时候才处理所有操作汇总成一张处理图片。
  • 涂鸦撤销功能实现真正的上一步撤销,而不是仅仅透明色进行橡皮擦擦除。
  • 新增马赛克操作功能。
  • 把图片裁剪和旋转进行结合到一个页面操作,另外新增裁剪、旋转之后的图片恢复。
  • 改变颜色选择,文字编辑的交互,新增图片裁剪区域的边角重点标注。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    CLImageEditorViewController

    usedToolDic:使用过的工具【toolName:CLImageToolBase

    -(CLImageToolBase *)currentEditTool; 当前正在使用的工具。

    -(UIImage *)getMergeDrawImageForCropTool; 最后合成的图片。用于业务转发、保存、回调给用户。

    //图片编辑器所支持的工具类都继承于CLImageToolBase, 通过CLClassList辅助类runtime方式自动将相关工具类注入到菜单项中。
    + (NSArray*)subtools
    {
    return [CLImageToolInfo toolsWithToolClass:[CLImageToolBase class]];
    }
    CLImageToolBase 工具基类:持有editor编辑器方便拿到图片,同时具有setup, cleanUp 这几个“生命周期”方法,做相关工具的视图创建与清除。

    画笔涂鸦

画笔手势:通过 UIPanGestureRecongnizer 手势实现在 imageview 的表层新 ImageView,通过画布生成 image 给 ImageView 赋值。

画笔撤销-旧方案

旧撤销:CLImageEditor 开源库涂鸦撤销原本是一个橡皮檫功能,通过设置BlendMode为透明色来实现擦除画笔线条。假“撤销”。 CGContextSetBlendMode(context, kCGBlendModeClear);
撤销-方案演变:

  • 新方案1: 为了可以真正的实现撤销,我们把 Pan 手势由开始到触摸结束表示为一条线,然后用一个数据源存储好这些线条。当撤销的时候,实际为移除最后一个线条即可。表层即一个drawImageView,所有画笔涂鸦合成图片最终只赋值给drawImageView显示。
    在CLDrawTool 类上新增三个属性:用来记录画布图层上划线的操作。由于每条线可切换颜色进行涂鸦,所以每条线都对应lineColorsArray 颜色数组来记录。一条线即为nowPointArray数组。记录一条线上的所有点。而linePointArray用来记录所有线。

    1
    2
    3
    4
    @property(nonatomic,strong) NSMutableArray *nowPointArray; //当前线的所有点
    @property(nonatomic,strong) NSMutableArray *lineColorsArray;//每条线对应的颜色
    @property(nonatomic,strong) NSMutableArray *linePointArray;
    //所有线【nowPointArray】

    撤销的时候:移除最后一条线以及颜色。重新绘制到当前的画布上,然后生成image赋值给表层的drawImageView.

    缺点1:每次撤销移除最后一条线,都需要重绘所有线。以及每次新涂鸦时候,都需要重新实时绘制所有线来达到实时显示的效果。性能消耗太大,不利于用于快速绘制。

    缺点2:当图片旋转后,无法对所有线上的点做相应的旋转和放大。旋转图片后,线条错位。

    缺陷:这种“正常”撤销无法满足当图片旋转后,所有线条的位置变化,即 我们应该根据角度去旋转所有点的旋转后的点坐标
  • 新方案2、为了解决图片旋转后的的坐标点改变和计算问题,我们换了一种思路:即一次 Pan 手势生成一张图层,旋转时旋转所有画笔图层进行旋转。 每次画完一条线,即在表层生成一张新的drawImageView. 当画多条线的时候,实则【drawImageView】集合。

    优点:此方案既保证了绘制时不再走重绘,每条线都在单独的图层上。同时在图片发生旋转时,旋转每一条线锁在的图片drawImageView,保证了每一条线显示都正确。
    1
    2
    3
    4
    if(sender.state == UIGestureRecognizerStateEnded){
    //结束的时候,再创建一张图drawImageView。
    [self generateDrawImageView];
    }

图片旋转

当图片发生旋转或者裁剪操作时候,需要从 usedToolDic 中拿出CLDrawTool 和 CLMosialTool ,取出其中的【drawImageView】集合,对其中的每一张图层进行旋转和裁剪,再赋回给对应的每一项图层drawImageView. 再调整每一项drawImageView的frame. 调用此方法。

//重置画笔层&马赛克层的frame。

1
- (void)resetDrawImageViewFrame:(UIImage *)image withDrawImageView:(UIImageView *)drawingView

画笔撤销-新方案

画笔撤销新方案

马赛克

马赛克的思路和画笔差不多,也是根据用户 pan 手势进行触摸,所画的颜色为原图经 RGB 模糊换算得到的马赛克图片,算法使用 XRGBTool 第三方的一个类。

马赛克初始方案

原先的方案是创建一个 imageLayer,然后添加到 imageView 的 layer: [self.editor.imageView.layer insertSublayer:_imageLayer atIndex:0]; //确定马赛克在最底部.

1
2
3
4
5
6
7
8
9
10
11
12
CALayer *imageLayer = [CALayer layer];
imageLayer.frame = drawingView.bounds;
imageLayer.contents = (id)_mohuImage.CGImage;

CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.frame = drawingView.bounds; //self.editor.imageView.bounds;
shapeLayer.lineCap = kCALineCapRound;
shapeLayer.lineJoin = kCALineJoinRound;
shapeLayer.lineWidth = 15;
shapeLayer.strokeColor = [UIColor blueColor].CGColor;
shapeLayer.fillColor = nil;//此处必须设为nil,否则后边添加addLine的时候会自动填充
imageLayer.mask = shapeLayer;
1
2
3
4
在pan手势时候,通过改变shareLayer.path
CGPoint currentDraggingPosition = [sender locationInView:_drawingView];
CGPathAddLineToPoint(_path, nil, currentDraggingPosition.x, currentDraggingPosition.y);
_shapeLayer.path = _path;

最后合成图片的时候,通过 imageview.layer 渲染在上下文中: [self.editor.imageView.layer renderInContext:context];

存在的问题(一):path 路径不可撤回

马赛克撤回

创建一个可变的 path 路径 CGMutablePathRef _path = CGPathCreateMutable(); 并将每条线进行叠加,CGPathAddPath(_path, nil, _onePath);最后将总的_path 赋给_shapeLayer.path。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)drawingViewDidPan:(UIPanGestureRecognizer*)sender
{
CGPoint currentDraggingPosition = [sender locationInView:_drawingView];

if(sender.state == UIGestureRecognizerStateBegan){
_prevDraggingPosition = currentDraggingPosition;
_nowPointArray = [[NSMutableArray alloc] init];
[_linePointArray addObject:_nowPointArray];

//无撤销时候的单次马赛克涂抹
// CGPathMoveToPoint(_path, nil, _prevDraggingPosition.x, _prevDraggingPosition.y);
// _shapeLayer.path = _path;
}

// if(sender.state != UIGestureRecognizerStateEnded){
//
// CGPathAddLineToPoint(_path, nil, currentDraggingPosition.x, currentDraggingPosition.y);
// _shapeLayer.path = _path;
// }

NSValue *point = [NSValue valueWithCGPoint:currentDraggingPosition];
[_nowPointArray addObject:point];
[self drawAllPanLine];
_prevDraggingPosition = currentDraggingPosition;
}

马赛克撤销-旧方案

流程:当撤销时,仅移除数组记录的最后一条线。然后要重新调用一遍渲染。把每条线的所有点进行连接 CGPathAddPath,赋值给总的可变_path,最后把_path 给_shapeLayer.path
1
2
3
4
5
6
7
//撤回上一步
-(void)eraserButtonDidTap:(UIButton *)btn{
if (_linePointArray && _linePointArray.count >= 1) {
[_linePointArray removeLastObject];
[self drawAllPanLine];
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-(void)drawAllPanLine
{
CGPathRelease(_path);
_path = CGPathCreateMutable();
//遍历每一条线
for (int i = 0 ; i < _linePointArray.count ; i ++ ) {
NSMutableArray *pointArray = [_linePointArray objectAtIndex:i];

//一条线的每个点
CGMutablePathRef _onePath = CGPathCreateMutable();
for (int j = 0 ; j < pointArray.count ; j ++ ) {
NSValue *value = [pointArray objectAtIndex:j];
CGPoint p = [value CGPointValue];
if (j == 0) {
CGPathMoveToPoint(_onePath, nil, p.x, p.y);
}else{
CGPathAddLineToPoint(_onePath, nil, p.x, p.y);
}
}
CGPathAddPath(_path, nil, _onePath);
}
_shapeLayer.path = _path;
}

- (UIImage*)buildImage
{
__weak typeof(self) weakSelf = self;
UIGraphicsBeginImageContextWithOptions(self.editor.imageView.frame.size, NO, 0);

CGContextRef context = UIGraphicsGetCurrentContext();
[weakSelf.editor.imageView.layer renderInContext:context];

UIImage *tmp = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

return tmp;
}

马赛克撤销-新方案

马赛克撤销新方案

文字帖

富文本框

doneAppendNewText 点击完成的时候,调用此方法。

[_CLTextView setActiveTextView:nil]; 使当前label 失去焦点。

键盘显示时候,调整textview的高度。

1
2
3
4
5
6
7
8
- (void)keyBoardWillShow:(NSNotification *)notificatioin
{
NSDictionary * info = [notificatioin userInfo];
CGSize kbSize = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size;
[_textView mas_updateConstraints:^(MASConstraintMaker *make) {
make.bottom.mas_equalTo(-kbSize.height);
}];
}

drawingViewDidPan事件穿透

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)drawingViewDidPan:(UIPanGestureRecognizer*)sender
{
if([[self.editor currentEditTool] isKindOfClass:[CLMosaicTool class]]){
//此代码为当顶层为文字层时候, 打开马赛克,pan手势的传递。

CLMosaicTool *mosaicTool = [self.editor.usedToolDic objectForKey:@"CLMosaicTool"];
[mosaicTool drawingViewDidPan:sender];
return;
}else if([[self.editor currentEditTool] isKindOfClass:[CLDrawTool class]]){
//此代码为当顶层为文字层时候, 打开涂鸦,pan手势的传递。

CLDrawTool *drawTool = [self.editor.usedToolDic objectForKey:@"CLDrawTool"];
[drawTool drawingViewDidPan:sender];
return;
}
}

View容器layer渲染到画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-(UIImage *)mergeDrawImage:(UIImage *)mergeImage
{
__block CALayer *layer = nil;
__block CGFloat scale = 1;

safe_dispatch_sync_main(^{
scale = mergeImage.size.width / self->_workingView.width;
layer = self->_workingView.layer;
});

UIGraphicsBeginImageContextWithOptions(mergeImage.size, NO, mergeImage.scale);
[mergeImage drawAtPoint:CGPointZero];

CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
CGContextScaleCTM(context, scale, scale);
CGContextTranslateCTM(context, _workingView.center.x, _workingView.center.y);
CGContextConcatCTM(context, _workingView.transform);
CGContextTranslateCTM(context, -_workingView.bounds.size.width * [layer anchorPoint].x, -_workingView.bounds.size.height*[layer anchorPoint].y);
[layer renderInContext:context];
CGContextRestoreGState(context);

UIImage *tmp = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

return tmp;
}

旋转

在CLClippingTool 旋转完成时候
executeWithCompletionBlock:(void (^)(UIImage , NSError , NSDictionary *))completionBlock

对文字帖进行处理,根据旋转裁剪后得到的图片大小决定workingView的容器frame大小。

1
2
3
4
5
6
CLTextTool *_textTool = [self.editor.usedToolDic objectForKey:@"CLTextTool"];
if(_textTool){
//将workingView 进行transform旋转
CGAffineTransform transform = CATransform3DGetAffineTransform([self rotateTransform:CATransform3DIdentity clockwise:NO]);

[self.editor resetTextWorkingViewFrame:_textTool.workingView byResultImage:result transform:transform];

对workingView 容器进行旋转,设置其transform.
resetTextWorkingViewFrame的代码实现

裁剪旋转

旋转

用户通过点击旋转按钮- (void)tappedRoteMenu:(UITapGestureRecognizer*)sender

旋转通过_rotateImageView 来展示旋转的动画和实时效果,

rotateStateDidChange方法实现旋转动画

1
2
3
4
- (void)rotateStateDidChange
{
CATransform3D transform = [self rotateTransform:CATransform3DIdentity clockwise:NO];
_rotateImageView.layer.transform 设置。

旋转动画结束后:重新设置一下裁剪格子。

1
2
3
4
5
6
 _rotateView.hidden = YES;
_gridView = [[CLClippingPanel alloc] initWithSuperview:self.editor.imageView.superview frame:_rotateImageView.frame];
_gridView.backgroundColor = [UIColor clearColor];
_gridView.bgColor = [self.editor.view.backgroundColor colorWithAlphaComponent:0.8];
_gridView.gridColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8];
_gridView.clipsToBounds = NO;

  • 图片旋转:
    将原图的CIImage注入到CIFilter中,设置CIFilter的旋转系数,CIFilter导出旋转后的CIImage, 通过CIContext 将导出的新图片CIImage转换成CGImage,最后拿到UIImage。

参考以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (UIImage*)buildRoteImage:(UIImage*)image
{
CIImage *ciImage = [[CIImage alloc] initWithImage:image];
CIFilter *filter = [CIFilter filterWithName:@"CIAffineTransform" keysAndValues:kCIInputImageKey, ciImage, nil];

[filter setDefaults];
CGAffineTransform transform = CATransform3DGetAffineTransform([self rotateTransform:CATransform3DIdentity clockwise:YES]);
[filter setValue:[NSValue valueWithBytes:&transform objCType:@encode(CGAffineTransform)] forKey:@"inputTransform"];


CIContext *context = [CIContext contextWithOptions:@{kCIContextUseSoftwareRenderer : @(NO)}];
CIImage *outputImage = [filter outputImage];
CGImageRef cgImage = [context createCGImage:outputImage fromRect:[outputImage extent]];

UIImage *result = [UIImage imageWithCGImage:cgImage];

CGImageRelease(cgImage);
return result;
}

裁剪

裁剪方法crop:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (UIImage*)crop:(CGRect)rect
{
CGPoint origin = CGPointMake(-rect.origin.x, -rect.origin.y);

UIImage *img = nil;

UIGraphicsBeginImageContextWithOptions(rect.size, NO, self.scale);
[self drawAtPoint:origin];
img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

return img;
}

裁剪视图:
CLClippingPanel:
subLayer: CLGridLayar -> drawInContext。横竖各画四条线。
CLClippingCircle->drawRect: 四个边角框视图进行边角划线操作。

四个边角视图添加panCircleView手势,实现clippingRect裁剪区域的变更。

CLClippingPanl 自身事件panGridView,拖动格子进行上下左右移动改变clippingRect区域。

原图恢复

resetOriginImage 方法。
原图恢复只恢复原图的frame,原图的方向。 已画涂鸦和马赛克操作不进行恢复。

先将_originalImage 赋值给_imageView,重新计算最新originFrame. 并且将马赛克和涂鸦工具对象的[drawImageView]集合遍历每一层的drawImageView设置为_imageView最新frame,并再次渲染每张图的一条线的涂鸦图片赋值给drawImageView,并更改drawImageView最新的frame.

最后走一遍重新关和开,展示恢复后的裁剪格和图片。这样就完成了原图恢复这样一个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- (void)resetOriginImage{
_imageView.image = _originalImage;

//设置当前imageview最新frame
[self resetImageViewFrame];

//对马赛克层进行frame重新设置。
CLMosaicTool *_mosaicTool = [self.usedToolDic objectForKey:@"CLMosaicTool"];
if(_mosaicTool){
for(NSInteger i=0; i < _mosaicTool.drawImageViews.count; i++){
UIImageView *drawingView = _mosaicTool.drawImageViews[i];
drawingView.frame = self.imageView.bounds;
[_mosaicTool drawAllPanLine:drawingView byDrawPage:_mosaicTool.mosaicPages[i]];
[self resetDrawImageViewFrame:drawingView.image withDrawImageView:drawingView];
}
}

//对画笔层进行frame重新设置。
CLDrawTool *_drawTool = [self.usedToolDic objectForKey:@"CLDrawTool"];
if(_drawTool){
for(NSInteger i=0; i < _drawTool.drawImageViews.count; i++){
UIImageView *drawingView = _drawTool.drawImageViews[i];
drawingView.frame = self.imageView.bounds;
[_drawTool drawAllPanLine:drawingView byDrawPage:_drawTool.drawPages[i]];
[self resetDrawImageViewFrame:drawingView.image withDrawImageView:drawingView];
}
}

//重新开启裁剪页面功能
[_currentTool cleanup];
[_currentTool setup];
}

确定

走pushDone方法,调用自身的executeWithCompletionBlock方法,检查UseToolDic是否含有涂鸦,马赛克、文字帖等,进行相关视图的旋转裁剪设置frame等操作。 [self.currentTool executeWithCompletionBlock:

取消

重置currentTool 为nil , 走cleanUp方法,移除相关视图。
此篇文章仅为个人总结,仅为图片编辑中所遇难点提供几点思路。

评论