iOS 绘制就是采集点,贝塞尔曲线得到形状,绘图上下文去渲染出来
Asana/Drawsana 图形库,设计的挺好
他可以画多种图形,画线、文本、橡皮擦、五角形、矩形、箭头、角度,
他支持多种操作,撤销上一步、还原上一步,平移选择的已渲染图形
他的实现,大量使用了协议
可以分为三个层次,行为的处理 ( 采集点的传递 ) ,图形的绘制, 呈现的视图( 最开始采集点, 最后的渲染呈现 )
Shape 协议,决定了可看 ( 可渲染 ),可点击
ShapeSelectable 协议,增加了形状区域和仿射变换。
Shape
有一个 id、 类型的区分,
渲染出来,可否响应点击事件,
更改绘图设置 ( 画线的颜色、画线的填充色、画线的宽度 )
public protocol Shape: AnyObject, Codable {var id: String { get set }static var type: String { get }func render(in context: CGContext)func hitTest(point: CGPoint) -> Boolfunc apply(userSettings: UserSettings)
}
ShapeSelectable
下面 3 个协议,添加功能
ShapeWithBoundingRect 协议继承自 Shape, 添加了区域
public protocol ShapeWithBoundingRect: Shape {var boundingRect: CGRect { get }
}
ShapeWithTransform 协议继承自 Shape, 添加了仿射变换
public protocol ShapeWithTransform: Shape {var transform: ShapeTransform { get set }
}
最终形状通用的协议为 ShapeSelectable
,
他继承自上面两个协议
public protocol ShapeSelectable: ShapeWithBoundingRect, ShapeWithTransform {
}
AngleShape
角度这个形状,需要三个点确定
class AngleShape: ShapeSelectable{public var a: CGPoint = .zeropublic var b: CGPoint = .zeropublic var c: CGPoint = .zero// ... // 实现通用的形状信息, id 、种类、线宽等
}
这里还有一个协议, ShapeWithThreePoints
该协议拿到了三个点,可算出三个点决定的矩形区域
extension AngleShape: ShapeWithThreePoints{}public protocol ShapeWithThreePoints {var a: CGPoint { get set }var b: CGPoint { get set }var c: CGPoint { get set }var strokeWidth: CGFloat { get set }
}
ShapeWithThreePoints
三点形状协议,统一处理了三个点形状的区域;
这个库的形状,大部分是 ShapeWithTwoPoints
, 2 点形状协议,统一处理了 2 点形状的区域,
椭圆、星星、矩形、线段,都是 2 点形状
工具 tool 是形状 shape 的进一步封装
工具的通用模版 DrawingTool
包含如下信息
public protocol DrawingTool: AnyObject {// 正在进行var isProgressive: Bool { get }// 工具名称var name: String { get }// 用户手指点击func handleTap(context: ToolOperationContext, point: CGPoint)// 用户手指刚滑动// 形状的拖拽刚开始 / 图形的绘制刚开始func handleDragStart(context: ToolOperationContext, point: CGPoint)// 用户手指滑动正在进行// 形状的拖拽继续 / 图形的绘制继续func handleDragContinue(context: ToolOperationContext, point: CGPoint, velocity: CGPoint)// 用户手指滑动结束了// 结束形状的拖拽 / 结束图形的绘制func handleDragEnd(context: ToolOperationContext, point: CGPoint)// 用户手指滑动取消了// 取消形状的拖拽 / 取消图形的绘制func handleDragCancel(context: ToolOperationContext, point: CGPoint)// 修改绘图设置,绘图的颜色func apply(context: ToolOperationContext, userSettings: UserSettings)}
使用工具 tool 的功能模版,
有两点形状的工具,有 3 点形状的工具,有画线形状的工具 ( pen ),
有选择工具 …
这里的例子是 2 点形状的工具,DrawingToolForShapeWithTwoPoints
他里面有一个属性, 正在画的形状 shapeInProgress,
该形状通过两个点决定
pen class DrawingToolForShapeWithTwoPoints: DrawingTool {public typealias ShapeType = Shape & ShapeWithTwoPointsopen var name: String { fatalError("Override me") }// shapeInProgress 通过下面的 makeShape() 方法创建public var shapeInProgress: ShapeType?open func makeShape() -> ShapeType {fatalError("Override me")}// ...// 实现了 DrawingTool 协议的方法
}
因为前面的协议,定义的比较完善,
大量通用的代码,放到了协议和父类里面,
所以具体绘制的工具,实现比较简洁
// 画线工具
public class LineTool: DrawingToolForShapeWithTwoPoints {public override var name: String { return "Line" }public override func makeShape() -> ShapeType { return LineShape() }
}// 箭头工具
public class ArrowTool: DrawingToolForShapeWithTwoPoints {public override var name: String { return "Arrow" }public override func makeShape() -> ShapeType {let shape = LineShape()shape.arrowStyle = .standardreturn shape}
}// 矩形工具
public class RectTool: DrawingToolForShapeWithTwoPoints {public override var name: String { return "Rectangle" }public override func makeShape() -> ShapeType { return RectShape() }
}// ...
重写触摸方法,采集到点
class ImmediatePanGestureRecognizer: UIGestureRecognizer {// 开始触摸override func touchesBegan(_ touches: Set
绘图需要两张 UIImage,
画一笔,把之前绘制的 UIImage 渲染出来,把正在画的那一笔渲染出来,得到绘制的视图,也就是第二张 UIImage
这里用了三张 UIImage, 一张最终版本 persistentBuffer
,
把之前绘制的 UIImage 渲染出来 , transientBuffer
得到绘制的视图,也就是第二张 UIImage , transientBufferWithShapeInProgress
public class DrawsanaView: UIView {private var persistentBuffer: UIImage?private var transientBuffer: UIImage?private var transientBufferWithShapeInProgress: UIImage?
对应的处理代码,
这里是一个画线/平移操作的绘制匿名函数, updateUncommittedShapeBuffers
,
let updateUncommittedShapeBuffers: () -> Void = {self.transientBufferWithShapeInProgress = DrawsanaUtilities.renderImage(size: self.drawing.size) {// 把之前绘制的 UIImage 渲染出来self.transientBuffer?.draw(at: .zero)// 把正在画的那一笔渲染出来self.tool?.renderShapeInProgress(transientContext: $0)}// 得到绘制的视图 transientBufferWithShapeInProgress// 得到绘制的视图, 呈现self.drawingContentView.layer.contents = self.transientBufferWithShapeInProgress?.cgImage// 正在进行,就更新if self.tool?.isProgressive == true {self.transientBuffer = self.transientBufferWithShapeInProgress}}
POP, 使用协议,能够避免大量的重复代码
如果面向过程写,好理解写。逻辑都在一坨。因为同样的代码,到处拷贝,不好维护,容易出错
角度形状,有 3 个点
public class AngleShape: ShapeWithThreePoints, ShapeWithStrokeState, ShapeSelectable{public static let type: String = "Angle"public var id: String = UUID().uuidStringpublic var a: CGPoint = .zeropublic var b: CGPoint = .zeropublic var c: CGPoint = .zero}
角度的画线方法
public func render(in context: CGContext) {// 开始绘制transform.begin(context: context)// 绘图效果的设置context.setLineCap(capStyle)context.setLineJoin(joinStyle)context.setLineWidth(strokeWidth)context.setStrokeColor(strokeColor.cgColor)if let dashPhase = dashPhase, let dashLengths = dashLengths {context.setLineDash(phase: dashPhase, lengths: dashLengths)} else {context.setLineDash(phase: 0, lengths: [])}// 画角度的两根线context.move(to: a)context.addLine(to: b)context.move(to: b)context.addLine(to: c)context.strokePath()// 画中间的角度弧形,和角度文字renderInfo(in: context)// 结束绘制transform.end(context: context)}
调用下面的,绘制中间的角度弧形,和角度文字
调用,绘制角度大小的文字 通过三个点,计算出角度的大小 绘制视图 DrawsanaView,里面有一个操作栈 操作栈 这个类,里面有两个操作的数组, 作为栈使用,操作后进先出, 一般修改,都是改后面的 里面有三个方法, 撤销功能和还原功能,好理解 剩下的添加操作, 每次画完一笔,添加进 添加操作的逻辑 每个工具,触摸完成/ 使用完成,就添加一次操作
private func renderInfo(in context: CGContext) {// 计算开始角,和结束角if a == c {return}let center = bvar startAngle = atan2(a.y - b.y, a.x - b.x)var endAngle = atan2(c.y - b.y, c.x - b.x)if 0
private func renderDegreesInfo(in context: CGContext, startAngle: CGFloat, endAngle: CGFloat) {// 得到角度的大小,富文本let radius: CGFloat = 44let fontSize: CGFloat = 14let font = UIFont.systemFont(ofSize: fontSize)let string = NSAttributedString(string: "\(degreesBetweenThreePoints(pointA: a, pointB: b, pointC: c))°", attributes: [NSAttributedString.Key.font: font,NSAttributedString.Key.foregroundColor: strokeColor])// 计算出,摆放角度大小文本的位置let normalEnd = startAngle private func degreesBetweenThreePoints(pointA: CGPoint, pointB: CGPoint, pointC: CGPoint) -> Int {// 邻边let a = pow((pointB.x - pointA.x), 2) + pow((pointB.y - pointA.y), 2)// 邻边let b = pow((pointB.x - pointC.x), 2) + pow((pointB.y - pointC.y), 2)// 对边let c = pow((pointC.x - pointA.x), 2) + pow((pointC.y - pointA.y), 2)if a == 0 || b == 0 {return 0}return Int(acos((a + b - c) / sqrt(4 * a * b) ) * 180 / CGFloat.pi)}
操作可撤销,可还原的实现
operationStack
public class DrawsanaView: UIView {public lazy var operationStack: DrawingOperationStack = {return DrawingOperationStack(drawing: drawing)}()}
DrawingOperationStack
的实现func apply(operation
undoStack
, 等待用户去操作public class DrawingOperationStack {public private(set) var undoStack = [DrawingOperation]()var redoStack = [DrawingOperation]()// 添加操作public func apply(operation: DrawingOperation) {guard operation.shouldAdd(to: self) else { return }undoStack.append(operation)redoStack = []operation.apply(drawing: drawing)delegate?.drawingOperationStackDidApply(self, operation: operation)}/// 撤销操作@objc public func undo() {guard let operation = undoStack.last else { return }operation.revert(drawing: drawing)redoStack.append(operation)undoStack.removeLast()delegate?.drawingOperationStackDidUndo(self, operation: operation)}/// 恢复操作@objc public func redo() {guard let operation = redoStack.last else { return }operation.apply(drawing: drawing)undoStack.append(operation)redoStack.removeLast()delegate?.drawingOperationStackDidRedo(self, operation: operation)}
}
public func handleDragEnd(context: ToolOperationContext, point: CGPoint){// ...// 添加操作context.operationStack.apply(operation: AddShapeOperation(shape: shape))// ...}这个库,挺有特色的。功能强大,就要考虑到方方面面,绕来绕去