cloudprogrammer
cloudprogrammer

Reputation: 1

CAShapeLayer/UIBezierPath clipping multiple shape intersections when not drawn in the same direction

I'm trying to draw multiple shapes using UIBezierPath and CAShapeLayer in the same layer so my patternImage color goes across all the shapes correctly, but there seems to be some weird clipping/cutting out that I can't solve. When drawing 2 shapes in the same path & layer it clips the overlap but only when the shapes are not drawn in the same direction.

My code:

let view = UIView()
let path = UIBezierPath()
path.addArc(withCenter: .init(x: 100, y: 100), radius: 100, startAngle: 0 * .pi, endAngle: 2 * .pi, clockwise: true)
path.close()
path.addArc(withCenter: .init(x: 200, y: 100), radius: 100, startAngle: 0 * .pi, endAngle: 2 * .pi, clockwise: true)
path.close()
path.addArc(withCenter: .init(x: 100, y: 300), radius: 100, startAngle: 2 * .pi, endAngle: 0 * .pi, clockwise: false)
path.close()
path.addArc(withCenter: .init(x: 200, y: 300), radius: 100, startAngle: 2 * .pi, endAngle: 0 * .pi, clockwise: false)
path.close()
path.addArc(withCenter: .init(x: 100, y: 500), radius: 100, startAngle: 2 * .pi, endAngle: 0 * .pi, clockwise: false)
path.close()
path.addArc(withCenter: .init(x: 200, y: 500), radius: 100, startAngle: 0 * .pi, endAngle: 2 * .pi, clockwise: true)

let layer = CAShapeLayer()
layer.path = path.cgPath
layer.fillColor = UIColor(patternImage: .maskStripes).cgColor
layer.fillRule = .nonZero
view.layer.addSublayer(layer)

The output: rendered circles

You can see that the first 2 groups of circles that have matching clockwise directions render fine but the last one where the directions are opposites the middle is clipped. Setting the bezierPath's usesEvenOddFillRule doesn't help or the layer's fillRule and I can't guarantee in my real app that the shapes will be drawn with the same directions hence the problem.

Thank you in advance.

Upvotes: 0

Views: 119

Answers (2)

DonMag
DonMag

Reputation: 77486

One approach that may work for you...

  • add a CAShapeLayer the full size of the arcs bounds
  • set the path for that layer to a rectangle of the same size
  • set the .fillColor of that layer to the patternImage

then

  • use the individual arc paths to generate an image
  • fill each arc path with a solid (opaque) color
  • create a CALayer, with that image as its .contents
  • apply that layer as a mask on the shape layer

Here is some sample code...


We'll start with an "Arc" struct that matches the bezier path properties:

struct MyArc {
    var c: CGPoint = .zero
    var r: CGFloat = 100.0
    var s: CGFloat = 0 * .pi
    var e: CGFloat = 2 * .pi
    var k: Bool = true
}

This view controller will use the six Arcs that you used in your example.

It will create 3 views:

  • 1 using your original approach (showing the clipping problem)
  • 1 using multiple shape layers (showing the alpha problem)
  • 1 using the mask approach

I used this tiny 10x10-pixel image, with 50% opacity "between the lines" as the pattern image:

enter image description here

The example controller has a segmented control to toggle between the three views... and a label in the "background" to show the transparency (tap anywhere to toggle the label visibility):

class PathFillViewController: UIViewController {

    var patternImg: UIImage!
    
    var theArcs: [MyArc] = []
    
    var arcsRect: CGRect!
    
    var threeViews: [UIView] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground

        // load the pattern image
        guard let img = UIImage(named: "pattern1010d") else {
            fatalError("Could not load pattern image!!!!")
        }
        patternImg = img

        // create 6 arcs
        var c: CGPoint = .zero
        
        c.x = 100.0
        c.y = 100.0
        theArcs.append(MyArc(c: c, s: 0, e: 2, k: true))
        
        c.x = 200.0
        c.y = 100.0
        theArcs.append(MyArc(c: c, s: 0, e: 2, k: true))
        
        c.x = 100.0
        c.y = 300.0
        theArcs.append(MyArc(c: c, s: 2, e: 0, k: false))
        
        c.x = 200.0
        c.y = 300.0
        theArcs.append(MyArc(c: c, s: 2, e: 0, k: false))
        
        c.x = 100.0
        c.y = 500.0
        theArcs.append(MyArc(c: c, s: 2, e: 0, k: false))
        
        c.x = 200.0
        c.y = 500.0
        theArcs.append(MyArc(c: c, s: 0, e: 2, k: true))
        
        // let's calculate the rect needed to hold the arcs
        var bzPth: UIBezierPath!
        theArcs.forEach { a in
            bzPth = UIBezierPath()
            bzPth.addArc(withCenter: a.c, radius: a.r, startAngle: a.s * .pi, endAngle: a.e * .pi, clockwise: a.k)
            bzPth.close()
            if nil == arcsRect {
                arcsRect = bzPth.bounds
            } else {
                arcsRect = CGRectUnion(arcsRect, bzPth.bounds)
            }
        }

