0

I'd like to recreate a bevel-effect circular UIButton subclass, as pictured below (but with a user-chosen base color). I am confused on how to set the bounds for a CAGradientLayer / masking CAShapeLayer.

Goal1

I figured to compose this of several CAShapeLayers as I would in SwiftUI. This renders as desired (sans-gradients).

CAShapeLayers

However, when masking a CAGradientLayer with a previously-rendered CAShapeLayer, the gradient is not drawn visibly.

CAGradientLayer3

I assume my error is related to bounds/frame setting. When I move the sublayer additions to layoutSubviews(), part of the gradient ring is rendered. Also, the user-color circle obeys constraints set by a parent VC, but the other layers do not.

layoutSubviews

I've read several posts (1 2, 3, 4) on applying a CAShapeLayer as a mask to a CAGradientLayer, but am failing to understand how to properly set frames for these multiple layers.


extension BeveledButton {
    
    /// Draws yellow ring in desired location
    func makeEmbossmentRingAsShapeLayer()  -> CAShapeLayer {
        let pathFrame = frame.insetBy(dx: insetBevel, dy: insetBevel)
        let path = CGPath.circleStroked(width: bevelRingWidth, inFrame: pathFrame)
        
        let shape = CAShapeLayer()
        shape.path = path
        shape.fillColor = UIColor.yellow.cgColor
        return shape
    }
    
    /// Does not draw gradient (visibly) where yellow ring was
    func makeEmbossmentRingAsGradientLayer()  -> CAGradientLayer {
        let pathFrame = frame.insetBy(dx: insetBevel, dy: insetBevel)
        let path = CGPath.circleStroked(width: bevelRingWidth, inFrame: frame)
        
        let mask = CAShapeLayer()
        mask.path = path
        mask.fillColor = UIColor.black.cgColor
        mask.lineWidth = 4
        mask.frame = pathFrame
        
        let gradient = CAGradientLayer(colors: [UIColor.orange, UIColor.green], in: pathFrame)
        gradient.frame = pathFrame
        gradient.mask = mask
        return gradient
    }
}


extension CGPath {
    
    static func circle(inFrame: CGRect) -> CGPath {
        CGPath(ellipseIn: inFrame, transform: nil)
    }
    
    static func circleStroked(width: CGFloat, inFrame: CGRect) -> CGPath {
        var path = CGPath(ellipseIn: inFrame, transform: nil)
        path = path.copy(strokingWithWidth: width, lineCap: .round, lineJoin: .round, miterLimit: 0)
        return path
    }
}

extension CAGradientLayer {
    
    convenience init(colors: [UIColor], in frame: CGRect) {
        self.init()
        self.colors = colors.map(\.cgColor)
        self.frame = frame
    }
}

import UIKit
import Combine

class BeveledButton: UIButton {
    
    weak var userColor: CurrentValueSubject<UIColor,Never>?
    private var updates: AnyCancellable? = nil
    
    private lazy var outerRing: CAShapeLayer = makeOuterRing()
    private lazy var innerReflection: CAShapeLayer = makeReflectionCircle()
    private lazy var userColorCircle: CAShapeLayer = makeUserColorCircle()
    private lazy var embossmentRing: CAGradientLayer = makeEmbossmentRingAsGradientLayer()
    //    private lazy var embossmentRing: CAShapeLayer = makeEmbossmentRingAsShapeLayer()
    
    init(frame: CGRect, userColor: CurrentValueSubject<UIColor,Never>) {
        self.userColor = userColor
        super.init(frame: frame)
        setupRings()
        updateUserColor(userColor)
    }
    
    required init?(coder: NSCoder) { fatalError("") }
    
    private let outerRingWidth: CGFloat = 2.5
    private let inset: CGFloat = 3
    private let insetReflection: CGFloat = 5
    private lazy var insetBevel: CGFloat = inset + 1
    private let bevelRingWidth: CGFloat = 2
}


private extension BeveledButton {
    
    func setupRings() {
        layer.addSublayer(outerRing)
        layer.addSublayer(userColorCircle)
        layer.addSublayer(innerReflection)
        layer.addSublayer(embossmentRing)
    }
    
