可撤销草图绘制的跨平台解决思路

本文介绍了一款iOS绘图应用的开发过程,包括撤销功能、跨平台绘图、大图绘制等关键技术点,并解决了性能瓶颈及文本框手势边界处理等问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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)
        }
    }

github 链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值