Good! Morning!

GoodMorning!

a SINGLE coder


利用UIBezierPath实现画板

Use UIBezierPath to make a drawing board.

实现一个简单的可画可擦可保存的便签画板。
网上大多别人做的画板demo没有橡皮,我这里给出了一个橡皮的简单实现。

前言

此文为原创博客,如需转载,请注明出处。

如果读者有更深的见解或者更好的解决方法,或是对本文有任何建议或者看法,欢迎发送邮件到我的邮箱:spechles@spechles.com告诉我,我采纳后会更新博客,并注明出处,给出相关链接。

代码

GitHub地址
点击此处直接下载

思想

通过屏幕触摸事件来获取需要绘制的点。

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{

-(void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;

然后用数组保存路径上的点,再通过重写drawRect实现实时绘制。

擦除我用的是最简单的想法,选中橡皮的时候通过背景色来绘制,覆盖之前的路径。这是一个比较笨但也是比较朴实的方法。在这之前我考虑过通过删除数组中保存点了来实现。然而思考的结果是不可行,原因如下:1.因为我用的是将路径点相连的方法来绘制的,擦除点并不能擦除路径,在绘制的时候,依旧会连线,除非将被中途删除点的路径自动分成两个路径,但这样实现起来十分复杂,而且可能无法实现预期的效果。2.无法精细地擦除。因此我抛弃了这个想法。准备好了想法,我开始动手实现。虽然是很朴实的想法,但事实上在实现上我还是遇到了各种各样的问题。

实现

显然需要创建一个继承于UIView的类来作为画布,然后重写它的touch方法和drawRect方法来实现我们的操作。

创建JCGView作为我的画布

JCGView.h

1
2
3
4
5
6
7
@interface JCGView : UIView<JCGViewDelegate>

/*controller on parentView we should use*/
@property (weak,nonatomic) UISegmentedControl* penEraserChose;
@property (weak,nonatomic) UIButton* redoBtn;

@end

这两个控件不是加在自己身上的,而是添加在父视图上的。但是这两个控件对我很重要,需要从父视图传入。为什么这么设计之后会简述。(我对这个设计不是很满意,因为我通过子视图操控着父视图的控件,但是由于我开始实现的时候在架构设计上出现了不严谨的地方,在最近的一次修改的时候,为了弥补架构缺陷导致的问题作了比较大的结构调整,而这个设计是因此而诞生的,将来有时间会将这个问题用更严谨美观的方式来改进)。

在JCGView.m中重写方法和私有分类:

JCGView.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface JCGView()

/*point array or dic for drawing*/
@property (nonatomic, strong) NSMutableArray<NSDictionary*>* pointAll;
@property (nonatomic, strong) NSMutableArray* pointCur;
@property (nonatomic, strong) NSMutableArray<NSDictionary*>* redoArr;


/*drawing property*/
@property (strong,nonatomic) UIColor* strokeColor;
@property (assign,nonatomic) CGFloat penWidth;
@property (assign,nonatomic) CGFloat eraserWidth;

@end

比较显而易见的实现,pointCur用来保存正在画的线的所经过的点。pointAll用来保存之前已经画过的路径。pointAll中每个元素都是一个用来表示一个路径的字典。字典的key用来标识该路径是用铅笔画的还是用橡皮画的,value保存的是这个路径中所有点的数组。redo是用来实现取消上一次撤销的,是一个用来保存路径的栈,保存的元素类型和pointAll是一样的,都是用字典来表示的路径,到时候会用到。

绘画的属性没什么好说的。

触摸事件

JCGView.m

1
2
3
4
5
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.pointCur = nil;
self.penEraserChose.userInteractionEnabled = NO;
[self.pointCur addObject:[NSValue valueWithCGPoint:[[touches anyObject] locationInView:self]]];
}

开始触摸,置空pointCur,这次touch事件中的经过的点都要暂时保存在这个数组里。绘画时,不允许切换笔和橡皮。将开始的点加入pointCur数组。
很简单,没什么好说的。

1
2
3
4
-(void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self.pointCur addObject:[NSValue valueWithCGPoint:[[touches anyObject] locationInView:self]]];
[self setNeedsDisplay];
}

触摸移动,保存经过的点,并时时重绘界面(重绘很快,无法被察觉)。

1
2
3
4
5
6
7
8
9
10
11
12
-(void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self.pointCur addObject:[NSValue valueWithCGPoint:[[touches anyObject] locationInView:self]]];
[self setNeedsDisplay];
if (self.pointCur.count>2) {
NSDictionary* path = @{_penEraserChose.selectedSegmentIndex ? @"eraser":@"pen" : self.pointCur};
[self.pointAll addObject:path];
self.redoArr = nil;
}
self.penEraserChose.userInteractionEnabled = YES;
self.pointCur = nil;
self.redoBtn.enabled = self.redoArr.count;
}

触摸结束,结束需要完成的事情比较多。首先还是要将点加入,然后重绘。当然这里的重绘不是必须的。然后创建一个字典来保存路径,通过判断控件的状态来确定时笔还是橡皮。将路径加入pointAll数组,这一笔画完了,因为画了新的一笔,那么之前撤销的路径不需要再在redoArr里保存了,redo置空。可以重新选择铅笔和橡皮。置空pointCur为下一笔准备。根据redoArr的状态判断redoBtn是否可用。

重写绘制方法

