iOS 开发草图绘制,简单
可参照前文 iOS 开发简单的手绘应用
思路:
* 绘图的步骤可撤销
那么绘图的每一个步骤,都要保存。建设一个行为栈,撤销操作,对应栈的后进先出。
本文保存三种行为,文书框、画线、与橡皮擦。
橡皮擦也是画线,那线的颜色为背景色,即可。
保存文本框
struct PaintItem: Codable {
var id: Int
var action: Int
var text: String? // 内容
var pos: MyPoint? // 坐标
}
文本框的行为,分为新建,和移动已有的文本框位置,通过 action 区分
保存两种画线
struct PaintItem: Codable {
var id: Int
var action: Int
var pointList: [MyPoint]? // 采集到的点
}
* 跨平台,绘图可以在安卓、iPhone 和 iPad 都呈现
思路是,做一个坐标转换,
将采集的点的坐标,转换为相对坐标
原 point -> CGPoint(x: point.x/canvasWidth, y: point.y/canvasHeight)
拿到服务端下发的相对坐标,再转化为用于绘制的坐标
* 大图绘制,本文例子是在一个 scrollView 上绘制
绘制时,不可滚动。画完后,可以滚动。
* 本文中的图片,特别大,频繁绘制,遇到性能问题,
优化原则,耗费性能的事件,少做
(easy + heavy) X n -> easy X n + heavy
* 本文支持编辑与查看的 list
为了逻辑简单,用两个草图,一个读,一个写
读,相对于写,不用采集坐标,文本框不可输入,不可移动
读,相对于写,逻辑差异比较大。
单独拎出来,代码会直观很多
实现流程简述:
功能实现
有两个视图,一个读,一个写
class DrawManager: NSObject{
lazy var painterView = BoardX() // 绘制, 编辑
lazy var painterReadOnly = BoardReadOnly() // 只读
}
加载完成,就把这两个视图,添加到内容视图 map 上。
这里计算内容高度,有点特别,
采用的是图片切片,每一栏五线谱,是一个图片切片,是一个 cell, 累计起来,成了图片高度
func addDrawBottom(){
map.addSubs([drawAdmin.painterView, drawAdmin.painterReadOnly])
var height: CGFloat = 200
guard let heights = self.score?.data.heights else {
return
}
for item in heights {
height += item.value
}
drawAdmin.painterView.snp.makeConstraints { (m) in
m.leading.top.width.equalTo(map)
m.height.equalTo(height)
}
drawAdmin.painterReadOnly.snp.makeConstraints { (m) in
m.leading.top.width.equalTo(map)
m.height.equalTo(height)
}
}
编辑、新建与显示,就是控制这两个视图的隐藏与显示
* iOS 开发草图绘制,简单
可编辑的画板,用于新建,和编辑已有的
class BoardX: UIImageView {
// 累计的操作
var actionList = [PaintItem]()
// 文本框
var textFieldList = [PainterTextField]()
// touch 状态记录
private var drawingState: DrawingState!
// 画笔
var brush = Brush(type: .pencil)
// 缓存图片
var realImage: UIImage?
// 当前行为
var action: PaintItem?
// 何时是,新建文本框操作
var isTextModeOpen = false
// ...
}
可参照前文 iOS 开发简单的手绘应用
* 放置文本框
这里处理的,比较简单,
文本框,放置在画板上,
移动文本框,就是给文本框,添加平移手势
/**
创建一个放置在画板上的 textview
*/
func createTextField(id: Int) -> PainterTextField {
let tv = PainterTextField(id: id, dele: self)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture))
tv.addGestureRecognizer(panGesture)
return tv
}
@objc func handlePanGesture(sender: UIPanGestureRecognizer) {
let tran = sender.translation(in: self)
var x = (sender.view?.center.x)! + tran.x
var y = (sender.view?.center.y)! + tran.y
sender.view?.center = CGPoint(x: x, y: y)
sender.setTranslation(.zero, in: self)
}
* 撤销操作,就是去除最新的行为,然后重新绘制
画布是一个 UIImageView,
先初始化,去除可见的图片 image,去除缓存的属性 realImage
有行为,才可以撤销
撤销,就是出栈,去除最新的行为。
接着初始化,去除所有的文本框。
重新绘制,done
func rollback(){
painterView.image = nil
painterView.realImage = nil
guard painterView.actionList.count > 0 else{
return
}
let _ = painterView.actionList.removeLast()
painterView.textFieldList.forEach {
$0.removeFromSuperview()
}
painterView.textFieldList.removeAll()
painterView.drawList(actionList: painterView.actionList)
}
* 清除操作
将图片,重置为透明背景图,
去除所有的文本框
func clearBoard(){
painterView.drawEmpty()
painterView.textFieldList.forEach {
$0.removeFromSuperview()
}
painterView.textFieldList.removeAll()
painterView.actionList.removeAll()
}
图片重置,为透明背景
func drawEmpty(){
UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, 0)
self.realImage = UIGraphicsGetImageFromCurrentImageContext()
UIColor.clear.setFill()
UIRectFill(self.bounds)
self.image = self.realImage
UIGraphicsEndImageContext()
}
遇到的问题:
文本介绍两种,性能瓶颈,和文本框手势边界处理
pad 开发,相对于 iPhone 开发,性能问题,更加明显
* 性能问题 1, 初始化内存打爆
当绘图的尺寸很大的时候, UIGraphicsGetImageFromCurrentImageContext
方法,非常消耗性能, 不能频繁调用
需要把所有的绘制相关逻辑,单独拎出来,集中绘制,统一渲染
(easy + heavy) X n -> easy X n + heavy
func drawList(actionList: [PaintItem]) {
var actionQueue = [PaintItem]()
for behave in actionList{
if [0, 2].contains(behave.action){
actionQueue.append(behave)
}
}
UIGraphicsBeginImageContextWithOptions(self.bounds.size, false, 0)
UIColor.clear.setFill()
UIRectFill(self.bounds)
let context = UIGraphicsGetCurrentContext()!
context.setLineCap(.round)
for item in actionQueue{
var points = [MyPoint]()
if let ps = item.pointList{
points.append(contentsOf: ps)
}
let brush: Brush
if item.action == 0{
brush = Brush(type: .pencil)
}
else{
brush = Brush(type: .eraser)
}
context.setLineWidth(brush.strokeWidth)
context.setStrokeColor(brush.strokeColor.cgColor)
// 4.
brush.draw(in: context, pairs: points)
context.strokePath()
}
realImage = UIGraphicsGetImageFromCurrentImageContext()
actionQueue.removeAll()
// ...
}
* 不能采集到点,就进行绘制
因为采集到点,就进行绘制与渲染,等于频繁调用 UIGraphicsGetImageFromCurrentImageContext
造成性能问题,体现是画出来的是多边形,不是流畅的曲线
因为频繁调用消耗性能的方法,丢帧严重,下面的采点方法,单位时间内采集到的点,严重不足
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
}
采用计时器,将采集点的方法,与绘制渲染调度的逻辑分开
// 跑,就绘制
var runs = false
let intervalSpan: TimeInterval = 0.15
func trigger(){
timer = Timer.scheduledTimer(withTimeInterval: intervalSpan, repeats: true) { (t) in
guard self.runs else{
return
}
self.drawingImage()
}
}
前文 iOS 开发简单的手绘应用 ,采集到一个点,就绘制渲染,是实时渲染
这里是把 0.15 秒内的点,一次绘制,算是弱实时渲染
// 采集到的很多点
var actionDotsQueue = [CGPoint]()
let intervalSpan: TimeInterval = 0.15
private func drawingImage() {
guard let context = ctx else {
return
}
context.setLineWidth(brush.strokeWidth)
context.setStrokeColor(brush.strokeColor.cgColor)
// 3.
if let realImage = self.realImage {
realImage.draw(in: self.bounds)
}
// 4.
brush.draw(in: context, dots: actionDotsQueue)
context.strokePath()
// 5.
image = UIGraphicsGetImageFromCurrentImageContext()
if self.drawingState != .ended {
self.realImage = image
}
brush.lastPoint = brush.endPoint
let lastP = actionDotsQueue.last
actionDotsQueue.removeAll()
if let oneTouch = lastP{
actionDotsQueue.append(oneTouch)
}
}
采用计时器, 需要注意的是
结束触摸事件,计时器不跑了,但是还有点,没绘制渲染,
需最后补一个绘制
上一次绘制,过了 0.5 s, 结束了,
已经结束了, 最后 0.5 s 的数据,计时器的调度方法,不会执行的
所以手动补下
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?){
self.runs = false
drawingImage()
actionDotsQueue.removeAll()
}
* 文本框手势,的边界处理
文本框,添加在画布上,
如果不检测边界,文本框拖出画布,就手势失效,拖不动了
为了简化逻辑,文本框高度固定,并初始化了一个最小宽度
边界检测,需要检测其上下左右
- 检测左右,比较简单,
原点有一个极大值和极小值,
x 小于极小值,等于极小值,
x 大于极大值,等于极大值
- 检测上下,稍微复杂
文本框,添加在画布上,
我们在意的,不仅仅是,文本框在画布上的位置,
在意的,是文本框在可视区域中的位置,
这里要做一个坐标转化,
转化后,判断 yRelative 的极小值,
yRelative 小于极小值,等于极小值,
再把 yRelative 转换为新的 y
转化后,还要判断 yRelative 的极大值,
yRelative 大于极大值,等于极大值,
再把 yRelative 转换为新的 y
y 的极大值,还有一个异常处理
底部拉出去了,1 s 内的底部拖动,忽略
var boundary = true
@objc func handlePanGesture(sender: UIPanGestureRecognizer) {
let point = sender.location(in: self)
if point.y < 0 || point.y > self.bounds.size.height {
return
}
let tran = sender.translation(in: self)
var x = (sender.view?.center.x)! + tran.x
// 计算左右
if x < PaintLayout.s.width/3 {
x = PaintLayout.s.width/3
}
if x > UI.width - PaintLayout.s.width/3 {
x = UI.width - PaintLayout.s.width/3
}
var y = (sender.view?.center.y)! + tran.y
let oldY = y
var reY = convert(CGPoint(x: 0, y: y), to: nil)
let relativeOldY = reY
if relativeOldY.y < PaintLayout.s.height/2{
reY.y = PaintLayout.s.height/2
y = oldY + reY.y - relativeOldY.y
print(y)
}
// 计算上下
var stdY = UI.height - 70
let gg = convert(frame.end, to: nil)
let ggY = gg.y - PaintLayout.s.height/2
if stdY > ggY{
stdY = ggY
}
var goodCondition = false
if relativeOldY.y > stdY{
reY.y = stdY
y = oldY + reY.y - relativeOldY.y
print(y)
if boundary{
goodCondition = true
boundary = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.boundary = true
}
}
}
sender.view?.center = CGPoint(x: x, y: y)
sender.setTranslation(.zero, in: self)
if sender.state == UIPanGestureRecognizer.State.ended{
goodCondition = true
}
if goodCondition, let origin = sender.view?.frame.origin{
let tf = sender.view as! PainterTextField
let item = PaintItem(id: tf.id_Dng, action: 3, pointList: nil, text: nil, pos: origin.toMyPoint())
self.actionList.append(item)
}
}