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.
I figured to compose this of several CAShapeLayers
as I would in SwiftUI. This renders as desired (sans-gradients).
However, when masking a CAGradientLayer
with a previously-rendered CAShapeLayer
, the gradient is not drawn visibly.
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.
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
}
}
}
}