    func makeOuterRing() -> CAShapeLayer {
        let path = CGPath.circle(inFrame: frame)
        let shape = CAShapeLayer()
        shape.fillColor = UIColor.clear.cgColor
        shape.strokeColor = UIColor.lightGray.cgColor
        shape.lineWidth = outerRingWidth
        shape.path = path
        return shape
    }
    
    func makeUserColorCircle()  -> CAShapeLayer {
        let pathFrame = frame.insetBy(dx: inset, dy: inset)
        let path = CGPath.circle(inFrame: pathFrame)
        
        let shape = CAShapeLayer()
        shape.path = path
        shape.fillColor = userColor?.value.cgColor
        return shape
    }
    
    func makeReflectionCircle()  -> CAShapeLayer {
        let pathFrame = frame.insetBy(dx: insetReflection, dy: insetReflection)
        let path = CGPath.circle(inFrame: pathFrame)
        
        let shape = CAShapeLayer()
        shape.path = path
        shape.fillColor = UIColor.lightGray.cgColor.copy(alpha: 0.4)
        return shape
    }
    
    func updateUserColor(_ userColor: CurrentValueSubject<UIColor,Never>) {
        updates = userColor.sink { [weak self] color in
            UIView.animate(withDuration: 0.3) {
                self?.userColorCircle.fillColor = color.cgColor
            }
        }
    }
}
Ryan
  • 1,252
  • 6
  • 15

1 Answers1

0

You're on the right track...

The idea is to add 4 sublayers:

  1. "outer" CAShapeLayer
  2. "ring" CAShapeLayer
  3. "bevel" CAGradientLayer with CAShapeLayer mask
  4. "inner" CAGradientLayer with CAShapeLayer mask

Inset the frame of each layer by the width of the previous layer, so:

  • rect = bounds
  • outer layer frame will be rect
  • inset rect by "outer width"
  • ring layer frame will be that inset rect
  • inset rect by "ring width"
  • bevel layer frame will be that inset rect
  • inset rect by "bevel width"
  • inner layer frame will be that inset rect

So layoutSubviews will look something like this:

override func layoutSubviews() {
    super.layoutSubviews()
    
    var pth: UIBezierPath = UIBezierPath()
    var frameRect: CGRect = .zero
    var pathRect: CGRect = .zero
    
    frameRect = bounds
    
    outerLayer.frame = frameRect
    pathRect.size = frameRect.size
    pth = UIBezierPath(ovalIn: pathRect)
    outerLayer.path = pth.cgPath
    
    frameRect = frameRect.insetBy(dx: outerWidth, dy: outerWidth)
    
    ringLayer.frame = frameRect
    pathRect.size = frameRect.size
    pth = UIBezierPath(ovalIn: pathRect)
    ringLayer.path = pth.cgPath
    
    frameRect = frameRect.insetBy(dx: ringWidth, dy: ringWidth)
    
    bevelGradLayer.frame = frameRect
    pathRect.size = frameRect.size
    pth = UIBezierPath(ovalIn: pathRect)
    bevelLayerMask.path = pth.cgPath
    
    frameRect = frameRect.insetBy(dx: bevelWidth, dy: bevelWidth)
    
    innerGradLayer.frame = frameRect
    pathRect.size = frameRect.size
    pth = UIBezierPath(ovalIn: pathRect)
    innerLayerMask.path = pth.cgPath
    
}

In the code you posted, you are using extension to try and segment the code functions... that can be helpful, but it can also make things more complicated, and can make it a little difficult to follow the flow - particularly when elements need to be related.

Here is a version of what you're going for... note that it is @IBDesignable with various user-available @IBInspectable properties, so you can see it in IB / Storyboard (if desired):

@IBDesignable
class BevelButton: UIButton {
    