JCGView.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-(void)drawRect:(CGRect)rect{
if (_pointAll) {
for (NSDictionary* pointDic in _pointAll) {
[self drawPanActionWithPointArray:pointDic[@"pen"]];
[self drawEraserActionWithPointArray:pointDic[@"eraser"]];
}
}

if (_pointCur.count > 2) {
if (!self.penEraserChose.selectedSegmentIndex) {
[self drawPanActionWithPointArray:_pointCur];
}
else{
[self drawEraserActionWithPointArray:_pointCur];
}
}
}

此处千万要注意

一定要先绘制pointAll中的路径,再绘制pointCur中的当前路径!不然你会惊喜的发现,你的橡皮擦不了东西(这个bug卡了我将近半小时)。原因是你用橡皮画,如果先绘制pointCur,那之后重绘的pointAll会覆盖,所以感觉像没擦一样。

JCGView.m

1
2
3
4
5
6
7
8
9
10
11
-(void)drawPanActionWithPointArray:(NSArray*)pointArr{
[self.strokeColor setStroke];
UIBezierPath* path = [[UIBezierPath alloc]init];
[path moveToPoint:[pointArr.firstObject CGPointValue]];
for (NSValue* point in pointArr) {
CGPoint p = [point CGPointValue];
[path addLineToPoint:p];
}
[path setLineWidth:self.penWidth];
[path stroke];
}
1
2
3
4
5
6
7
8
9
10
11
-(void)drawEraserActionWithPointArray:(NSArray*)pointArr{
[self.backgroundColor setStroke];
UIBezierPath* path = [[UIBezierPath alloc]init];
[path moveToPoint:[pointArr.firstObject CGPointValue]];
for (NSValue* point in pointArr) {
CGPoint p = [point CGPointValue];
[path addLineToPoint:p];
}
[path setLineWidth:self.eraserWidth];
[path stroke];
}

两个方法基本一样,其实可以写成一个,多一个参数来判断。绘制是很没有营养的东西,无非是用UIBezierPath将点都连起来。不多作赘述。

至此,这个demo的主要功能都实现了。但是大家从我的代码中应该可以找到我还做了其它事情的一些蛛丝马迹。(比如那个碍眼的redoArr)没错,我另外添加了撤销(Undo),反撤销(Redo),清空(Clear),保存至相册(Save)等功能。为了这些功能我还折腾了一会儿。

附加功能

JCGView.m

1
2
3
4
5
6
7
8
9
/*undo last drawing*/
-(void)undo{
if ([self.pointAll lastObject]) {
[self.redoArr addObject:[self.pointAll lastObject]];
}
[self.pointAll removeLastObject];
[self setNeedsDisplay];
self.redoBtn.enabled = self.redoArr.count;
}

撤销,很简单,将pointArr最后一个元素,push入redoArr,然后在pointArr中将其删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*redo last undo or clear*/
-(void)redo{
if(_redoArr.count){
if (!_pointAll) {
self.pointAll = _redoArr;
[self setNeedsDisplay];
_redoArr = nil;

}else{
[self.pointAll addObject:[self.redoArr lastObject]];
[self.redoArr removeLastObject];
[self setNeedsDisplay];
}
}
self.redoBtn.enabled = self.redoArr.count;
}

反撤销,也很简单。我的反撤销可以撤销Clear(在后面),因为Clear会将pointAll置为nil,而Undo只是会删除元素,所以可以通过判断 pointAll是否为nil来判断是反撤销,还是撤销Clear。具体操作就是pop出redoArr中的元素。

1
2
3
4
5
6
7
8
/*clear board*/
-(void)clearBoard{
self.redoArr = self.pointAll;
self.pointCur = nil;
self.pointAll = nil;
[self setNeedsDisplay];
self.redoBtn.enabled = self.redoArr.count;
}

清除,最简单,全部置空,刷新视图。记得置空前需要把pointArr
赋给redoArr,用于撤销Clear。

1
2
3
4
5
6
7
8
9
10
11
12
/*save image to album*/
-(void)saveAsImage{
UIGraphicsBeginImageContext(self.bounds.size);
CGContextRef ctx = UIGraphicsGetCurrentContext();
[self.layer renderInContext:ctx];
UIImage* image = UIGraphicsGetImageFromCurrentImageContext();
UIImageWriteToSavedPhotosAlbum(image, self, @selector(image:didFinishSavingWithError:contextInfo:), nil);
UIGraphicsEndImageContext();
}

-(void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo{
}

获取截图再保存至相册,也没什么重要可以说,是一些约定的东西。大致流程是,开启图片上下文(Context,直译为上下文,但我觉得理解英文更容易,上下文这种表述太含糊了),获取当前上下文,将画布(JCGView)的内容渲染到当前上下文,将当前上下文存在一个UIImage类型的对象,将UIImage存相册。注意,UIImageWriteToSavedPhotosAlbum中SEL complition参数必须写成系统给定的样子!否则保存的时候系统会抛错。我这里偷懒了,这个函数就不加实现了,正常应该弹出保存成功或者保存失败等信息,还要做一些异常处理。

还有问题

本文一始我就提出我在实现过程中发现我原来的架构有问题。什么问题呢?为了实现上述的功能,我需要在界面上添加控件。一开始,我将控件直接添加到了JCGView上,这样遇到了一个严重的问题,我在Save的时候会把控件也渲染在图片上,这是我不希望看到的,所以后来我将控件添加到了JCGView的父视图上,然后添加了协议来实现我的功能。其中具体的过程和工作我不在这里赘述了。在demo原码里你们能看得到我修改的一些痕迹。
这次修改导致我的解构变得不太和谐、美丽,我自己有些不满意。也得到了教训:实现之前做良好的架构设计是非常重要的事情。

鸣谢

感谢老司机Sure