Bartłomiej Semańczyk
Bartłomiej Semańczyk

Reputation: 61774

How to join a few rectangle UIBezierPath objects into one?

I simply do the following in code:

    let path = UIBezierPath(rect: blurView.bounds)
    path.usesEvenOddFillRule = true
    path.append(UIBezierPath(rect: CGRect(x: 100, y: 100, width: 100, height: 100)))
    path.append(UIBezierPath(rect: CGRect(x: 150, y: 150, width: 100, height: 100)))
    //here you can add more paths, but the number is not known
    let layer = CAShapeLayer()
    layer.path = path.cgPath
    layer.fillRule = .evenOdd
    blurView.layer.mask = layer

and the effect is following:

enter image description here

Two rectangles overlapping one another. But all I need is to combine area from both rectanges, not to exclude everlapping area. Is it possible?

Upvotes: 0

Views: 804

Answers (2)

Asteroid
Asteroid

Reputation: 1118

I would go with ClippingBezier because it is fast, easy to use and neat. It'll be something like this:

let rect1 = CGRect(x: 100, y: 100, width: 200, height: 200)
let rect2 = CGRect(x: 150, y: 200, width: 200, height: 200)
        
let path0 = UIBezierPath(rect: blurView.bounds)
let path1 = UIBezierPath(rect: rect1)
let path2 = UIBezierPath(rect: rect2)
        
let unionPathArray = path1.union(with: path2)
let unionPath = UIBezierPath()
        
if let array = unionPathArray {
            
    array.forEach(unionPath.append)
            
    path0.append(unionPath.reversing())
    let layerUnion = CAShapeLayer()
    layerUnion.path = path0.cgPath
            
    blurView.layer.mask = layerUnion
}
        

Output:

enter image description here

EDIT

It appears that this method doesn't work properly when using UIBezierPath(roundedRect:cornerRadius:). To overcome that, here is how we can construct our own func to do that:


extension UIBezierPath {
    
    convenience init(rectangleIn rect: CGRect, cornerRadius: CGFloat) {
        self.init()
        
        move(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius))
        addArc(withCenter: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius), radius: cornerRadius, startAngle: .pi, endAngle: 3.0 * .pi / 2.0, clockwise: true)
        addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY))
        addArc(withCenter: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius), radius: cornerRadius, startAngle: 3.0 * .pi / 2.0, endAngle: 2 * .pi, clockwise: true)
        
        addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius))
        addArc(withCenter: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY - cornerRadius), radius: cornerRadius, startAngle: 0.0, endAngle: .pi / 2.0, clockwise: true)
        addLine(to: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY))
        addArc(withCenter: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), radius: cornerRadius, startAngle: .pi / 2.0, endAngle: .pi, clockwise: true)
        //addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius))
        
        close()
    }
}

We can also extend the above-mentioned solution to multiple paths. Here is one way to create the union of multiple paths:


extension UIBezierPath {
    
    class func getUnion(of paths: [UIBezierPath]) -> UIBezierPath {
        var result = UIBezierPath()
        paths.forEach { subPath in
            guard let union = result.union(with: subPath) else { return }
            let unionCombined = UIBezierPath()
            union.forEach(unionCombined.append)
            result = unionCombined
        }
        return result
    }
  
}

Here is an example:


    let rect1 = CGRect(x: 100, y: 100, width: 200, height: 180)
    let rect2 = CGRect(x: 150, y: 200, width: 200, height: 200)
    let rect3 = CGRect(x: 150, y: 500, width: 100, height: 100)
    let rect4 = CGRect(x: 150, y: 800, width: 300, height: 100)
        
    let pathBase = UIBezierPath(rect: blurView.bounds)
    let path1 = UIBezierPath(rectangleIn: rect1, cornerRadius: 20.0)
    let path2 = UIBezierPath(rect: rect2)
    let path3 = UIBezierPath(ovalIn: rect3)
    let path4 = UIBezierPath(ovalIn: rect4)
        
    let union = UIBezierPath.getUnion(of: [path1, path2, path3, path4])
    pathBase.append(union.reversing())
    let layerUnion = CAShapeLayer()
    layerUnion.path = pathBase.cgPath
        
    blurView.layer.mask = layerUnion
        
        

And the output:

enter image description here

Upvotes: 2

DonMag
DonMag

Reputation: 77462

Using the "even-odd" fill rule is great for "cutting a hole" in a path. However, this code:

// create a big rect
let path = UIBezierPath(rect: blurView.bounds)
// cut a hole in it
path.append(UIBezierPath(rect: CGRect(x: 100, y: 100, width: 100, height: 100)))
// cut a hole overlapping a hole?
path.append(UIBezierPath(rect: CGRect(x: 150, y: 150, width: 100, height: 100)))

