1

I have been looking everywhere for the answer to a question that has been asked a ton of times. I have spent hours looking through SO and Google. There has to be an answer that isn't going to take a mountain moving effort.

I am working on a vector drawing app and finally got the drawing and undo-ing functionality working. Now I need an eraser :-o

EDIT: Per the great write up from @DonMag I was able to get pretty close to an eraser, but something still isn't quite right. So I am going to try and explain how my views and layers are in the app and why I have done it this way:

Starting from the bottom view/layer to the top...

  1. BackgroundImageView - I am using this image view to hold the "background" for the drawing surface. It is a layer that can be changed and can have new "templates" saved and recalled into. I keep it separate so that the user can't erase the drawing surface. And the background consists of CAShapeLayers that are drawn to represent different paper types.

  2. MainImageView - I am using this image view to do all the drawing that the user initiates. So I touch and drag my finger, and new CAShapeLayer is added to the image view. This keeps the user's drawing separate from the "drawing surface". This is also the place I want the erasing to happen

  3. PageImagesView - I use this view to hold images that the user can add to the page, and move/resize them. I don't want the eraser to effect the image, but if a line drawn in MainImageView crosses over the image and needs to be erased it should let the image show through, and not remove parts of the image.

I also added another layer trying to get the eraser working, and called it "EraserImageView", and was drawing the "mask" into it, then trying to apply that mask to the MainImageView.

Here is my drawing code, called each time touchesMoved is called:

EDIT: Adding the code for eraser into my Drawing code.

 if eraser {
            let linePath = UIBezierPath()

            for (index, point) in line.enumerated() {
                if index == 0 {
                    midPoint = CGPoint(
                        x: (point.x + point.x) / 2,
                        y: (point.y + point.y) / 2
                    )
                    linePath.move(to: midPoint!)
                } else {
                    midPoint = CGPoint(
                        x: (point.x + line[index - 1].x) / 2,
                        y: (point.y + line[index - 1].y) / 2
                    )
                    linePath.addQuadCurve(to: midPoint!, controlPoint: line[index - 1])
                }
            }

            let maskLayer = CAShapeLayer()
            maskLayer.lineWidth = brush
            maskLayer.lineCap = .round
            maskLayer.strokeColor = UIColor.black.cgColor
            maskLayer.fillColor = nil
            maskLayer.frame = backgroundImageView.bounds
            maskLayer.path = linePath.cgPath
            //eraserImageView.layer.addSublayer(backgroundImageView.layer)
            eraserImageView.layer.addSublayer(maskLayer)
            eraserImageView.layer.mask = mainImageView.layer            
        }

The code above causes all of the user drawing to disappear except the portion that is touched by the "eraser". I know that I have something out of order, or I'm applying the mask incorrectly. Does anyone have a solution?

Drawing some Lines, and it looks great...