    @IBInspectable
    var outerColor: UIColor = UIColor(white: 0.75, alpha: 1.0) {
        didSet {
            outerLayer.fillColor = outerColor.cgColor
        }
    }
    @IBInspectable
    var ringColor: UIColor = .black {
        didSet {
            ringLayer.fillColor = ringColor.cgColor
        }
    }
    @IBInspectable
    var startColor: UIColor = UIColor(white: 0.9, alpha: 1.0) {
        didSet {
            bevelGradLayer.colors = [startColor.cgColor, endColor.cgColor]
            innerGradLayer.colors = [endColor.cgColor, startColor.cgColor]
        }
    }
    @IBInspectable
    var endColor: UIColor = UIColor(white: 0.75, alpha: 1.0) {
        didSet {
            bevelGradLayer.colors = [startColor.cgColor, endColor.cgColor]
            innerGradLayer.colors = [endColor.cgColor, startColor.cgColor]
        }
    }
    
    @IBInspectable
    var outerWidth: CGFloat = 6

    @IBInspectable
    var ringWidth: CGFloat = 2

    @IBInspectable
    var bevelWidth: CGFloat = 6
    
    private let outerLayer = CAShapeLayer()
    private let ringLayer = CAShapeLayer()
    private let innerLayerMask = CAShapeLayer()
    private let innerGradLayer = CAGradientLayer()
    private let bevelLayerMask = CAShapeLayer()
    private let bevelGradLayer = CAGradientLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    private func commonInit() -> Void {
        
        layer.addSublayer(outerLayer)
        layer.addSublayer(ringLayer)
        layer.addSublayer(bevelGradLayer)
        layer.addSublayer(innerGradLayer)
        
        bevelGradLayer.mask = bevelLayerMask
        innerGradLayer.mask = innerLayerMask
        
        bevelLayerMask.fillColor = UIColor.black.cgColor
        innerLayerMask.fillColor = UIColor.black.cgColor
        
        outerLayer.fillColor = outerColor.cgColor
        ringLayer.fillColor = ringColor.cgColor
        
        bevelGradLayer.colors = [startColor.cgColor, endColor.cgColor]
        innerGradLayer.colors = [endColor.cgColor, startColor.cgColor]
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        var pth: UIBezierPath = UIBezierPath()
        var frameRect: CGRect = .zero
        var pathRect: CGRect = .zero
        
        frameRect = bounds
        
        outerLayer.frame = frameRect
        pathRect.size = frameRect.size
        pth = UIBezierPath(ovalIn: pathRect)
        outerLayer.path = pth.cgPath
        
        frameRect = frameRect.insetBy(dx: outerWidth, dy: outerWidth)
        
        ringLayer.frame = frameRect
        pathRect.size = frameRect.size
        pth = UIBezierPath(ovalIn: pathRect)
        ringLayer.path = pth.cgPath
        
        frameRect = frameRect.insetBy(dx: ringWidth, dy: ringWidth)
        
        bevelGradLayer.frame = frameRect
        pathRect.size = frameRect.size
        pth = UIBezierPath(ovalIn: pathRect)
        bevelLayerMask.path = pth.cgPath
        
        frameRect = frameRect.insetBy(dx: bevelWidth, dy: bevelWidth)
        
        innerGradLayer.frame = frameRect
        pathRect.size = frameRect.size
        pth = UIBezierPath(ovalIn: pathRect)
        innerLayerMask.path = pth.cgPath
        
    }
    
    override var isHighlighted: Bool {
        get {
            return super.isHighlighted
        }
        set {
            if newValue {
                innerGradLayer.colors = [startColor.cgColor, endColor.cgColor]
                bevelGradLayer.colors = [endColor.cgColor, startColor.cgColor]
            } else {
                bevelGradLayer.colors = [startColor.cgColor, endColor.cgColor]
                innerGradLayer.colors = [endColor.cgColor, startColor.cgColor]
            }
            super.isHighlighted = newValue
        }
    }
}

Here's how it looks (with default properties) in Storyboard:

enter image description here

Here's the IBInspectable properties panel:

enter image description here

And, at run-time, default state:

enter image description here

and Highlighted state (with the gradients flipped vertically):

enter image description here

DonMag
  • 69,424
  • 5
  • 50
  • 86