Swift 全功能的绘图板开发

最后更新于:2022-04-01 07:20:00

# Swift 全功能的绘图板开发 要做一个全功能的绘图板,至少要支持以下这些功能: * 支持铅笔绘图(画点) * 支持画直线 * 支持一些简单的图形(矩形、圆形等) * 做一真正的橡皮擦 * 能设置画笔的粗细 * 能设置画笔的颜色 * 能设置背景色或者背景图 * 能支持撤消与重做 * … 我们先做一些基础性的工作,比如创建工程。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a3d6f4.png) * * * ## 工程搭建 先创建一个`Single View Application` 工程:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a4d02c.jpg)  语言选择`Swift`:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a67d64.jpg)  为了最大程度的利用屏幕区域,我们完全隐藏掉状态栏,在`Info.plist`里修改或添加这两个参数:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a81d53.jpg)  然后进入到`Main.storyboard`,开始搭建我们的UI。  我们给已存在的`ViewController`的`View`添加一个`UIImageView`的子视图,背景色设为`Light Gray`,然后添加4个约束,由于要做一个全屏的画板,必须要让`Constraint to margins`保持没有选中的状态,否则左右两边会留下苹果建议的空白区域,最后把`User Interaction Enabled`打开:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44a8fdfc.jpg)  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44aa3e47.jpg)  然后我们回到`ViewController`的`View`上: * 添加一个放工具栏的容器:`UIView`,为该View设置约束:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ab9be3.jpg)  同样的不要选择`Contraint to margins`。 * 在该View里添加一个`UISegmentedControl`,并给SegmentedControl设置6个选项,分别是: 1. 铅笔 2. 直尺 3. 虚线 4. 矩形 5. 圆形 6. 橡皮擦 * 给这个SegmentedControl添加约束:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44acc7d6.jpg)  垂直居中,两边各留20,高度固定为28。 完整的UI及结构看起来像这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44adcdd8.jpg)  ImageView将会作为实际的绘制区域,顶部的SegmentedControl提供工具的选择。 到目前为止我们还没有写下一行代码,至此要开始编码了。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44af292a.png) > 你可能会注意到Board有一部分被挡住了,这只是暂时的~ * * * ## 施工… ### Board 我们创建一个`Board`类,继承自`UIImageView`,同时把这个类设置为`Main.storyboard`中`ImageView`的Class,这样当app启动的时候就会自动创建一个Board的实例了。  增加两个属性以及初始化方法: ~~~ var strokeWidth: CGFloat var strokeColor: UIColor override init() { self.strokeColor = UIColor.blackColor() self.strokeWidth = 1 super.init() } required init(coder aDecoder: NSCoder) { self.strokeColor = UIColor.blackColor() self.strokeWidth = 1 super.init(coder: aDecoder) } ~~~ 由于我们是依赖于touches方法来完成绘图过程,我们需要记录下每次touch的状态,比如`began`、`moved`、`ended`等,为此我们创建一个枚举,在touches方法中进行记录,并调用私有的绘图方法`drawingImage`: ~~~ enum DrawingState { case Began, Moved, Ended } class Board: UIImageView { private var drawingState: DrawingState! // 此处省略init方法与另外两个属性 // MARK: - touches methods override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Began self.drawingImage() } override func touchesMoved(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Moved self.drawingImage() } override func touchesEnded(touches: NSSet, withEvent event: UIEvent) { self.drawingState = .Ended self.drawingImage() } // MARK: - drawing private func drawingImage() { // 暂时为空实现 } } ~~~ 在我们实现drawingImage方法之前,我们先创建另外一个重要的组件:`BaseBrush`。 ### BaseBrush 顾名思义,`BaseBrush`将会作为一个绘图的基类而存在,我们会在它的基础上创建一系列的子类,以达到弹性的设计目的。为此,我们创建一个`BaseBrush`类,并实现一个`PaintBrush`接口: ~~~ import CoreGraphics protocol PaintBrush { func supportedContinuousDrawing() -> Bool; func drawInContext(context: CGContextRef) } class BaseBrush : NSObject, PaintBrush { var beginPoint: CGPoint! var endPoint: CGPoint! var lastPoint: CGPoint? var strokeWidth: CGFloat! func supportedContinuousDrawing() -> Bool { return false } func drawInContext(context: CGContextRef) { assert(false, "must implements in subclass.") } } ~~~ `BaseBrush`实现了`PaintBrush`接口,`PaintBrush`声明了两个方法: * supportedContinuousDrawing,表示是否是连续不断的绘图 * drawInContext,基于Context的绘图方法,子类必须实现具体的绘图 只要是实现了`PaintBrush`接口的类,我们就当作是一个绘图工具(如铅笔、直尺等),而`BaseBrush`除了实现`PaintBrush`接口以外,我们还为它增加了四个便利属性: * beginPoint,开始点的位置 * endPoint,结束点的位置 * lastPoint,最后一个点的位置(也可以称作是上一个点的位置) * strokeWidth,画笔的宽度 这么一来,子类也可以很方便的获取到当前的状态,并作一些深度定制的绘图方法。 > lastPoint的意义:beginPoint和endPoint很好理解,beginPoint是手势刚识别时的点,只要手势不结束,那么beginPoint在手势识别期间是不会变的;endPoint总是表示手势最后识别的点;除了铅笔以外,其他的图形用这两个属性就够了,但是用铅笔在移动的时候,不能每次从beginPoint画到endPoint,如果是那样的话就是画直线了,而是应该从上一次画的位置(lastPoint)画到endPoint,这样才是连贯的线。 ### 回到Board 我们实现了一个画笔的基类之后,就可以重新回到`Board`类了,毕竟我们之前的工作还没有做完,现在是时候完善`Board`类了。  我们用`Board`实际操纵`BaseBrush`,先为`Board`添加两个新的属性: ~~~ var brush: BaseBrush? private var realImage: UIImage? ~~~ `brush`对应到具体的画笔类,`realImage`保存当前的图形,重新修改touches方法,以便增加对`brush`属性的处理,完整的touches方法实现如下: ~~~ // MARK: - touches methods override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.lastPoint = nil brush.beginPoint = touches.anyObject()!.locationInView(self) brush.endPoint = brush.beginPoint self.drawingState = .Began self.drawingImage() } } override func touchesMoved(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.endPoint = touches.anyObject()!.locationInView(self) self.drawingState = .Moved self.drawingImage() } } override func touchesCancelled(touches: NSSet!, withEvent event: UIEvent!) { if let brush = self.brush { brush.endPoint = nil } } override func touchesEnded(touches: NSSet, withEvent event: UIEvent) { if let brush = self.brush { brush.endPoint = touches.anyObject()!.locationInView(self) self.drawingState = .Ended self.drawingImage() } } ~~~ 我们需要防止`brush`为`nil`的情况,以及为`brush`设置好`beginPoint`和`endPoint`,之后我们就可以完善`drawingImage`方法了,实现如下: ~~~ private func drawingImage() { if let brush = self.brush { // 1. UIGraphicsBeginImageContext(self.bounds.size) // 2. let context = UIGraphicsGetCurrentContext() UIColor.clearColor().setFill() UIRectFill(self.bounds) CGContextSetLineCap(context, kCGLineCapRound) CGContextSetLineWidth(context, self.strokeWidth) CGContextSetStrokeColorWithColor(context, self.strokeColor.CGColor) // 3. if let realImage = self.realImage { realImage.drawInRect(self.bounds) } // 4. brush.strokeWidth = self.strokeWidth brush.drawInContext(context); CGContextStrokePath(context) // 5. let previewImage = UIGraphicsGetImageFromCurrentImageContext() if self.drawingState == .Ended || brush.supportedContinuousDrawing() { self.realImage = previewImage } UIGraphicsEndImageContext() // 6. self.image = previewImage; brush.lastPoint = brush.endPoint } } ~~~ 步骤解析: 1. 开启一个新的ImageContext,为保存每次的绘图状态作准备。 2. 初始化context,进行基本设置(画笔宽度、画笔颜色、画笔的圆润度等)。 3. 把之前保存的图片绘制进context中。 4. 设置`brush`的基本属性,以便子类更方便的绘图;调用具体的绘图方法,并最终添加到context中。 5. 从当前的context中,得到Image,如果是`ended`状态或者需要支持连续不断的绘图,则将Image保存到`realImage`中。 6. 实时显示当前的绘制状态,并记录绘制的最后一个点。 这些工作完成以后,我们就可以开始写第一个工具了:铅笔工具。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b0c445.png) ### 铅笔工具 铅笔工具应该支持连续不断的绘图(不断的保存到realImage中),这也是我们给`PaintBrush`接口增加`supportedContinuousDrawing`方法的原因,考虑到用户的手指可能快速的移动,导致从一个点到另一个点有着跳跃性的动作,我们对铅笔工具采用画直线的方式来实现。  首先创建一个类,名为`PencilBrush`,继承自`BaseBrush`类,实现如下: ~~~ class PencilBrush: BaseBrush { override func drawInContext(context: CGContextRef) { if let lastPoint = self.lastPoint { CGContextMoveToPoint(context, lastPoint.x, lastPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } else { CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } override func supportedContinuousDrawing() -> Bool { return true } } ~~~ 如果lastPoint为nil,则基于beginPoint画线,反之则基于lastPoint画线。  这样一来,一个铅笔工具就完成了,怎么样,很简单吧。  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b2fe50.png) * * * ## 测试 到目前为止,我们的`ViewController`还保持着默认的状态,是时候先为铅笔工具写一些测试代码了。  在`ViewController`添加`board`属性,并与`Main.storyboard`中的Board关联起来;创建一个`brushes`属性,并为之赋值为: ~~~ var brushes = [PencilBrush()] ~~~ 在`ViewController`中添加`switchBrush:`方法,并把`Main.storyboard`中的SegmentedControl的`ValueChanged`连接到`ViewController`的`switchBrush:`方法上,实现如下: ~~~ @IBAction func switchBrush(sender: UISegmentedControl) { assert(sender.tag < self.brushes.count, "!!!") self.board.brush = self.brushes[sender.selectedSegmentIndex] } ~~~ 最后在`viewDidLoad`方法中做一个初始化:  `self.board.brush = brushes[0]`  编译、运行,铅笔工具可以完美运行~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b42845.jpg) * * * ## 其他的工具 接下来我们把其他的绘图工具也实现了。  其他的工具不像铅笔工具,不需要支持连续不断的绘图,所以也就不用覆盖`supportedContinuousDrawing`方法了。 ### 直尺 创建一个`LineBrush`类,实现如下: ~~~ class LineBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } ~~~ ### 虚线 创建一个`DashLineBrush`类,实现如下: ~~~ class DashLineBrush: BaseBrush { override func drawInContext(context: CGContextRef) { let lengths: [CGFloat] = [self.strokeWidth * 3, self.strokeWidth * 3] CGContextSetLineDash(context, 0, lengths, 2); CGContextMoveToPoint(context, beginPoint.x, beginPoint.y) CGContextAddLineToPoint(context, endPoint.x, endPoint.y) } } ~~~ 这里我们就用到了`BaseBrush`的`strokeWidth`属性,因为我们想要创建一条动态的虚线。 ### 矩形 创建一个`RectangleBrush`类,实现如下: ~~~ class RectangleBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextAddRect(context, CGRect(origin: CGPoint(x: min(beginPoint.x, endPoint.x), y: min(beginPoint.y, endPoint.y)), size: CGSize(width: abs(endPoint.x - beginPoint.x), height: abs(endPoint.y - beginPoint.y)))) } } ~~~ 我们用到了一些计算,因为我们希望矩形的区域不是由beginPoint定死的。 ### 圆形 创建一个`EllipseBrush`类,实现如下: ~~~ class EllipseBrush: BaseBrush { override func drawInContext(context: CGContextRef) { CGContextAddEllipseInRect(context, CGRect(origin: CGPoint(x: min(beginPoint.x, endPoint.x), y: min(beginPoint.y, endPoint.y)), size: CGSize(width: abs(endPoint.x - beginPoint.x), height: abs(endPoint.y - beginPoint.y)))) } } ~~~ 同样有一些计算,理由同上。 ### 橡皮擦 从本文一开始就说过了,我们要做一个***真正的橡皮擦***,网上有很多的橡皮擦的实现其实就是把画笔颜色设置为背景色,但是如果背景色可以动态设置,甚至设置为一个渐变的图片时,这种方法就失效了,所以有些绘图app的背景色就是固定为白色的。  其实Apple的Quartz2D框架本身就是支持橡皮擦的,只用一个方法就可以完美实现。  让我们创建一个`EraserBrush`类,实现如下: ~~~ class EraserBrush: PencilBrush { override func drawInContext(context: CGContextRef) { CGContextSetBlendMode(context, kCGBlendModeClear); super.drawInContext(context) } } ~~~ 注意,与其他的工具不同,橡皮擦是继承自`PencilBrush`的,因为橡皮擦本身也是基于点的,而`drawInContext`里也只是加了一句: ~~~ CGContextSetBlendMode(context, kCGBlendModeClear); ~~~ 加入这一句代码,一个真正的橡皮擦便实现了。 * * * ## 再次测试 现在我们的工程结构应该类似于这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b550f4.jpg)  我们修改下`ViewController`中的`brushes`属性的初始值: ~~~ var brushes = [PencilBrush(), LineBrush(), DashLineBrush(), RectangleBrush(), EllipseBrush(), EraserBrush()] ~~~ 编译、运行:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b6abd9.jpg)  除了橡皮擦擦除的范围太小以外,一切都很完美~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b812ad.png) * * * ## 设计思路 在继续完成剩下的功能之前,我想先对之前的代码进行些说明。 ### 为什么不用drawRect方法 其实我最开始也是使用drawRect方法来完成绘制,但是感觉限制很多,比如context无法保存,还是要每次重画(虽然可以保存到一个BitMapContext里,但是这样与保存到image里有什么区别呢?);后来用CALayer保存每一条CGPath,但是这样仍然不能避免每次重绘,因为需要考虑到橡皮擦和画笔属性之类的影响,这么一来还不如采用image的方式来保存最新绘图板。  既然定下了以image来保存绘图板,那么drawRect就不方便了,因为不能用`UIGraphicsBeginImageContext`方法来创建一个ImageContext。 ### ViewController与Board、BaseBrush之间的关系 在`ViewController`、`Board`和`BaseBrush`这三者之间,虽然VC要知道另外两个组件,但是仅限于选择对应的工具给Board,Board本身并不知道当前的brush是哪个brush,也不需要知道其内部实现,只管调用对应的brush就行了;BaseBrush(及其子类)也并不知道自己将会被用于哪,它们只需要实现自己的算法即可。类似于这样的图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44b99834.jpg)  实际上这里包含了两个设计模式。 #### 策略设计模式 `策略设计模式`的UML图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44bd1665.jpg)  `策略设计模式`在iOS中也应用广泛,如`AFNetworking`的`AFHTTPRequestSerializer`和`AFHTTPResponseSerializer`的设计,通过在运行时动态的改变委托对象,变换行为,使程序模块之间解耦、提高应变能力。  以我们的绘图板为例,输出不同的图形就意味着不同的算法,用户可根据不同的需求来选择某一种算法,即BaseBrush及其子类做具体的封装,这样的好处是每一个子类只关心自己的算法,达到了高聚合的原则,高级模块(Board)不用关心具体实现。  想象一下,如果是让Board里自身来处理这些算法,那代码中无疑会充斥很多与算法选择相关的逻辑,而且每增加一个算法都需要重新修改Board类,这又与代码应该对拓展开放、对修改关闭原则有了冲突,而且每个类也应该只有一个责任。  通过采用策略模式我们实现了一个好维护、易拓展的程序(妈妈再也不用担心工具栏不够用了^^)。 策略模式的定义:***定义一个算法群,把每一个算法分别封装起来,让它们之间可以互相替换,使算法的变化独立于使用它的用户之上。*** #### 模板方法 在传统的策略模式中,每一个算法类都独自完成整个算法过程,例如一个网络解析程序,可能有一个算法用于解析`JSON`,有另一个算法用于解析`XML`等(另外一个例子是压缩程序,用`ZIP`或`RAR`算法),独自完成整个算法对灵活性更好,但免不了会有重复代码,在`DrawingBoard`里我们做一个折中,尽量保证灵活性,又最大限度地避免重复代码。  我们将`BaseBrush`的角色提升为算法的基类,并提供一些便利的属性(如`beginPoint`、`endPoint`、`strokeWidth`等),然后在`Board`的`drawingImage`方法里对`BaseBrush`的接口进行调用,而`BaseBrush`不会知道自己的接口是如何联系起来的,虽然`supportedContinuousDrawing`(这是一个“钩子”)甚至影响了算法的流程(铅笔需要实时绘图)。  我们用`drawingImage`搭建了一个算法的骨架,看起来像是模板方法的UML图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44be3724.jpg) > 图中右边的方框代表模板方法。 `BaseBrush`通过提供抽象方法(`drawInContext`)、具体方法或钩子方法(`supportedContinuousDrawing`)来对应算法的每一个步骤,让其子类可以重定义或实现这些步骤。同时,让模板方法(即`dawingImage`)定义一个算法的骨架,模板方法不仅可以调用在抽象类中实现的基本方法,也可以调用在抽象类的子类中实现的基本方法,还可以调用其他对象中的方法。  除了对算法的封装以外,模板方法还能防止“循环依赖”,即高层组件依赖低层组件,反过来低层组件也依赖高层组件。想像一下,如果既让Board选择具体的算法子类,又让算法类直接调用drawingImage方法(不提供钩子,直接把Board的事件下发下去),那到时候就热闹了,这些类串在一起难以理解,又不好维护。 模板方法的定义:***在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。*** > 其实模式都很简单,很多人在工作中会思考如何让自己的代码变得更好,“情不自禁”地就会慢慢实现这些原则,了解模式的设计意图,有助于在遇到需要折中的地方更加明白如何在设计上取舍。 以上就是我设计时的思路,说完了,接下来还要完成的工作有: * 提供对画笔颜色、粗细的设置 * 背景设置 * 全屏绘图(不能让Board一直显示不全) 先从画笔开始,*Let’s go!* * * * ## 画笔设置 不管是画笔还是背景设置,我们都要有一个能提供设置的工具栏。 ### 设置工具栏 所以我们往`Board`上再盖一个`UIToolbar`,与顶部的View类似: 1. 拖一个`UIToolbar`到`Board`的父类上,与`Board`的视图层级平级。 2. 设置`UIToolbar`的约束:左、右、下间距为0,高为44:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c01956.jpg) 3. 往`UIToolbar`上拖一个`UIBarButtonItem`,`title`就写:画笔设置。 4. 在`ViewController`里增加一个`paintingBrushSettings`方法,并把`UIBarButtonItem`的`action`连接`paintingBrushSettings`方法上。 5. 在`ViewController`里增加一个`toolar`属性,并把Xib中的`UIToolbar`连接到`toolbar`上。 UIToolbar配置好后,UI及视图层级如下:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c12d73.jpg) ### RGBColorPicker 考虑到多个页面需要选取自定义的颜色,我们先创建一个工具类:`RGBColorPicker`,用于选择RGB颜色: ~~~ class RGBColorPicker: UIView { var colorChangedBlock: ((color: UIColor) -> Void)? private var sliders = [UISlider]() private var labels = [UILabel]() override init(frame: CGRect) { super.init(frame: frame) self.initial() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.initial() } private func initial() { self.backgroundColor = UIColor.clearColor() let trackColors = [UIColor.redColor(), UIColor.greenColor(), UIColor.blueColor()] for index in 1...3 { let slider = UISlider() slider.minimumValue = 0 slider.value = 0 slider.maximumValue = 255 slider.minimumTrackTintColor = trackColors[index - 1] slider.addTarget(self, action: "colorChanged:", forControlEvents: .ValueChanged) self.addSubview(slider) self.sliders.append(slider) let label = UILabel() label.text = "0" self.addSubview(label) self.labels.append(label) } } override func layoutSubviews() { super.layoutSubviews() let sliderHeight = CGFloat(31) let labelWidth = CGFloat(29) let yHeight = self.bounds.size.height / CGFloat(sliders.count) for index in 0..<self.sliders.count { let slider = self.sliders[index] slider.frame = CGRect(x: 0, y: CGFloat(index) * yHeight, width: self.bounds.size.width - labelWidth - 5.0, height: sliderHeight) let label = self.labels[index] label.frame = CGRect(x: CGRectGetMaxX(slider.frame) + 5, y: slider.frame.origin.y, width: labelWidth, height: sliderHeight) } } override func intrinsicContentSize() -> CGSize { return CGSize(width: UIViewNoIntrinsicMetric, height: 107) } @IBAction private func colorChanged(slider: UISlider) { let color = UIColor( red: CGFloat(self.sliders[0].value / 255.0), green: CGFloat(self.sliders[1].value / 255.0), blue: CGFloat(self.sliders[2].value / 255.0), alpha: 1) let label = self.labels[find(self.sliders, slider)!] label.text = NSString(format: "%.0f", slider.value) if let colorChangedBlock = self.colorChangedBlock { colorChangedBlock(color: color) } } func setCurrentColor(color: UIColor) { var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: nil) let colors = [red, green, blue] for index in 0..<self.sliders.count { let slider = self.sliders[index] slider.value = Float(colors[index]) * 255 let label = self.labels[index] label.text = NSString(format: "%.0f", slider.value) } } } ~~~ 这个工具类很简单,没有采用Auto Layout进行布局,因为`layoutSubviews`方法已经能很好的满足我们的需求了。当用户拖动任何一个`UISlider`的时候,我们能实时的通过`colorChangedBlock`回调给外部。它能展现一个这样的视图:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c2ddc8.jpg)  不过虽然该工具类本身没有采用Auto Layout进行布局,但是它还是支持Auto Layout的,当它被添加到某个Auto Layout的视图中的时候,Auto Layout布局系统可以通过`intrinsicContentSize`知道该视图的尺寸信息。  最后它还有一个`setCurrentColor`方法从外部接收一个UIColor,可以用于初始化。 ### 画笔设置的UI 我打算在用户点击`画笔设置`的时候,从底部弹出一个控制面板(就像系统的`Control Center`那样),所以我们还要有一个像这样的设置UI:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c3ba1b.jpg)  具体的,创建一个`PaintingBrushSettingsView`类,同时创建一个`PaintingBrushSettingsView.xib`文件,并把xib中view的`Class`设为`PaintingBrushSettingsView`,设置view的背景色为透明: 1. 放置一个title为“画笔粗细”的`UILabel`,约束设为:宽度固定为68,高度固定为21,左和上边距为8。 2. 放置一个title为“1”的`UILabel`,“1”与“画笔粗细”的垂直间距为10,宽度固定为10,高度固定为21,与`superview`的左边距为10。 3. 放置一个`UISlider`,用于调节画笔的粗细,与“1”的水平间距为5,并与“1”垂直居中,高度固定为30,宽度暂时不设,在`PaintingBrushSettingsView`中添加`strokeWidthSlider`属性,与之连接起来。 4. 放置一个title为“20”的`UILabel`,约束设为:宽度固定为20,高度固定为21,top与“1”相同,与`superview`的右间距为10。并把上一步中的`UISlider`的右间距设为与“20”相隔5。 5. 放置一个title为“画笔颜色”的`UILabel`,宽、高、left与“画笔粗细”相同,与上面`UISlider`的垂直间距设为12。 6. 放置一个`UIView`至“画笔颜色”下方(上图中被选中的那个UIView),宽度固定为50,高度固定为30,left与“画笔颜色”相同,并且与“画笔颜色”的垂直间距为5,在`PaintingBrushSettingsView`中添加`strokeColorPreview`属性,与之连接起来。 7. 放置一个`UIView`,把它的Class改为`RGBColorPicker`,约束设为:left与顶部的UISlider相同,底部与superview的间距为0,右间距为10,与上一步中的UIView的垂直间距为5。 `PaintingBrushSettingsView`类的完整代码如下: ~~~ class PaintingBrushSettingsView : UIView { var strokeWidthChangedBlock: ((strokeWidth: CGFloat) -> Void)? var strokeColorChangedBlock: ((strokeColor: UIColor) -> Void)? @IBOutlet private var strokeWidthSlider: UISlider! @IBOutlet private var strokeColorPreview: UIView! @IBOutlet private var colorPicker: RGBColorPicker! override func awakeFromNib() { super.awakeFromNib() self.strokeColorPreview.layer.borderColor = UIColor.blackColor().CGColor self.strokeColorPreview.layer.borderWidth = 1 self.colorPicker.colorChangedBlock = { [unowned self] (color: UIColor) in self.strokeColorPreview.backgroundColor = color if let strokeColorChangedBlock = self.strokeColorChangedBlock { strokeColorChangedBlock(strokeColor: color) } } self.strokeWidthSlider.addTarget(self, action: "strokeWidthChanged:", forControlEvents: .ValueChanged) } func setBackgroundColor(color: UIColor) { self.strokeColorPreview.backgroundColor = color self.colorPicker.setCurrentColor(color) } func strokeWidthChanged(slider: UISlider) { if let strokeWidthChangedBlock = self.strokeWidthChangedBlock { strokeWidthChangedBlock(strokeWidth: CGFloat(slider.value)) } } } ~~~ `strokeWidthChangedBlock`和`strokeColorChangedBlock`两个*Block*用于给外部传递状态。`setBackgroundColor`用于初始化。 #### 关于 Swift 1.2 在 Swift 1.2里,不能用 `setBackgroundColor`方法了,具体的,见Xcode 6.3的发布文档:[Xcode 6.3 Release Notes](https://developer.apple.com/library/ios/releasenotes/DeveloperTools/RN-Xcode/Chapters/xc6_release_notes.html#//apple_ref/doc/uid/TP40001051-CH4-DontLinkElementID_23),下面是用`didSet`代替原有的`setBackgroundColor`方法: ~~~ override var backgroundColor: UIColor? { didSet { self.strokeColorPreview.backgroundColor = self.backgroundColor self.colorPicker.setCurrentColor(self.backgroundColor!) super.backgroundColor = oldValue } } ~~~ #### 实现毛玻璃效果 在把`PaintingBrushSettingsView`显示出来之前,我们要先想一想以何种方式展现比较好,众所周知`Control Center`是有毛玻璃效果的,我们也想要这样的效果,而且不用自己实现。那如何产生效果? 答案是用`UIToolbar`就行了。  `UIToolbar`本身就是带有毛玻璃效果的,只要你不设置背景色,并且`translucent`属性为true,“恰好”我们页面底部就有一个`UIToolbar`,我们把它拉高就可以插入展现`PaintingBrushSettingsView`了。  只要*get*到了这一点,毛玻璃效果就算实现了~~  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c4da62.png) ### 测试画笔设置 我们在ViewController新增加几个属性: ~~~ var toolbarEditingItems: [UIBarButtonItem]? var currentSettingsView: UIView? @IBOutlet var toolbarConstraintHeight: NSLayoutConstraint! ~~~ `toolbarConstraintHeight`连接到`Main.storyboard`中对应的约束上就行了。`toolbarEditingItems`能让我们在`UIToolbar`上显示不同的`items`,本来还需要一个`toolbarItems`属性的,因为`UIViewController`类本身就自带,我们便不用单独新增。`currentSettingsView`是用来保存当前展示的哪个设置页面,考虑到我们后面会增加`背景设置`,这个属性还是有必要的。  我们先写一个往toolbar上添加约束的工具方法: ~~~ func addConstraintsToToolbarForSettingsView(view: UIView) { view.setTranslatesAutoresizingMaskIntoConstraints(false) self.toolbar.addSubview(view) self.toolbar.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-0-[settingsView]-0-|", options: .DirectionLeadingToTrailing, metrics: nil, views: ["settingsView" : view])) self.toolbar.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[settingsView(==height)]", options: .DirectionLeadingToTrailing, metrics: ["height" : view.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height], views: ["settingsView" : view])) } ~~~ 这个工具方法会把传入进来的view添加到toolbar上,同时添加相应的约束。注意高度的约束,我是通过`systemLayoutSizeFittingSize`方法计算出设置视图最佳的高度,这是为了达到更好的拓展性(背景设置与画笔设置所需要的高度很可能会不同)。  然后再增加一个`setupBrushSettingsView`方法: ~~~ func setupBrushSettingsView() { let brushSettingsView = UINib(nibName: "PaintingBrushSettingsView", bundle: nil).instantiateWithOwner(nil, options: nil).first as PaintingBrushSettingsView self.addConstraintsToToolbarForSettingsView(brushSettingsView) brushSettingsView.hidden = true brushSettingsView.tag = 1 brushSettingsView.setBackgroundColor(self.board.strokeColor) brushSettingsView.strokeWidthChangedBlock = { [unowned self] (strokeWidth: CGFloat) -> Void in self.board.strokeWidth = strokeWidth } brushSettingsView.strokeColorChangedBlock = { [unowned self] (strokeColor: UIColor) -> Void in self.board.strokeColor = strokeColor } } ~~~ 我们在这个方法里实例化了一个`PaintingBrushSettingsView`,并添加到toolbar上,增加相应的约束,以及一些初始化设置和两个Block回调的处理。  然后修改`viewDidLoad`方法,增加以下行为: ~~~ //--- self.toolbarEditingItems = [ UIBarButtonItem(barButtonSystemItem:.FlexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "完成", style:.Plain, target: self, action: "endSetting") ] self.toolbarItems = self.toolbar.items self.setupBrushSettingsView() //--- ~~~ 在`paintingBrushSettings`方法里响应点击: ~~~ @IBAction func paintingBrushSettings() { self.currentSettingsView = self.toolbar.viewWithTag(1) self.currentSettingsView?.hidden = false self.updateToolbarForSettingsView() } func updateToolbarForSettingsView() { self.toolbarConstraintHeight.constant = self.currentSettingsView!.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height + 44 self.toolbar.setItems(self.toolbarEditingItems, animated: true) UIView.beginAnimations(nil, context: nil) self.toolbar.layoutIfNeeded() UIView.commitAnimations() self.toolbar.bringSubviewToFront(self.currentSettingsView!) } ~~~ `updateToolbarForSettingsView`也是一个工具方法,用于更新toolbar的高度。  由于我们采用了Auto Layout进行布局,动画要通过调用`layoutIfNeeded`方法来实现。  响应点击“完成”按钮的`endSetting`方法: ~~~ @IBAction func endSetting() { self.toolbarConstraintHeight.constant = 44 self.toolbar.setItems(self.toolbarItems, animated: true) UIView.beginAnimations(nil, context: nil) self.toolbar.layoutIfNeeded() UIView.commitAnimations() self.currentSettingsView?.hidden = true } ~~~ 这么一来画笔设置就做完了,代码应该还是比较好理解,编译、运行后,应该能看到:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c6035c.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c7340b.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c83fe7.jpg)  完成度已经很高了^^!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44c9709b.png) * * * ## 背景设置 整体的框架基本上已经在之前的工作中搭好了,我们快速过掉这一节。  在`Main.storyboard`中增加了一个title为“背景设置”的`UIBarButtonItem`,并将action连接到`ViewController`的`backgroundSettings`方法上,你可以选择在插入“背景设置”之前,先插入一个`FlexibleSpace`的`UIBarButtonItem`。  创建`BackgroundSettingsVC`类,继承自`UIViewController`,这与画笔设置继承于`UIView`不同,我们希望背景设置可以在用户的相册中选择照片,而使用`UIImagePickerController`的前提是要实现`UIImagePickerControllerDelegate`、`UINavigationControllerDelegate`两个接口,如果让UIView来实现这两个接口会很奇怪。  创建一个`BackgroundSettingsVC.xib`文件: 1. 放置一个title为“从相册中选择背景图”的UIButton,约束为:左、上边距为8,宽度固定为135,高度固定为30。 2. 放置一个RGBColorPicker,约束为:左、右边距为8,与UIButton的垂直间距为20,底部与superview齐平。 3. 把UIButton的`Touch Up Inside`事件连接到`BackgroundSettingsVC`的`pickImage`方法上;RGBColorPicker连接到`BackgroundSettingsVC`的`colorPicker`属性上。 看上去像这样:  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ca347a.jpg) `BackgroundSettingsVC`类的完整代码: ~~~ class BackgroundSettingsVC : UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate { var backgroundImageChangedBlock: ((backgroundImage: UIImage) -> Void)? var backgroundColorChangedBlock: ((backgroundColor: UIColor) -> Void)? @IBOutlet private var colorPicker: RGBColorPicker! lazy private var pickerController: UIImagePickerController = { [unowned self] in let pickerController = UIImagePickerController() pickerController.delegate = self return pickerController }() override func awakeFromNib() { super.awakeFromNib() self.colorPicker.colorChangedBlock = { [unowned self] (color: UIColor) in if let backgroundColorChangedBlock = self.backgroundColorChangedBlock { backgroundColorChangedBlock(backgroundColor: color) } } } func setBackgroundColor(color: UIColor) { self.colorPicker.setCurrentColor(color) } @IBAction func pickImage() { self.presentViewController(self.pickerController, animated: true, completion: nil) } // MARK: UIImagePickerControllerDelegate Methods func imagePickerController(picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [NSObject : AnyObject]) { let image = info[UIImagePickerControllerOriginalImage] as UIImage if let backgroundImageChangedBlock = self.backgroundImageChangedBlock { backgroundImageChangedBlock(backgroundImage: image) } self.dismissViewControllerAnimated(true, completion: nil) } // MARK: UINavigationControllerDelegate Methods func navigationController(navigationController: UINavigationController, willShowViewController viewController: UIViewController, animated: Bool) { UIApplication.sharedApplication().setStatusBarHidden(true, withAnimation: .None) } } ~~~ 同样用两个*Block*进行回调;`setBackgroundColor`公共方法用于设置内部的RGBColorPicker的初始颜色状态;在`UINavigationControllerDelegate`里隐藏系统默认显示的状态栏。  回到`ViewController`,我们对背景设置进行测试。  像`setupBrushSettingsView`方法一样,我们增加一个`setupBackgroundSettingsView`方法: ~~~ func setupBackgroundSettingsView() { let backgroundSettingsVC = UINib(nibName: "BackgroundSettingsVC", bundle: nil).instantiateWithOwner(nil, options: nil).first as BackgroundSettingsVC self.addConstraintsToToolbarForSettingsView(backgroundSettingsVC.view) backgroundSettingsVC.view.hidden = true backgroundSettingsVC.view.tag = 2 backgroundSettingsVC.setBackgroundColor(self.board.backgroundColor!) self.addChildViewController(backgroundSettingsVC) backgroundSettingsVC.backgroundImageChangedBlock = { [unowned self] (backgroundImage: UIImage) in self.board.backgroundColor = UIColor(patternImage: backgroundImage) } backgroundSettingsVC.backgroundColorChangedBlock = { [unowned self] (backgroundColor: UIColor) in self.board.backgroundColor = backgroundColor } } ~~~ 修改viewDidLoad方法: ~~~ self.toolbarEditingItems = [ UIBarButtonItem(barButtonSystemItem:.FlexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "完成", style:.Plain, target: self, action: "endSetting") ] self.toolbarItems = self.toolbar.items self.setupBrushSettingsView() self.setupBackgroundSettingsView() // Added~!!! ~~~ 实现`backgroundSettings`方法: ~~~ @IBAction func backgroundSettings() { self.currentSettingsView = self.toolbar.viewWithTag(2) self.currentSettingsView?.hidden = false self.updateToolbarForSettingsView() } ~~~ 编译、运行,现在你可以用不同的背景色(或背景图)了!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44cb5d9b.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44cc9e83.jpg)![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44ce8a0d.jpg) * * * ## 全屏绘图 到目前为止,`Board`一直显示不全(事实上,我很早就实现了全屏绘图,但是优先级一直被我排在最后![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-11-04_5639cee14c0a0.gif)),现在是时候来解决它了。  解决思路是这样的:当用户开始绘图的时候,我们把顶部和底部两个View隐藏;当用户结束绘图的时候,再让两个View显示。  为了获取用户的绘图状态,我们需要在`Board`里加个“钩子”: ~~~ // 增加一个Block回调 var drawingStateChangedBlock: ((state: DrawingState) -> ())? private func drawingImage() { if let brush = self.brush { // hook if let drawingStateChangedBlock = self.drawingStateChangedBlock { drawingStateChangedBlock(state: self.drawingState) } UIGraphicsBeginImageContext(self.bounds.size) // ... ~~~ 这样一来用户绘图的状态就在ViewController掌握中了。  ViewController想要控制两个View的话,还需要增加几个属性: ~~~ @IBOutlet var topView: UIView! @IBOutlet var topViewConstraintY: NSLayoutConstraint! @IBOutlet var toolbarConstraintBottom: NSLayoutConstraint! ~~~ 然后在viewDidLoad方法里增加对“钩子”的处理: ~~~ self.board.drawingStateChangedBlock = {(state: DrawingState) -> () in if state != .Moved { UIView.beginAnimations(nil, context: nil) if state == .Began { self.topViewConstraintY.constant = -self.topView.frame.size.height self.toolbarConstraintBottom.constant = -self.toolbar.frame.size.height self.topView.layoutIfNeeded() self.toolbar.layoutIfNeeded() } else if state == .Ended { UIView.setAnimationDelay(1.0) self.topViewConstraintY.constant = 0 self.toolbarConstraintBottom.constant = 0 self.topView.layoutIfNeeded() self.toolbar.layoutIfNeeded() } UIView.commitAnimations() } } ~~~ 只有当状态为开始或结束的时候我们才需要更新UI状态,而且我们在结束的事件里延迟了1秒钟,这样用户可以暂时预览下全图。 > 依靠Auto Layout布局系统以及我们在钩子里对高度的处理,用户在设置页面绘图时也能完美运行。 * * * ## 保存到图库 最后一个功能:保存到图库!  在toolbar上插入一个title为“保存到图库”的`UIBarButtonItem`,还是可以先插入一个`FlexibleSpace`的`UIBarButtonItem`,然后把action连接到ViewController的`saveToAlbumy`方法上: ~~~ @IBAction func saveToAlbum() { UIImageWriteToSavedPhotosAlbum(self.board.takeImage(), self, "image:didFinishSavingWithError:contextInfo:", nil) } ~~~ 我为`Board`添加一个新的公共方法:takeImage: ~~~ func takeImage() -> UIImage { UIGraphicsBeginImageContext(self.bounds.size) self.backgroundColor?.setFill() UIRectFill(self.bounds) self.image?.drawInRect(self.bounds) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image } ~~~ 然后是一个方法指针的回调: ~~~ func image(image: UIImage, didFinishSavingWithError error: NSError?, contextInfo:UnsafePointer<Void>) { if let err = error { UIAlertView(title: "错误", message: err.localizedDescription, delegate: nil, cancelButtonTitle: "确定").show() } else { UIAlertView(title: "提示", message: "保存成功", delegate: nil, cancelButtonTitle: "确定").show() } } ~~~ ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d10882.jpg)  旅行到终点了~!  ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-01-18_569ca44d67c3d.png) * * * ## 感谢一路的陪伴! 看了下,有些小长,文本+代码有2w3+,全部代码去除空行和空格有1w4+,直接贴代码会简单很多,但我始终觉得让代码完成功能并不是全部目的,代码背后隐藏的问题定义、设计、构建更有意义,毕竟软件开发完成“后”比完成“前”所花费的时间永远更多(除非是一个只有10行代码或者“一次性”的程序)。  希望与大家多多交流。 > 最后吐槽下CSDN新的Markdown编辑器,代码样式丑且不能自定义,而且有些代码高亮都无法识别。不过感觉草稿箱比以前更方便,问题主要还是集中在样式上,希望以后能不断改进,会一如既往的支持。 * * * ## 更新——撤消与重做功能 [Swift 绘图板功能完善以及终极优化](http://blog.csdn.net/zhangao0086/article/details/45289475) * * * ## GitHub地址 [DrawingBoard](https://github.com/zhangao0086/DrawingBoard)
';