[I draw some lines, and looks great!

When I attempt the eraser this is what happens...

When I start to erase everything disappears except for the spot I touched with the eraser.

As you can see above I can draw lines, but once I touch the eraser to the page it removes everything except for the part I touch with the eraser.

Does anyone know where I am going wrong??

Edit: SO CLOSE! I was able to get the eraser to remove part of the drawn line when I move my finger. But it isn't drawing using the Sizes and it is making shapes. It is also replacing all the "erased" parts as soon as I touch the drawing surface after using the eraser.

Here is my new eraser code:

if eraser {
            //var rect: CGRect = CGRect()
            let linePath = UIBezierPath(rect: mainImageView.bounds)

            for (index, point) in line.enumerated() {
                if index == 0 {
                    midPoint = CGPoint(
                        x: (point.x + point.x) / 2,
                        y: (point.y + point.y) / 2
                    )
                    //rect = CGRect(x: midPoint!.x, y: midPoint!.y, width: brush, height: brush)
                    linePath.move(to: midPoint!)
                } else {
                    midPoint = CGPoint(
                        x: (point.x + line[index - 1].x) / 2,
                        y: (point.y + line[index - 1].y) / 2
                    )
                    //rect = CGRect(x: midPoint!.x, y: midPoint!.y, width: brush, height: brush)
                    linePath.addQuadCurve(to: midPoint!, controlPoint: line[index - 1])
                }
            }

            let maskLayer = CAShapeLayer()
            maskLayer.lineWidth = brush
            maskLayer.lineCap = .round
            maskLayer.strokeColor = UIColor.clear.cgColor
            maskLayer.fillColor = UIColor.black.cgColor
            maskLayer.opacity = 1.0
            maskLayer.path = linePath.cgPath
            maskLayer.fillRule = .evenOdd
            mainImageView.layer.addSublayer(maskLayer)
            mainImageView.layer.mask = maskLayer

        }

Here is the result: enter image description here

Any ideas on how to get the eraser to draw just like the lines?

EDIT: Adding the code for the background "drawing" at the request of @DonMag

import Foundation
import UIKit

class DrawBulletLayer : UIView {

    private var bullet: CAShapeLayer?

    func drawBullets(coordinates: UIImageView, bulletColor: UIColor) -> CALayer {
        let bullet = self.bullet ?? CAShapeLayer()
        let bulletPath = UIBezierPath()

        bullet.contentsScale = UIScreen.main.scale

        var bullets: [CGPoint] = []
        let width = coordinates.frame.width
        let height = coordinates.frame.height

        let widthBullets = CGFloat(width / 55)
        let heightBullets = CGFloat(height / 39)

        var hb: CGFloat?
        var wb: CGFloat?

        for n in 1...39 {
            hb = heightBullets * CGFloat(n)
            for o in 1...55 {
                wb = widthBullets * CGFloat(o)
                bullets.append(CGPoint(x: wb!, y: hb!))
            }
        }

        UIColor.black.setStroke()

        bullets.forEach { point in
            bulletPath.move(to: point)
            bulletPath.addLine(to: point)
        }

        bullet.path = bulletPath.cgPath
        bullet.opacity = 1.0
        bullet.lineWidth = 2.0
        bullet.lineCap = .round
        bullet.fillColor = UIColor.clear.cgColor
        bullet.strokeColor = bulletColor.cgColor

        if self.bullet == nil {
            self.bullet = bullet
            layer.addSublayer(bullet)
        }

        return layer
    }
}

Here is how it is added to the BackgroundImageView:

func updateTemplate() {
        let templates = TemplatePickerData()
        var loadLayer = templates.loadTemplateIds()
        if loadLayer.count == 0 {
            _ = templates.loadTemplates()
            loadLayer = templates.loadTemplateIds()
        }
        print("this is the template ID: \(templateId)")
        //let templateId = loadLayer[template].value(forKey: "templateId") as! Int
        if template < 0 {
            template = 0
        }

        switch template {
        case 0:
            //scrollView.image = UIImage(named: "habitTracker0")!
            scrollView.backgroundImageView.layer.sublayers?.removeAll()
            scrollView.backgroundImageView.layer.addSublayer(drawBullets.drawBullets(coordinates: scrollView.backgroundImageView, bulletColor: UIColor(red: 214.0/255.0, green: 214.0/255.0, blue: 214.0/255.0, alpha: 1.0)))
            scrollView.setNeedsLayout()
            scrollView.layoutIfNeeded()
            scrollView.setNeedsDisplay()
        case 1:
            //scrollView.image = UIImage(named: "monthTemplate0")!
            scrollView.backgroundImageView.layer.sublayers?.removeAll()
            scrollView.backgroundImageView.layer.addSublayer(drawNotes.drawLines(coordinates: scrollView.backgroundImageView, lineColor: UIColor(red: 214.0/255.0, green: 214.0/255.0, blue: 214.0/255.0, alpha: 1.0)))
            scrollView.setNeedsLayout()
            scrollView.layoutIfNeeded()
            scrollView.setNeedsDisplay()
        case 2:
            //scrollView.image = UIImage(named: "habitTracker0")!
            scrollView.backgroundImageView.layer.sublayers?.removeAll()
            scrollView.backgroundImageView.layer.addSublayer(drawNotes2.drawLines(coordinates: scrollView.backgroundImageView, lineColor: UIColor(red: 214.0/255.0, green: 214.0/255.0, blue: 214.0/255.0, alpha: 1.0)))
            scrollView.setNeedsLayout()
            scrollView.layoutIfNeeded()
            scrollView.setNeedsDisplay()
        default:
            if loadLayer.count > template {
                template = 0
            }
            print("this layer is named: \(loadLayer[template].value(forKey: "templateName") as! String)")
            let layer = loadLayer[template].value(forKey: "templatePath") as! String
            templateId = loadLayer[template].value(forKey: "templateId") as! Int
            let thisTemplate = templates.loadImage(image: layer)

            scrollView.backgroundImageView.layer.sublayers?.removeAll()
            scrollView.backgroundImageView.layer.addSublayer(drawBullets.drawBullets(coordinates: scrollView.backgroundImageView, bulletColor: UIColor(red: 214.0/255.0, green: 214.0/255.0, blue: 214.0/255.0, alpha: 1.0)))
            scrollView.backgroundImageView.layer.addSublayer(thisTemplate)
            scrollView.setNeedsLayout()
            scrollView.layoutIfNeeded()
            scrollView.setNeedsDisplay()
        }
        scrollView.setNeedsDisplay()

        if optionsMenuView.pageNameTextField.text != "" {
            if isYear {
                page = optionsMenuView.savePage(journalName: journalName, monthName: nil, weekName: nil, yearName: yearName, yearPosition: yearPosition, pageDrawingPath: pageDrawingPath, originalName: originalYearName, brushColor: 1, brushSize: brushSizeMenuView.brushSlider.value, templateId: templateId, pageDrawing: scrollView.mainImageView.layer)
            } else {
                page = optionsMenuView.savePage(journalName: journalName, monthName: monthName, weekName: weekName, yearName: nil, yearPosition: nil, pageDrawingPath: pageDrawingPath, originalName: originalWeekName, brushColor: 1, brushSize: brushSizeMenuView.brushSlider.value, templateId: templateId, pageDrawing: scrollView.mainImageView.layer)
            }
        }
        optionsMenuView.templateId = templateId
    }

Hope that helps more...

jforward5
  • 93
  • 1
  • 12
  • Add an "erase" layer as the top layer, track the erase points, set that path's strokeColor to the background color of you view? – DonMag Aug 06 '19 at 19:33
  • Thank you @DonMag. One other layer of complexity here is that the background is an image, so setting the color isn't an option. – jforward5 Aug 06 '19 at 22:11
  • I have a couple ideas, but... you said *"a vector drawing app"* ... does that mean you're allowing the user to add / delete / move control points? Also, you mention **layers** plural... does that mean you are using a new layer for each "new drawing action"? – DonMag Aug 07 '19 at 13:02
  • Yes, every new drawing action is a new layer, and that is so that i can make the “undo” function work. I am storing each drawing action in the “undomanager”. And i am saving the layers as 1 layer once they leave the drawing or hit save. I also occasionally “flatten” the image by combining the layers when they draw 500 points at once time. – jforward5 Aug 07 '19 at 23:15
  • I forgot to answer your other question. The only point they can move is when drawing a straight line, but it is only movable until they let go of it – jforward5 Aug 08 '19 at 11:10
  • @robmayoff I see that in many of your answers you seem to be an expert in layers and masking. Could you have a look at my problem and let me know how I can fix it? – jforward5 Sep 07 '19 at 19:34
  • @DonMag, anyone else? I know someone knows how to do this. I am so close! Just need a little more help getting there! – jforward5 Sep 08 '19 at 00:54

1 Answers1

11

Erasing part of a bezier path would be tricky... you'd probably need to calculate intersections (of the stroke width, not just of the path itself) and break existing lines into multiple segments.

Here is another approach - not sure if it will work for you, but might be worth considering:

enter image description here

The "Drawing" layers are probably what you already have. The "Eraser" layer would include the background image, and then the "line" (the bezier path) would be used as a mask, so it would appear to erase portions of the layers below.

With the final line as a yellow "Drawing" layer:

enter image description here

and with the final line as an "Eraser" layer:

enter image description here

Here is the code I used for this. I think it's pretty straight-forward to demonstrate the idea. No actual "drawing" feature -- it just uses a hard-coded set of coordinates and properties as if they had been generated by touch-tracking.

When you run it, the button at the top will add the Red, Green and Blue "lines," and then will toggle the last set of points between a "Yellow line" and an "Eraser line."

//
//  ViewController.swift
//  VectorDrawTest
//
//  Created by Don Mag on 8/8/19.
//

import UIKit

enum LineType: Int {
    case DRAW
    case ERASE
}

class LineDef: NSObject {
    var lineType: LineType = .DRAW
    var color: UIColor = UIColor.black
    var opacity: Float = 1.0
    var lineWidth: CGFloat = 8.0
    var points: [CGPoint] = [CGPoint]()
}

class DrawingView: UIView {

    // the background image
    var bkgImage: UIImage = UIImage() {
        didSet {
            updateBkgImage()
        }
    }

    func updateBkgImage() -> Void {
        // if no layers have been added yet, add the background image layer
        if layer.sublayers == nil {
            let l = CALayer()
            layer.addSublayer(l)
        }
        guard let layers = layer.sublayers else { return }
        for l in layers {
            if let _ = l as? CAShapeLayer {
                // in case we're changing the backgound image after lines have been drawn
                // ignore shape layers
            } else {
                // this layer is NOT a CAShapeLayer, so it's either the first (background image) layer
                // or it's an eraser layer, so update the contents
                l.contents = bkgImage.cgImage
            }
        }
        setNeedsDisplay()
    }

    func undo() -> Void {
        // only remove a layer if it's not the first (background image) layer
        guard let n = layer.sublayers?.count, n > 1 else { return }
        _ = layer.sublayers?.popLast()
    }

    func addLineDef(_ def: LineDef) -> Void {

        if def.lineType == LineType.DRAW {

            // create new shape layer
            let newLayer = CAShapeLayer()

            // set "draw" properties
            newLayer.lineCap = .round
            newLayer.lineWidth = def.lineWidth
            newLayer.opacity = def.opacity
            newLayer.strokeColor = def.color.cgColor
            newLayer.fillColor = UIColor.clear.cgColor

            // create bezier path from LineDef points
            let drawPts = def.points
            let bez = UIBezierPath()
            for pt in drawPts {
                if pt == drawPts.first {
                    bez.move(to: pt)
                } else {
                    bez.addLine(to: pt)
                }
            }
            // set path
            newLayer.path = bez.cgPath

            // add layer
            layer.addSublayer(newLayer)

        } else {

            // create new layer
            let newLayer = CALayer()
            // set its contents to the background image
            newLayer.contents = bkgImage.cgImage
            newLayer.opacity = def.opacity

            // create a shape layer to use as a mask
            let maskLayer = CAShapeLayer()

            // set "draw" properties
            // strokeColor will always be black, because it just uses alpha for the mask
            maskLayer.lineCap = .round
            maskLayer.lineWidth = def.lineWidth
            maskLayer.strokeColor = UIColor.black.cgColor
            maskLayer.fillColor = UIColor.clear.cgColor

            // add mask
            newLayer.mask = maskLayer

            // create bezier path from LineDef points
            let drawPts = def.points
            let bez = UIBezierPath()
            for pt in drawPts {
                if pt == drawPts.first {
                    bez.move(to: pt)
                } else {
                    bez.addLine(to: pt)
                }
            }
            // set maskLayer's path
            maskLayer.path = bez.cgPath

            // add layer
            layer.addSublayer(newLayer)

        }

        setNeedsDisplay()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // update layer frames
        if let layers = layer.sublayers {
            for l in layers {
                l.frame = bounds
            }
        }
    }

}


class DrawViewController: UIViewController {

    let theDrawingView: DrawingView = {
        let v = DrawingView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    let demoButton: UIButton = {
        let v = UIButton()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
        v.setTitleColor(.blue, for: .normal)
        v.setTitleColor(.lightGray, for: .highlighted)
        v.setTitle("Draw Red", for: .normal)
        return v
    }()

    let redLine: LineDef = {
        let d = LineDef()
        d.lineType = .DRAW
        d.color = .red
        d.lineWidth = 8.0
        d.points = [
            CGPoint(x: 20, y: 20),
            CGPoint(x: 40, y: 140),
            CGPoint(x: 280, y: 200),
        ]
        return d
    }()

    let greenLine: LineDef = {
        let d = LineDef()
        d.lineType = .DRAW
        d.color = .green
        d.lineWidth = 16.0
        d.points = [
            CGPoint(x: 20, y: 100),
            CGPoint(x: 80, y: 80),
            CGPoint(x: 240, y: 140),
            CGPoint(x: 100, y: 200),
        ]
        return d
    }()

    let blueLine: LineDef = {
        let d = LineDef()
        d.lineType = .DRAW
        d.color = .blue
        d.opacity = 0.5
        d.lineWidth = 24.0
        d.points = [
            CGPoint(x: 250, y: 20),
            CGPoint(x: 150, y: 240),
            CGPoint(x: 100, y: 60),
        ]
        return d
    }()

    let yellowLine: LineDef = {
        let d = LineDef()
        d.lineType = .DRAW
        d.color = .yellow
        d.lineWidth = 32.0
        d.points = [
            CGPoint(x: 30, y: 200),
            CGPoint(x: 250, y: 80),
            CGPoint(x: 250, y: 180),
        ]
        return d
    }()

    let eraserLine: LineDef = {
        let d = LineDef()
        d.lineType = .ERASE
        d.lineWidth = 32.0
        d.points = [
            CGPoint(x: 30, y: 200),
            CGPoint(x: 250, y: 80),
            CGPoint(x: 250, y: 180),
        ]
        return d
    }()

    var testErase = false

    override func viewDidLoad() {
        super.viewDidLoad()

        // add the drawing view
        view.addSubview(theDrawingView)

        // constrain it 300 x 300 centered X and Y
        NSLayoutConstraint.activate([
            theDrawingView.widthAnchor.constraint(equalToConstant: 300),
            theDrawingView.heightAnchor.constraint(equalToConstant: 300),
            theDrawingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            theDrawingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            ])

        let imgName = "TheCat"
        if let img = UIImage(named: imgName) {
            theDrawingView.bkgImage = img
        }

        // add a demo button
        view.addSubview(demoButton)

        // constrain it 20-pts from the top, centered X
        NSLayoutConstraint.activate([
            demoButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20.0),
            demoButton.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8),
            demoButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            ])

        // add the touchUpInside target
        demoButton.addTarget(self, action: #selector(doTest), for: .touchUpInside)
    }

    @objc func doTest(_ sender: Any?) -> Void {

        if let b = sender as? UIButton {

            let t = b.currentTitle

            switch t {
            case "Draw Red":
                theDrawingView.addLineDef(redLine)
                b.setTitle("Draw Green", for: .normal)
            case "Draw Green":
                theDrawingView.addLineDef(greenLine)
                b.setTitle("Draw Blue", for: .normal)
            case "Draw Blue":
                theDrawingView.addLineDef(blueLine)
                b.setTitle("Draw Yellow", for: .normal)
            case "Draw Yellow":
                theDrawingView.addLineDef(yellowLine)
                b.setTitle("Toggle Yellow / Erase", for: .normal)
            default:
                toggle()
            }

        }
    }

    func toggle() -> Void {

        // undo the last action
        theDrawingView.undo()

        // toggle bool var
        testErase = !testErase

        // add either yellowLine or eraserLine
        theDrawingView.addLineDef(testErase ? eraserLine : yellowLine)

    }

}

Everything is done via code - no @IBOutlets or @IBActions - so just start a new project and replace ViewController.swift with the above code.

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • This looks very promising! I am going to try it out today and let you know what happens! Thank you @DonMag for putting this together. :) – jforward5 Aug 09 '19 at 19:15
  • Ok, so 2 things. I tried to put your code into an empty project and run it, and nothing happened. Just a totally white background with no buttons or anything at all. So then I tried to implement the eraser part of your code, and it also didn't do anything. Not sure what I did wrong, but I edited the post with my new code. – jforward5 Aug 19 '19 at 11:53
  • @jforward5 - I posted my example as a GitHub project: https://github.com/DonMag/VectorEraser ... give that a try. – DonMag Aug 19 '19 at 12:04
  • Your example works from GitHub, but still trying to figure out how to get it to work in my project. I was using Image for the background, but I am now using layers in a "backgroundimageview" for the "background" and another image view called "mainimageview" where the main drawing takes place. – jforward5 Aug 22 '19 at 01:09
  • Please see my edits above. I am very CLOSE to solving this (thanks to you!), but I am still missing something. Any ideas? – jforward5 Sep 04 '19 at 15:05
  • @robmayoff you helped someone solve a similar problem, maybe you could have a look? https://stackoverflow.com/questions/46721363/mask-uiview-with-uibezierpath-stroke-only – jforward5 Sep 06 '19 at 11:40
  • @jforward5 - are you using the method I demonstrated? That is, create an "eraser `CALayer` layer" with `.content` set to the background image, and the "eraser bezier path" set as a mask layer? – DonMag Sep 09 '19 at 12:18
  • the problem is that the "background" is a CAShapeLayer drawing, so that when zoomed and panned it can be crisp. How can I use the "CAShapeLayer" as the "eraser bezier path"? – jforward5 Sep 09 '19 at 13:14
  • @jforward5 - hmmm... well, tough to say without seeing your full code. I updated my sample project to use your "curves" code, and I put the view in a scroll view with zooming enabled. Take another look: https://github.com/DonMag/VectorEraser – DonMag Sep 09 '19 at 13:52
  • I think there is too much code to put up there, but I will put the portion that draws the background. I think the problem is that the mask layer needs to include the background, but since it is a drawn CAShapeLayer, I'm not sure how to make it part of the Mask, and update when eraser is drawn...I am adding more of my code above. – jforward5 Sep 09 '19 at 13:59
  • ok I added the part that draws the background, and the part that adds it to the "background" – jforward5 Sep 09 '19 at 14:04
  • I though I had solved this, but unfortunately, the Library I found was not doing what I thought it was. I wish this wasn't so difficult. I wonder how Good Notes does this.... – jforward5 Sep 16 '19 at 14:25
  • 1
    @jforward5 - I updated my "VectorEraser" repo at https://github.com/DonMag/VectorEraser again... It uses your code to generate the "Bullets" background instead of using a bitmap background image. You might want to take a look. – DonMag Sep 16 '19 at 15:28
  • YOU ARE MY HERO! – jforward5 Sep 16 '19 at 17:36
  • Ok, a few problems with this approach. The memory used when converting the layers to an image is unwieldy, and the whole drawing becomes grainy looking since it is now a raster. I think the mask is the right direction for this, but converting to an image and using as a mask I think isn't going to provide the outcome I need. Thank you for the effort tho. – jforward5 Sep 18 '19 at 11:59
  • @jforward5 Ask for help!! I'm also struggling in a situation like this. I want to implement an eraser tool for my drawing app. I would like to know the possibility of using this solution if I want to split a bezierPath into two separate layers that I can manipulate them individually such as moving, resizing, etc. Thanks – Lasantha Basnayake Dec 12 '19 at 10:27
  • Here I don't use any background image or something! – Lasantha Basnayake Dec 12 '19 at 11:05
  • @LasanthaBasnayake I ended up using the PencilKit in the end since trying to make a vector eraser is very very complicated, and I could not find a suitable solution. I recommend doing the same. – jforward5 Dec 12 '19 at 22:53
  • 1
    @LasanthaBasnayake in the end i did the same. Sorry for the very late response. – jforward5 Jun 08 '20 at 15:03