will be, as you've seen, problematic.

Depending on what all you are wanting to do, you could use a library such as ClippingBezier which allows you to manipulate paths with boolean actions.

Or, you can use a custom CALayer like this to "invert" multiple paths to use as a "cutout mask":

class BasicCutoutLayer: CALayer {
    
    var rects: [CGRect] = []
    
    func addRect(_ newRect: CGRect) {
        rects.append(newRect)
        setNeedsDisplay()
    }
    func reset() {
        rects = []
        setNeedsDisplay()
    }
    
    override func draw(in ctx: CGContext) {
        
        // fill entire layer with solid color
        ctx.setFillColor(UIColor.gray.cgColor)
        ctx.fill(self.bounds);

        rects.forEach { r in
            ctx.addPath(UIBezierPath(rect: r).cgPath)
        }

        // draw clear "cutouts"
        ctx.setFillColor(UIColor.clear.cgColor)
        ctx.setBlendMode(.sourceIn)
        ctx.drawPath(using: .fill)
        
    }
    
}

To show it in use, we'll use this image:

In a standard UIImageView, overlaid with a blur UIVisualEffectView, and then use the BasicCutoutLayer class with two overlapping rects as the blur view's layer mask:

class BasicCutoutVC: UIViewController {
    
    let myBlurView = UIVisualEffectView()
    let myCutoutLayer = BasicCutoutLayer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBlue

        let imgView = UIImageView()
        if let img = UIImage(named: "sampleBG") {
            imgView.image = img
        }
        
        [imgView, myBlurView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            imgView.topAnchor.constraint(equalTo: g.topAnchor),
            imgView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            imgView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            imgView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            
            myBlurView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            myBlurView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
            myBlurView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
            myBlurView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            
        ])

        myBlurView.effect = UIBlurEffect(style: .extraLight)
        
        // set mask for blur view
        myBlurView.layer.mask = myCutoutLayer
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
    
        // set mask layer frame
        myCutoutLayer.frame = myBlurView.bounds
        
        // add two overlapping rects
        
        let v: CGFloat = 160
        let c: CGPoint = CGPoint(x: myBlurView.bounds.midX, y: myBlurView.bounds.midY)
        var r: CGRect = CGRect(origin: c, size: CGSize(width: v, height: v))

        r.origin.x -= v * 0.75
        r.origin.y -= v * 0.75
        myCutoutLayer.addRect(r)

        r.origin.x += v * 0.5
        r.origin.y += v * 0.5
        myCutoutLayer.addRect(r)
    }

}

Before applying the mask, it looks like this:

enter image description here

after applying the mask we get:

enter image description here

As we see, the "overlap" displays as we want.

That was a very simple, basic example. For a more advanced example, take a look at this:

struct MyPath {
    var lineWidth: CGFloat = 0
    var lineCap: CGLineCap = .butt
    var lineJoin: CGLineJoin = .bevel
    var isStroked: Bool = true
    var isFilled: Bool = true
    var pth: UIBezierPath = UIBezierPath()
}

class AdvancedCutoutLayer: CALayer {
    
    var myPaths: [MyPath] = []
    
    func addPath(_ newPath: MyPath) {
        myPaths.append(newPath)
        setNeedsDisplay()
    }
    func reset() {
        myPaths = []
        setNeedsDisplay()
    }
    
    override func draw(in ctx: CGContext) {
        
        // fill entire layer with solid color
        ctx.setFillColor(UIColor.gray.cgColor)
        ctx.fill(self.bounds);
        ctx.setBlendMode(.sourceIn)

        myPaths.forEach { thisPath in
            ctx.setStrokeColor(thisPath.isStroked ? UIColor.clear.cgColor : UIColor.black.cgColor)
            ctx.setFillColor(thisPath.isFilled ? UIColor.clear.cgColor : UIColor.black.cgColor)
            ctx.setLineWidth(thisPath.isStroked ? thisPath.lineWidth : 0.0)
            ctx.setLineCap(thisPath.lineCap)
            ctx.setLineJoin(thisPath.lineJoin)
            ctx.addPath(thisPath.pth.cgPath)
            ctx.drawPath(using: .fillStroke)
        }
        
    }
    
}

along with a subclassed UIVisualEffectView for convenience:

class CutoutBlurView: UIVisualEffectView {
    
    let sl = AdvancedCutoutLayer()
    
    override init(effect: UIVisualEffect?) {
        super.init(effect: effect)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        sl.isOpaque = false
        layer.mask = sl
    }
    override func layoutSubviews() {
        super.layoutSubviews()
        sl.frame = bounds
        sl.setNeedsDisplay()
    }
    func addPath(_ newPath: MyPath) {
        sl.addPath(newPath)
    }
    func reset() {
        sl.reset()
    }
}

