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) -> Bool
func 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 = .zero
public var b: CGPoint = .zero
public 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
工具 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 & ShapeWithTwoPoints
open 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 = .standard
return 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<UITouch>, with event: UIEvent) {
// ...
}
// 画线中/ 拖拽视图中
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
// ...
}
// 结束触摸
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
// ...
}
}
最后的渲染呈现
绘图需要两张 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().uuidString
public var a: CGPoint = .zero
public var b: CGPoint = .zero
public 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)
}
调用下面的,绘制中间的角度弧形,和角度文字
private func renderInfo(in context: CGContext) {
// 计算开始角,和结束角
if a == c {
return
}
let center = b
var startAngle = atan2(a.y - b.y, a.x - b.x)
var endAngle = atan2(c.y - b.y, c.x - b.x)
if 0 < endAngle - startAngle
&& endAngle - startAngle < CGFloat.pi { // swap startAngle & endAngle
startAngle = startAngle + endAngle
endAngle = startAngle - endAngle
startAngle = startAngle - endAngle
}
// 逆时针,绘制中间的角度弧形
context.setLineWidth(strokeWidth / 2)
context.addArc(center: center, radius: 24, startAngle: startAngle, endAngle: endAngle, clockwise: true)
context.strokePath()
context.setLineWidth(strokeWidth)
// 绘制角度大小的文字
renderDegreesInfo(in: context, startAngle: startAngle, endAngle: endAngle)
}
调用,绘制角度大小的文字
private func renderDegreesInfo(in context: CGContext, startAngle: CGFloat, endAngle: CGFloat) {
// 得到角度的大小,富文本
let radius: CGFloat = 44
let fontSize: CGFloat = 14
let 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 < endAngle ? endAngle + 2 * CGFloat.pi : endAngle
let centerAngle = startAngle + (normalEnd - startAngle) / 2
let arcCenterX = b.x + cos(centerAngle) * radius - fontSize / 2
let arcCenterY = b.y + sin(centerAngle) * radius - fontSize / 2
// 绘制字符串
string.draw(at: CGPoint(x: arcCenterX, y: arcCenterY))
}
通过三个点,计算出角度的大小
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)
}
操作可撤销,可还原的实现
绘制视图 DrawsanaView,里面有一个操作栈 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))
// ...
}