Search code examples
swiftuiviewcore-graphicscalayeruibezierpath

how can i access UIBezierPath that is drawn in draw(_ rect: CGRect)?


I'm drawing lines with UIBezierPath in draw(_ rect: CGRect) method previously i did tried that with CAShapeLayer but i wasn't able to select particular path and move it so i'm trying this, but after drawing i'm not able to access that BezierPath from view's subviews or it's sublayers and not directly from UIView. How can i access that bezierpath drawn in drawRect method so i can change it's position according to touches.

private var bezierPaths: [MyBezierPath] = [MyBezierPath]()

    override func draw(_ rect: CGRect) {
    super.draw(rect)
    UIColor.orange.set()
    for path in bezierPaths {
        path.lineWidth = 4
        path.lineCapStyle = .round
        path.lineJoinStyle = .round
        path.stroke()
    }
}

    func drawingPath(_ path: [MyBezierPath]) {
    bezierPaths = path
}

like creating CAShapeLayer and setting layer.path = BezierPath like this :

        let shapeLayer = CAShapeLayer()
        shapeLayer.lineWidth = 1
        shapeLayer.strokeColor = UIColor.black.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineCap = .round
        shapeLayer.lineJoin = .round
        shapeLayer.lineDashPattern = [10, 10]
        shapeLayer.name = "ShapeLayer"
        self.canvas.layer.addSublayer(shapeLayer)
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {

if canvas.layer.sublayers != nil && canvas.layer.sublayers?.last?.name == "ShapeLayer" {
                guard let layer = canvas.layer.sublayers?.last as? CAShapeLayer else { return }
                layer.path = path.cgPath
            }
}

like this i can access layer.path but how can i do it for path drawn in draw(Rect:) method ?


