1

I have two shapes of type CAShapeLayer (e.g. one box and a circle) and a gradient layer of type CAGradientLayer. How can I mask the gradient layer with the intersection of the two shapes like this picture in Swift?

enter image description here

Asteroid
  • 1,049
  • 2
  • 8
  • 16

1 Answers1

0

Not exactly clear what you mean by "intersection of the two shapes" ... but maybe this is what you're going for:

enter image description here

To get that, we can create a CAShapeLayer with an oval (round) path, and use it as a mask on the gradient layer.

Here's some example code:

class GradientMaskingViewController: UIViewController {

    let gradView = MaskedGradView()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        gradView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(gradView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            gradView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 60.0),
            gradView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -60.0),
            gradView.heightAnchor.constraint(equalTo: gradView.widthAnchor),
            gradView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        gradView.colorArray = [
            .blue, .orange, .purple, .yellow
        ]

    }
    
}

class MaskedGradView: UIView {
    
    enum Direction {
        case horizontal, vertical, diagnal
    }
    public var colorArray: [UIColor] = [] {
        didSet {
            setNeedsLayout()
        }
    }
    public var locationsArray: [NSNumber] = [] {
        didSet {
            setNeedsLayout()
        }
    }
    public var direction: Direction = .vertical {
        didSet {
            setNeedsLayout()
        }
    }
    
    private let gLayer = CAGradientLayer()
    private let maskLayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        // add gradient layer as a sublayer
        layer.addSublayer(gLayer)
        
        // mask it
        gLayer.mask = maskLayer

        // we'll use a 120-point diameter circle for the mask
        maskLayer.path = UIBezierPath(ovalIn: CGRect(x: 0.0, y: 0.0, width: 120.0, height: 120.0)).cgPath
        
        // so we can see this view's frame
        layer.borderColor = UIColor.black.cgColor
        layer.borderWidth = 1
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // update gradient layer
        //  frame
        //  colors
        //  locations
        
        gLayer.frame = bounds
        
        gLayer.colors = colorArray.map({ $0.cgColor })
        
        if locationsArray.count > 0 {
            gLayer.locations = locationsArray
        }
        
        switch direction {
        case .horizontal:
            gLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
            gLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
        case .vertical:
            gLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
            gLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        case .diagnal:
            gLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
            gLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
        }
        
    }

    // touch code to drag the circular mask around
    private var curPos: CGPoint = .zero
    private var lPos: CGPoint = .zero
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        curPos = touch.location(in: self)
        lPos = maskLayer.position
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }
        let newPos = touch.location(in: self)
        let diffX = newPos.x - curPos.x
        let diffY = newPos.y - curPos.y
        
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        
        maskLayer.position = CGPoint(x: lPos.x + diffX, y: lPos.y + diffY)
        
        CATransaction.commit()  }
}

To help make it clear, I added touch handling code so you can drag the circle around inside the view:

enter image description here


Edit - after comment (but still missing details), let's try this again...

We can get the desired output by using multiple layers.

  • a white-filled gray-bordered rectangle CAShapeLayer
  • a white-filled NON-bordered oval CAShapeLayer
  • a CAGradientLayer masked with an oval CAShapeLayer
  • a NON-filled gray-bordered oval CAShapeLayer

So, we start with a view:

enter image description here

add a white-filled gray-bordered rectangle CAShapeLayer:

enter image description here

add a white-filled NON-bordered oval CAShapeLayer (red first, to show it clearly):

enter image description here enter image description here

add a CAGradientLayer:

enter image description here

mask it with an oval CAShapeLayer:

enter image description here enter image description here

finally, add a NON-filled gray-bordered oval CAShapeLayer:

enter image description here

Here's the example code:

class GradientMaskingViewController: UIViewController {
    
    let gradView = MultiLayeredGradView()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)

        gradView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(gradView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            gradView.widthAnchor.constraint(equalToConstant: 240.0),
            gradView.heightAnchor.constraint(equalTo: gradView.widthAnchor),
            gradView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            gradView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
    }
    
}

class MultiLayeredGradView: UIView {
    
    private let rectLayer = CAShapeLayer()
    private let filledCircleLayer = CAShapeLayer()
    private let gradLayer = CAGradientLayer()
    private let maskLayer = CAShapeLayer()
    private let outlineCircleLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() {
        
        // add filled-bordered rect layer as a sublayer
        layer.addSublayer(rectLayer)
        
        // add filled circle layer as a sublayer
        layer.addSublayer(filledCircleLayer)
        
        // add gradient layer as a sublayer
        layer.addSublayer(gradLayer)
        
        // mask it
        gradLayer.mask = maskLayer
        
        // add outline circle layer as a sublayer
        layer.addSublayer(outlineCircleLayer)
        
        let bColor: CGColor = UIColor.gray.cgColor
        let fColor: CGColor = UIColor.white.cgColor
        
        // filled-outlined
        rectLayer.strokeColor = bColor
        rectLayer.fillColor = fColor
        rectLayer.lineWidth = 2
        
        // filled
        filledCircleLayer.fillColor = fColor
        
        // clear-outlined
        outlineCircleLayer.strokeColor = bColor
        outlineCircleLayer.fillColor = UIColor.clear.cgColor
        outlineCircleLayer.lineWidth = 2

        // gradient layer properties
        let colorArray: [UIColor] = [
            .blue, .orange, .purple, .yellow
        ]
        gradLayer.colors = colorArray.map({ $0.cgColor })
        gradLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
        gradLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // circle diameter is 45% of the width of the view
        let circleDiameter: CGFloat = bounds.width * 0.45
        
        // circle Top is at vertical midpoint
        // circle is moved Left by 25% of the circle diameter
        let circleBounds: CGRect = CGRect(x: bounds.minX - circleDiameter * 0.25,
                                          y: bounds.maxY * 0.5,
                                          width: circleDiameter,
                                          height: circleDiameter)
        
        // gradient layer fills the bounds
        gradLayer.frame = bounds
        
        let rectPath = UIBezierPath(rect: bounds).cgPath
        rectLayer.path = rectPath
        
        let circlePath = UIBezierPath(ovalIn: circleBounds).cgPath
        filledCircleLayer.path = circlePath
        outlineCircleLayer.path = circlePath
        maskLayer.path = circlePath
        
    }

}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • This doesn't address the question. You considered the box a UIView, whereas it is a CAShapeLayer (as well as the circle). Also, I don't want to get rid of the empty part in the circle (see the image I posted). The thing is that the gradient should only be applied to the intersection (common area) between the box and the circle, but you can still see the full circle perimeter. – Asteroid Apr 19 '22 at 21:04
  • @Asteroid - OK, to try and clarify... An outlined circle and an outlined rectangle (except for the part where the rect crosses the circle)... and that should be used as a **mask**? Should the white areas be opaque-white? Or transparent? Is the outlined rect supposed to be the same size as the gradient rect? Or, are you just trying to show an "assembled mask" that you want to use at any size on any other-sized view? Or... is the image on the right your "goal" image? – DonMag Apr 19 '22 at 23:33
  • The image on the right is the goal image. As you can see only the intersection of the two shapes is masking the gradient layer. – Asteroid Apr 20 '22 at 03:21
  • @Asteroid ... it would be very complex - possibly impossible - to do this with a single mask. I added an **Edit** to my answer -- see if that gets you closer to your goal. – DonMag Apr 20 '22 at 16:00