and an example controller:

class AdvancedCutoutVC: UIViewController {
    
    let myView = CutoutBlurView()
    
    var idx: Int = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBlue
        
        let imgView = UIImageView()
        if let img = UIImage(named: "sampleBG") {
            imgView.image = img
        }
        
        [imgView, myView].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
        }
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            
            imgView.topAnchor.constraint(equalTo: g.topAnchor),
            imgView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            imgView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            imgView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            
            myView.topAnchor.constraint(equalTo: g.topAnchor),
            myView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            myView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            myView.bottomAnchor.constraint(equalTo: g.bottomAnchor),

        ])
        
        myView.effect = UIBlurEffect(style: .extraLight)
        
    }
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true, block: { _ in
            switch self.idx % 4 {
            case 1:
                self.addSomeOvals()
            case 2:
                self.addSomeLines()
            case 3:
                self.addSomeShapes()
            default:
                self.addSomeRects()
            }
            self.idx += 1
        })
    }
    func addSomeRects() {
        myView.reset()
        let w: CGFloat = myView.frame.width / 4.0
        let h: CGFloat = myView.frame.height / 4.0
        var x: CGFloat = ((myView.frame.width - (w * 5.0 * 0.5)) * 0.5) - (w * 0.25)
        var y: CGFloat = ((myView.frame.height - (h * 5.0 * 0.5)) * 0.5) - (h * 0.25)
        for _ in 1...5 {
            let bz = UIBezierPath(rect: CGRect(x: x, y: y, width: w, height: h))
            myView.addPath(MyPath(lineWidth: 0, isStroked: false, isFilled: true, pth: bz))
            x += w * 0.5
            y += h * 0.5
        }
    }
    func addSomeOvals() {
        myView.reset()
        let w: CGFloat = myView.frame.width / 4.0
        let h: CGFloat = myView.frame.height / 4.0
        var x: CGFloat = ((myView.frame.width - (w * 5.0 * 0.5)) * 0.5) - (w * 0.25)
        var y: CGFloat = ((myView.frame.height - (h * 5.0 * 0.5)) * 0.5) - (h * 0.25)
        for _ in 1...5 {
            let bz = UIBezierPath(ovalIn: CGRect(x: x, y: y, width: w, height: h))
            myView.addPath(MyPath(lineWidth: 0, isStroked: false, isFilled: true, pth: bz))
            x += w * 0.5
            y += h * 0.5
        }
    }
    func addSomeLines() {
        myView.reset()
        let w: CGFloat = myView.frame.width / 2.0
        let h: CGFloat = myView.frame.height / 4.0
        let x: CGFloat = 80
        var y: CGFloat = 80
        var lw: CGFloat = 4
        for _ in 1...5 {
            let bz = UIBezierPath()
            bz.move(to: CGPoint(x: x, y: y))
            bz.addLine(to: CGPoint(x: x + w, y: y + 20))
            myView.addPath(MyPath(lineWidth: lw, lineCap: .round, isStroked: true, isFilled: false, pth: bz))
            y += h * 0.5
            lw += 10
        }
    }
    func addSomeShapes() {
        myView.reset()
        var bz: UIBezierPath!
        
        bz = UIBezierPath(rect: CGRect(x: 80, y: 80, width: 80, height: 120))
        myView.addPath(MyPath(isStroked: false, isFilled: true, pth: bz))

        bz = UIBezierPath(rect: CGRect(x: 120, y: 120, width: 120, height: 60))
        myView.addPath(MyPath(isStroked: false, isFilled: true, pth: bz))

        bz = UIBezierPath(rect: CGRect(x: 80, y: 220, width: 220, height: 60))
        myView.addPath(MyPath(lineWidth: 12, isStroked: true, isFilled: false, pth: bz))
        
        bz = UIBezierPath(ovalIn: CGRect(x: 100, y: 240, width: 220, height: 60))
        myView.addPath(MyPath(lineWidth: 12, isStroked: true, isFilled: false, pth: bz))

        var r: CGRect = CGRect(x: 40, y: 320, width: myView.frame.width - 80, height: 200)
        for _ in 1...4 {
            bz = UIBezierPath(rect: r)
            myView.addPath(MyPath(lineWidth: 8, isStroked: true, isFilled: false, pth: bz))
            r = r.insetBy(dx: 20, dy: 20)
        }
    }
}

When run, this example will cycle through overlapping rect, overlapping ovals, some varying width lines, and some assorted shapes (just to give an idea):

enter image description here enter image description here

enter image description here enter image description here

Upvotes: 4

Related Questions