Solution

  • Depending on what you're actually trying to do, overriding draw(_:) may not be the best approach.

    For example, if you want to animate the drawn paths, it will be much easier if you are using CAShapeLayer as sublayers, or using subviews.

    Also, shape layers are highly optimized ... if you write your own draw / animate functions you risk ending up with lesser-optimized code.

    However, whichever approach you take, to "find the path" you want to use contains(_ point: CGPoint)

    For example, if you have an array of UIBezierPath, with all paths relative to the top-left (0,0) of the view, on touch you could do:

    // find a path that contains the touch point
    if let touchedPath = bezierPaths.first(where: {$0.contains(point)}) {
        // do something with that path
    }
    

    If the paths are not relative to the top-left of the view - for example, if the path is relative to the layer position, or is part of a subview - you'd need to convert the touch point.

    Here's a quick example that looks like this - on load, and then after dragging a few shapes around:

    enter image description here enter image description here

    We'll start with a simple "path" struct, which contains the bezier path and the color to use. We could add various other properties, such as line width, dash pattern, etc:

    struct MyPath {
        var color: UIColor = .white
        var path: UIBezierPath = UIBezierPath()
    }
    

    then we'll use this UIView subclass that will handle the drawing, as well as touches began/moved/ended:

    class BezDrawView: UIView {
        
        var myPaths: [MyPath] = [] {
            didSet {
                setNeedsDisplay()
            }
        }
        
        // used to track path to move
        var activePath: MyPath?
        var startPoint: CGPoint = .zero
        
        override func draw(_ rect: CGRect) {
            super.draw(rect)
            
            if myPaths.count > 0 {
                myPaths.forEach { p in
                    p.color.set()
                    p.path.stroke()
                }
            }
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            let point = t.location(in: self)
            // find a path that contains the touch point
            if let touchedPath = myPaths.first(where: {$0.path.contains(point)}) {
                self.activePath = touchedPath
                self.startPoint = point
                return
            }
        }
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let ap = activePath, let t = touches.first else { return }
            let point = t.location(in: self)
            // move the path by the distance the touch moved
            let tr = CGAffineTransform(translationX: point.x - startPoint.x, y: point.y - startPoint.y)
            ap.path.apply(tr)
            startPoint = point
            // this triggers draw(_:)
            setNeedsDisplay()
        }
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            // done dragging
            activePath = nil
        }
        
    }
    

    and an example controller which defines some sample shapes (paths):

    class ViewController: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            let testBezDrawView = BezDrawView()
            testBezDrawView.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
            testBezDrawView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testBezDrawView)
            
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // constrain bez draw view to all 4 sides with 20-points "padding"
                testBezDrawView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                testBezDrawView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testBezDrawView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                testBezDrawView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                
            ])
            
            // add some sample path shapes and colors
            let colors: [UIColor] = [
                .systemRed, .systemGreen, .systemBlue,
                .cyan, .magenta, .yellow, .orange, .green,
            ]
            let rects: [CGRect] = [
                CGRect(x: 20, y: 20, width: 60, height: 60),
                CGRect(x: 180, y: 20, width: 40, height: 60),
                CGRect(x: 20, y: 120, width: 60, height: 100),
                CGRect(x: 200, y: 140, width: 50, height: 90),
                CGRect(x: 90, y: 220, width: 100, height: 60),
                CGRect(x: 220, y: 260, width: 80, height: 160),
                CGRect(x: 50, y: 360, width: 200, height: 100),
                CGRect(x: 150, y: 480, width: 120, height: 80),
            ]
            var somePaths: [MyPath] = []
            var i: Int = 0
            for (c, r) in zip(colors, rects) {
                var b = UIBezierPath()
                switch i % 4 {
                case 1:     // oval
                    b = UIBezierPath(ovalIn: r)
                case 2:     // triangle shape
                    b = UIBezierPath()
                    b.move(to: CGPoint(x: r.minX, y: r.maxY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.minY))
                    b.addLine(to: CGPoint(x: r.maxX, y: r.maxY))
                    b.close()
                case 3:     // diamond
                    b = UIBezierPath()
                    b.move(to: CGPoint(x: r.minX, y: r.midY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.minY))
                    b.addLine(to: CGPoint(x: r.maxX, y: r.midY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.maxY))
                    b.close()
                default:    // rect
                    b = UIBezierPath(rect: r)
                }
                b.lineWidth = 4
                b.lineCapStyle = .round
                b.lineJoinStyle = .round
                b.setLineDash([5, 10], count: 2, phase: 0)
                let p = MyPath(color: c, path: b)
                somePaths.append(p)
                i += 1
            }
            testBezDrawView.myPaths = somePaths
        }
        
    }
    

    Here's a video of it in use (too big to convert to gif and embed here):

    https://i.sstatic.net/66awT.jpg


    Edit in response to comment...

    That needs very few changes to make it work with shape layers instead of draw(_:).

    We can use the same Translation Transform to "move" the path, then update the .path property of that path's associated layer:

        let tr = CGAffineTransform(translationX: point.x - startPoint.x, y: point.y - startPoint.y)
        active.path.apply(tr)
        activeLayer.path = activeParh.path.cgPath
    

    I strongly, strongly recommend that you try to convert that sample code to shape layers on your own - it would be a good learning exercise.

    But, if you run into trouble, here's a modified version...

    First, we're going to use the. array index of the MyPath object to match with the sublayer index, so we need to make our struct Equatable:

    struct MyPath: Equatable {
        var color: UIColor = .white
        var path: UIBezierPath = UIBezierPath()
    }
    

    Then some minor changes to BezDrawView -- which we'll name BezLayerView:

    class BezLayerView: UIView {
        
        var myPaths: [MyPath] = [] {
            didSet {
                // remove any existing layers
                if let subs = layer.sublayers {
                    subs.forEach { lay in
                        lay.removeFromSuperlayer()
                    }
                }
                // create layers for paths
                myPaths.forEach { p in
                    let lay = CAShapeLayer()
                    lay.lineWidth = 4
                    lay.lineCap = .round
                    lay.lineJoin = .round
                    lay.lineDashPattern = [5, 10]
                    lay.fillColor = UIColor.clear.cgColor
                    lay.strokeColor = p.color.cgColor
                    lay.path = p.path.cgPath
                    layer.addSublayer(lay)
                }
            }
        }
        
        // used to track path to move
        var activeLayer: CAShapeLayer?
        var activePath: MyPath?
        var startPoint: CGPoint = .zero
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first, let subs = layer.sublayers else { return }
            let point = t.location(in: self)
            // find a path that contains the touch point
            if let touchedPath = myPaths.first(where: {$0.path.contains(point)}) {
                // find the layer associated with that path
                if let idx = myPaths.firstIndex(of: touchedPath) {
                    if let lay = subs[idx] as? CAShapeLayer {
                        self.activePath = touchedPath
                        self.activeLayer = lay
                        self.startPoint = point
                    }
                }
            }
        }
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let lay = activeLayer, let ap = activePath, let t = touches.first else { return }
            let point = t.location(in: self)
            // move the path by the distance the touch moved
            let tr = CGAffineTransform(translationX: point.x - startPoint.x, y: point.y - startPoint.y)
            ap.path.apply(tr)
            lay.path = ap.path.cgPath
            startPoint = point
        }
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            // done dragging
            activeLayer = nil
            activePath = nil
        }
        
    }
    

    and an almost identical version of the controller:

    class ViewController: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            let testBezLayerView = BezLayerView()
            testBezLayerView.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
            testBezLayerView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testBezLayerView)
            
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // constrain bez draw view to all 4 sides with 20-points "padding"
                testBezLayerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                testBezLayerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                testBezLayerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                testBezLayerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                
            ])
            
            // add some sample path shapes and colors
            let colors: [UIColor] = [
                .systemRed, .systemGreen, .systemBlue,
                .cyan, .magenta, .yellow, .orange, .green,
            ]
            let rects: [CGRect] = [
                CGRect(x: 20, y: 20, width: 60, height: 60),
                CGRect(x: 180, y: 20, width: 40, height: 60),
                CGRect(x: 20, y: 120, width: 60, height: 100),
                CGRect(x: 200, y: 140, width: 50, height: 90),
                CGRect(x: 90, y: 220, width: 100, height: 60),
                CGRect(x: 220, y: 260, width: 80, height: 160),
                CGRect(x: 50, y: 360, width: 200, height: 100),
                CGRect(x: 150, y: 480, width: 120, height: 80),
            ]
            var somePaths: [MyPath] = []
            var i: Int = 0
            for (c, r) in zip(colors, rects) {
                var b = UIBezierPath()
                switch i % 4 {
                case 1:     // oval
                    b = UIBezierPath(ovalIn: r)
                case 2:     // triangle shape
                    b = UIBezierPath()
                    b.move(to: CGPoint(x: r.minX, y: r.maxY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.minY))
                    b.addLine(to: CGPoint(x: r.maxX, y: r.maxY))
                    b.close()
                case 3:     // diamond
                    b = UIBezierPath()
                    b.move(to: CGPoint(x: r.minX, y: r.midY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.minY))
                    b.addLine(to: CGPoint(x: r.maxX, y: r.midY))
                    b.addLine(to: CGPoint(x: r.midX, y: r.maxY))
                    b.close()
                default:    // rect
                    b = UIBezierPath(rect: r)
                }
                let p = MyPath(color: c, path: b)
                somePaths.append(p)
                i += 1
            }
            testBezLayerView.myPaths = somePaths
        }
        
    }
    

    The output and functionality should be indistinguishable from the BezDrawView implementation.