        threeViews.append(origView())
        threeViews.append(multiView())
        threeViews.append(maskView())

        let g = view.safeAreaLayoutGuide
        
        threeViews.forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            NSLayoutConstraint.activate([
                v.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                v.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                v.widthAnchor.constraint(equalToConstant: arcsRect.width),
                v.heightAnchor.constraint(equalToConstant: arcsRect.height),
            ])
        }
        
        // add a segmented control
        let sc = UISegmentedControl(items: ["Orig", "Multi Layers", "Masked Layer"])

        sc.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(sc)

        NSLayoutConstraint.activate([
            sc.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            sc.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            sc.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
        ])
        
        // let's insert a label with really big text behind the views
        //  so we can see the alpha
        let label = UILabel()
        label.font = .systemFont(ofSize: 60.0, weight: .light)
        label.textAlignment = .center
        label.numberOfLines = 0

        var s: String = "1234567890"
        for _ in 0..<8 {
            s += "\n" + "1234567890"
        }
        label.text = s

        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        view.sendSubviewToBack(label)
        
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: sc.bottomAnchor, constant: 20.0),
            label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
        ])

        sc.addTarget(self, action: #selector(segChanged(_:)), for: .valueChanged)
        sc.selectedSegmentIndex = 0
        
        segChanged(sc)
    }
    
    @objc func segChanged(_ sender: UISegmentedControl) {
        for (i, v) in threeViews.enumerated() {
            v.isHidden = sender.selectedSegmentIndex != i
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // toggle background label visibility
        if let v = view.subviews.first as? UILabel {
            v.isHidden.toggle()
        }
    }

    func origView() -> UIView {
        let v = UIView()
        
        let bzPth = UIBezierPath()
        
        theArcs.forEach { a in
            bzPth.addArc(withCenter: a.c, radius: a.r, startAngle: a.s * .pi, endAngle: a.e * .pi, clockwise: a.k)
            bzPth.close()
        }

        let shapeLayer = CAShapeLayer()
        
        shapeLayer.path = bzPth.cgPath
        shapeLayer.fillRule = .nonZero
        shapeLayer.fillColor = UIColor(patternImage: patternImg).cgColor
        v.layer.addSublayer(shapeLayer)

        return v
    }
    
    func multiView() -> UIView {
        let v = UIView()
        
        var bzPth: UIBezierPath!
        
        theArcs.forEach { a in
            bzPth = UIBezierPath()
            bzPth.addArc(withCenter: a.c, radius: a.r, startAngle: a.s * .pi, endAngle: a.e * .pi, clockwise: a.k)
            bzPth.close()
            let shapeLayer = CAShapeLayer()
            shapeLayer.path = bzPth.cgPath
            shapeLayer.fillRule = .nonZero
            shapeLayer.fillColor = UIColor(patternImage: patternImg).cgColor
            v.layer.addSublayer(shapeLayer)
        }

        return v
    }
    
    func maskView() -> UIView {
        let v = UIView()
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.frame = .init(origin: .zero, size: arcsRect.size)
        shapeLayer.fillColor = UIColor(patternImage: patternImg).cgColor
        shapeLayer.path = UIBezierPath(rect: .init(origin: .zero, size: arcsRect.size)).cgPath
        v.layer.addSublayer(shapeLayer)
        
        var bzPth: UIBezierPath!

        let sz: CGSize = shapeLayer.frame.size
        let rndr = UIGraphicsImageRenderer(size: sz)
        let tmpImg = rndr.image { ctx in
            UIColor.red.setFill()
            theArcs.forEach { a in
                bzPth = UIBezierPath()
                bzPth.addArc(withCenter: a.c, radius: a.r, startAngle: a.s * .pi, endAngle: a.e * .pi, clockwise: a.k)
                bzPth.close()
                ctx.cgContext.beginPath()
                ctx.cgContext.addPath(bzPth.cgPath)
                ctx.cgContext.fillPath()
            }
        }
        let iMsk = CALayer()
        iMsk.contents = tmpImg.cgImage
        iMsk.frame = .init(origin: .zero, size: sz)
        
        // apply image layer as a mask
        shapeLayer.mask = iMsk
        
        // un-comment if we want to see the image layer
        //v.layer.addSublayer(iMsk)

        return v
    }
    
}

I get this output:

enter image description here

enter image description here

enter image description here

and with the label showing through:

enter image description here

enter image description here

enter image description here

Upvotes: 0

Duncan C
Duncan C

Reputation: 131426

I don't think there is a solution that causes all overlapping closed paths to fill the union of their areas. I suggest creating a separate layer for each closed shape you want to fill and adding them all as sublayers. That will work, and let you use separate colors for each (if desired.)

Upvotes: 1

Related Questions