iosswiftcagradientlayer

Creating a gradient border of uiview with fewer crashes


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

Solution

  • 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