Eilon
Eilon

Reputation: 2982

Achieving erase/restore drawing on UIImage in Swift

I'm trying to make a simple image eraser tool, where the user can erase and restore as drawing into an image, just like in this image:

enter image description here

After many attempts and testing, I have achieved the sufficient "erase" functionality with the following code on the UI side:

// Drawing code - on user touch
// `currentPath` is a `UIBezierPath` property of the containing class.
guard let image = pickedImage else { return }
UIGraphicsBeginImageContextWithOptions(imageView.frame.size, false, 0)
if let context = UIGraphicsGetCurrentContext() {
    mainImageView.layer.render(in: context)
    context.addPath(currentPath.cgPath)
    context.setBlendMode(.clear)
    context.setLineWidth(translatedBrushWidth)
    context.setLineCap(.round)
    context.setLineJoin(.round)
    context.setStrokeColor(UIColor.clear.cgColor)
    context.strokePath()

    let capturedImage = UIGraphicsGetImageFromCurrentImageContext()
    imageView.image = capturedImage
}

UIGraphicsEndImageContext()

And upon user touch-up I am applying a scale transform to currentPath to render the image with the cutout part in full size to preserve UI performance.

What I'm trying to figure out now is how to approach the "restore" functionality. Essentially, the user should draw on the erased parts to reveal the original image.
I've tried looking at CGContextClipToMask but I'm not sure how to approach the implementation.

I've also looked at other approaches to achieving this "erase/restore" effect before rendering the actual images, such as masking a CAShapeLayer over the image but also in this approach restoring becomes a problem.

Any help will be greatly appreciated, as well as alternative approaches to erase and restore with a path on the UI-level and rendering level. Thank you!

Upvotes: 3

Views: 2271

Answers (1)

Duncan C
Duncan C

Reputation: 131481

Yes, I would recommend adding a CALayer to your image's layer as a mask.

You can either make the mask layer a CAShapeLayer and draw geometric shapes into it, or use a simple CALayer as a mask, where the contents property of the mask layer is a CGImage. You'd then draw opaque pixels into the mask to reveal the image contents, or transparent pixels to "erase" the corresponding image pixels.

This approach is hardware accelerated and quite fast.

Handling undo/redo of eraser functions would require you to collect changes to your mask layer as well as the previous state of the mask.

Edit:

I created a small demo app on Github that shows how to use a CGImage as a mask on an image view

Here is the ReadMe file from that project:


MaskableImageView

This project demonstrates how to use a CALayer to mask a UIView.

It defines a custom subclass of UIImageView, MaskableView.

The MaskableView class has a property maskLayer that contains a CALayer.

MaskableView defines a didSet method on its bounds property so that when the view's bounds change, it resizes the mask layer to match the size of the image view.

The MaskableView has a method installSampleMask which builds an image the same size as the image view, mostly filled with opaque black, but with a small rectangle in the center filled with black at an alpha of 0.7. The translucent center rectangle causes the image view to become partly transparent and show the view underneath.

The demo app installs a couple of subviews into the MaskableView, a sample image of Scampers, one of my dogs, and a UILabel. It also installs an image of a checkerboard under the MaskableView so that you can see the translucent parts more easily.

The MaskableView has properties circleRadius, maskDrawingAlpha, and drawingAction that it uses to let the user erase/un-erase the image by tapping on the view to update the mask.

The MaskableView attaches a UIPanGestureRecognizer and a UITapGestureRecognizer to itself, with an action of gestureRecognizerUpdate. The gestureRecognizerUpdate method takes the tap/drag location from the gesture recognizer and uses it to draw a circle onto the image mask that either decreases the image mask's alpha (to partly erase pixels) or increase the image mask's alpha (to make those pixels more opaque.)

The MaskableView's mask drawing is crude, and only meant for demonstration purposes. It draws a series of discrete circles intstead of rendering a path into the mask based on the user's drag gesture. A better solution would be to connect the points from the gesture recognizer and use them to render a smoothed curve into the mask.

The app's screen looks like this:

enter image description here

Edit #2:

If you want to export the resulting image to a file that preserves the transparency, you can convert the CGImage to a UIImage (Using the init(cgImage:) initializer) and then use the UIImage function

func pngData() -> Data?

to convert the image to PNG data. That function returns nil if it is unable to convert the image to PNG data.

If it succeeds, you can then save the data to a file with a .png extension.

I updated the sample project to include the ability to save the resulting image to disk.

First I added an image computed property to the MaskableView. That looks like this:

    public var image: UIImage? {
        guard let renderer = renderer else { return nil}
        let result = renderer.image {
            context in

            return layer.render(in: context.cgContext)
        }
        return result
    }

Then I added a save button to the view controller that fetches the image from the MaskableView and saves it to the app's Documents directory:

    @IBAction func handleSaveButton(_ sender: UIButton) {
        print("In handleSaveButton")
        if let image = maskableView.image,
           let pngData = image.pngData(){
            print(image.description)
            let imageURL = getDocumentsDirectory().appendingPathComponent("image.png", isDirectory: false)
            do {
                try pngData.write(to: imageURL)
                print("Wrote png to \(imageURL.path)")
            }
            catch {
                print("Error writing file to \(imageURL.path)")
            }
        }
    }

You could also save the image to the user's camera roll. It's been a while since I've done that so I'd have to dig up the steps for that.

Upvotes: 4

Related Questions