POP 绘图库 Asana/Drawsana 源代码看看

789 阅读6分钟

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))
    // ...
  }
这个库,挺有特色的。功能强大,就要考虑到方方面面,绕来绕去