I have this custom code that i wrote awhile back in order to have a gradient border on my views. Only problem is, it has slowly become my top crash in my app.
The crashlog I am getting in crashlytics points to the line super.layoutSubviews()
which doesn't make much sense to me.
Anyone have any idea on improvements, or a different approach to doing this same thing, that can lead to fewer crashes? I get 120-200 a week from this view.
class GradientBorderView: UIView {
var enableGradientBorder: Bool = false
var borderGradientColors: [UIColor] = [] {
didSet {
setNeedsLayout()
}
}
override func layoutSubviews() {
super.layoutSubviews()
guard enableGradientBorder && !borderGradientColors.isEmpty else {
layer.borderColor = nil
return
}
let gradient = UIImage.imageGradient(bounds: bounds, colors: borderGradientColors)
layer.borderColor = UIColor(patternImage: gradient).cgColor
}
}
extension UIImage {
static func imageGradient(bounds: CGRect, colors: [UIColor]) -> UIImage {
let gradientLayer = CAGradientLayer()
gradientLayer.frame = bounds
gradientLayer.colors = colors.map(\.cgColor)
// This makes it left to right, default is top to bottom
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { gradientLayer.render(in: $0.cgContext) }
}
}
Tough to say why you would be getting crashes, unless you can provide code that reproduces it.
However, you may find using a CAGradientLayer
with a .mask
to be a better, more efficient option:
class MyGradientBorderView: UIView {
var enableGradientBorder: Bool = false { didSet { setNeedsLayout() } }
var borderGradientColors: [UIColor] = [] { didSet { setNeedsLayout() } }
var gradientBorderWidth: CGFloat = 8.0 { didSet { setNeedsLayout() } }
let gLayer: CAGradientLayer = CAGradientLayer()
let mskLayer: CAShapeLayer = CAShapeLayer()
override func layoutSubviews() {
super.layoutSubviews()
guard enableGradientBorder, gradientBorderWidth > 0.0, !borderGradientColors.isEmpty else {
gLayer.removeFromSuperlayer()
return
}
if gLayer.superlayer == nil {
layer.addSublayer(gLayer)
}
gLayer.frame = bounds
gLayer.colors = borderGradientColors.map(\.cgColor)
gLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let w: CGFloat = gradientBorderWidth
mskLayer.path = UIBezierPath(rect: bounds.insetBy(dx: w * 0.5, dy: w * 0.5)).cgPath
mskLayer.fillColor = UIColor.clear.cgColor
mskLayer.strokeColor = UIColor.red.cgColor // any opaque color
mskLayer.lineWidth = w
gLayer.mask = mskLayer
}
}
Edit -- addressing the need for rounded corners...
There are a number of bugs with UIBezierPath(roundedRect: ...
.
The biggest problems come into play when the cornerRadius
is roughly 1/3rd of the short-side.
So, if the frame is 300 x 60
(perhaps to provide a border for a label), and we have a cornerRadius
of 20
, things can get really ugly.
Instead of overwhelming with details, refer to these SO posts (among others):
Now, IF you will always have a clear background, and IF you will never approach the 1/3rd radius-to-side ratio, using UIBezierPath(roundedRect: ...
with the above code should not be a problem.
However, here is another approach to your design goal that you may find more reliable and flexible...
We will keep the view background color clear, add a "fillLayer" for the desired background color, and we'll mask the gradient layer with a "plain" CALayer
with it's border properties set.
class AnotherGradientBorderView: UIView {
// override backgroundColor, because we want this view
// to always have a clear background
// to avoid edge anti-aliasing artifacts
// we use the fillLayer as the background color
override var backgroundColor: UIColor? {
set {
super.backgroundColor = .clear
self.bkgColor = newValue ?? .clear
}
get {
return self.bkgColor
}
}
public var enableGradientBorder: Bool = false { didSet { setNeedsLayout() } }
public var borderGradientColors: [UIColor] = [] { didSet { setNeedsLayout() } }
public var gradientBorderWidth: CGFloat = 8.0 { didSet { setNeedsLayout() } }
public var cornerRadius: CGFloat = 20.0 { didSet { setNeedsLayout() } }
private var bkgColor: UIColor = .clear { didSet { setNeedsLayout() } }
private let gLayer: CAGradientLayer = CAGradientLayer()
private let fillLayer: CALayer = CALayer()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layer.addSublayer(fillLayer)
layer.addSublayer(gLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
fillLayer.backgroundColor = bkgColor.cgColor
let w: CGFloat = gradientBorderWidth
let rad: CGFloat = cornerRadius
self.layer.cornerRadius = rad
let mskLayer = CALayer()
mskLayer.frame = bounds
mskLayer.cornerRadius = rad
mskLayer.borderWidth = w
mskLayer.borderColor = UIColor.black.cgColor // any opaque color
gLayer.mask = mskLayer
if enableGradientBorder, gradientBorderWidth > 0.0, !borderGradientColors.isEmpty {
gLayer.opacity = 1.0
gLayer.frame = bounds
gLayer.colors = borderGradientColors.map(\.cgColor)
gLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
// to avoid edge anti-aliasing artifacts
// inset and adjust cornerRadius of fillLayer
// if gradient border is showing
fillLayer.frame = bounds.insetBy(dx: w * 0.5, dy: w * 0.5)
fillLayer.cornerRadius = rad - w * 0.5
} else {
gLayer.opacity = 0.0
fillLayer.frame = bounds
fillLayer.cornerRadius = rad
}
}
}
Example controller
class ViewController: UIViewController {
var gbViews: [AnotherGradientBorderView] = []
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
let g = view.safeAreaLayoutGuide
for _ in 1...4 {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 20.0, weight: .bold)
lbl.text = "Label behind/underneath the gradient border view."
lbl.numberOfLines = 0
lbl.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(lbl)
let v = AnotherGradientBorderView()
v.backgroundColor = .black.withAlphaComponent(0.20)
v.borderGradientColors = [.red, .yellow]
v.enableGradientBorder = true
v.cornerRadius = 20.0
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
NSLayoutConstraint.activate([
v.centerXAnchor.constraint(equalTo: g.centerXAnchor),
v.widthAnchor.constraint(equalToConstant: 120.0),
v.heightAnchor.constraint(equalToConstant: 80.0),
lbl.centerXAnchor.constraint(equalTo: v.centerXAnchor),
lbl.centerYAnchor.constraint(equalTo: v.centerYAnchor),
lbl.widthAnchor.constraint(equalToConstant: 200.0),
])
gbViews.append(v)
}
gbViews[2].cornerRadius = 0.0
gbViews[3].cornerRadius = 0.0
gbViews[1].backgroundColor = .systemBlue
gbViews[3].backgroundColor = .systemBlue
NSLayoutConstraint.activate([
gbViews[0].topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
gbViews[1].topAnchor.constraint(equalTo: gbViews[0].bottomAnchor, constant: 8.0),
gbViews[2].topAnchor.constraint(equalTo: gbViews[1].bottomAnchor, constant: 8.0),
gbViews[3].topAnchor.constraint(equalTo: gbViews[2].bottomAnchor, constant: 8.0),
])
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
gbViews.forEach { v in
v.enableGradientBorder.toggle()
}
}
}
Output - note that on the first and third instances, I used .black.withAlphaComponent(0.20)
for the background color instead of .clear
-- otherwise when we toggle off the gradient border we wouldn't see anything:
tapping anywhere toggles the gradient border: