iosswiftuiimageviewcore-graphicscgpath

Applying mask to UIImageView results in wrong mask placement


I have an UIImageView and another view overlapping it which allows me to draw in it. When I draw something, it has the correct dimensions and placement, but when I try to apply that as a mask on the image view, it has a weird y offset, as shown on Image 1 captured from view hierarchy.

I am applying the mask like this:

canvas.onTouchesEnd = { [weak self] in
            guard let self = self else { return }
            self.backgroundView.layer.mask = self.canvas.layer
        }

canvas is the view where I draw shapes (red one on the image), and backgroundView is the image view I'm trying to mask. "onTouchesEnd" is just a callback which is called upon finishing drawing the shape.

UIImageView is created in storyboard and constraints are set there.

I've tried setting translatesAutoresizingMaskIntoConstraints = false, returning the path from canvas and creating a new layer for mask, but whatever I do, it has the same offset and nothing really worked.

Any ideas how to correctly translate this path from canvas onto image view mask?

Image 1


Solution

  • The reason your mask has a "weird y offset" is because of the way you are accessing the other view's layer.

    With your code, the canvas.layer will come with an origin relative to its superview - in this case, the "root" view of the controller.

    If you put your image view and "canvas" at the top (of the view, not top of the safe area), it should line up.

    However, that's really not an ideal way to do this - and self.backgroundView.layer.mask = self.canvas.layer is not a good idea to begin with.

    You didn't show us what you're doing with your "canvas" view, but assuming it has a CAShapeLayer where you set the path (and line width, line join, etc), you could use this as your closure:

        canvas.onTouchesEnd = { [weak self] in
            guard let self = self else { return }
            
            // new shape layer
            let maskLayer = CAShapeLayer()
            
            // any opaque color
            maskLayer.strokeColor = UIColor.black.cgColor
            
            // use same shape properties
            maskLayer.lineWidth = self.canvas.shapeLayer.lineWidth
            maskLayer.fillColor = self.canvas.shapeLayer.fillColor
            maskLayer.lineJoin = self.canvas.shapeLayer.lineJoin
            maskLayer.lineCap = self.canvas.shapeLayer.lineCap
            
            // set the mask layer path to the same path
            maskLayer.path = self.canvas.shapeLayer.path
            
            // apply the mask
            self.backgroundView.layer.mask = maskLayer
        }
    

    What you might want to consider, though, is a using a subclassed UIImageView that would handle the touches and masking itself... avoiding all of that.

    Here's a quick "scratch off" example:

    class ScratchOffImageview: UIImageView {
        
        public var onTouchesEnd: (()->())?
        
        // default line width of 30, but can be set as desired
        public var lineWidth: CGFloat = 40.0 {
            didSet { maskLayer.lineWidth = lineWidth }
        }
        
        private var maskPath: CGMutablePath!
        private var maskLayer: CAShapeLayer!
        
        convenience init() {
            self.init(frame: .zero)
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
    
            // create the mask path
            maskPath = CGMutablePath()
    
            // create the mask shape layer
            maskLayer = CAShapeLayer()
            
            // any opaque color
            maskLayer.strokeColor = UIColor.black.cgColor
            
            // use same shape properties
            maskLayer.lineWidth = self.lineWidth
            maskLayer.fillColor = nil
            maskLayer.lineJoin = .round
            maskLayer.lineCap = .round
    
            // if this is set here,
            //  view will start completely transparent
            self.layer.mask = maskLayer
    
            // UIImageView has interaction disabled by default
            //  need to set it to true so it will receive touches
            self.isUserInteractionEnabled = true
            
        }
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            maskPath.move(to: t.location(in: self))
            maskLayer.path = maskPath
        }
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            maskPath.addLine(to: t.location(in: self))
            maskLayer.path = maskPath
        }
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            // if you want to do something on touchesEnded
            self.onTouchesEnd?()
        }
    
    }
    

    This is now completely self-contained. If you add a UIImageView in Storyboard, set its Custom Class to ScratchOffImageview (and enable User Interaction), you're all done.

    You can test it with this sample view controller - requires an image asset named "sampleImage" (or edit the code with your image name):

    class ScratchTestVC: UIViewController {
        
        var scratchView: ScratchOffImageview = ScratchOffImageview()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            guard let img = UIImage(named: "sampleImage") else {
                fatalError("Could not load sampleImage !!!")
            }
            
            scratchView.image = img
            
            scratchView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scratchView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                // let's inset the image view 20-points on each side
                //  same proportional height as sampleImage
                //  and centered vertically
                scratchView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                scratchView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                scratchView.heightAnchor.constraint(equalTo: scratchView.widthAnchor, multiplier: img.size.height / img.size.width),
                scratchView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
            ])
            
            // because the image view starts with an empty mask, it will be completely transparent
            //  so let's put a bordered view behind the image view,
            //  1-point larger on each side so it will "frame" the image
            
            let frameView = UIView()
            frameView.layer.borderColor = UIColor.black.cgColor
            frameView.layer.borderWidth = 1
            frameView.translatesAutoresizingMaskIntoConstraints = false
            view.insertSubview(frameView, belowSubview: scratchView)
            NSLayoutConstraint.activate([
                frameView.topAnchor.constraint(equalTo: scratchView.topAnchor, constant: -1.0),
                frameView.leadingAnchor.constraint(equalTo: scratchView.leadingAnchor, constant: -1.0),
                frameView.trailingAnchor.constraint(equalTo: scratchView.trailingAnchor, constant: 1.0),
                frameView.bottomAnchor.constraint(equalTo: scratchView.bottomAnchor, constant: 1.0),
            ])
            
            // set the closure so we can "do something" on touches end
            scratchView.onTouchesEnd = { [weak self] in
                guard let self = self else { return }
                print("Touches Ended")
            }
            
        }
        
    }
    

    You'll notice in the comments that the custom "scratch off" image view will start completely clear / transparent -- and you won't see anything and won't know where to touch/drag... so I inserted a bordered "frame" view below it.

    Looks like this:

    enter image description here