Creating a gradient border of uiview with fewer crashes

56 views Asked by At

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) }
    }
}
1

There are 1 answers

8
DonMag On BEST ANSWER

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:

enter image description here

tapping anywhere toggles the gradient border:

enter image description here