2
\$\begingroup\$

InnerGradientBorderView is a subclass of UIView that has a thick border with a gradient. I have tried to replicate the Zoom meeting UI. They apply this kind of a border during screenshare.

import UIKit

class InnerGradientBorderView: UIView {

    var gradientLayers = [CAGradientLayer]()

    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = UIColor.clear
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        backgroundColor = UIColor.clear
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        for layer in gradientLayers {
            layer.removeFromSuperlayer()
        }

        addGradientInnerBorder(width: 8, color: UIColor.orange)
    }

    func addGradientInnerBorder(width: CGFloat, color: UIColor) {

        // Setup
        let topLeftO = CGPoint(x: 0, y: 0)
        let topLeftI = CGPoint(x: width, y: width)

        let topRightO = CGPoint(x: frame.width, y: 0)
        let topRightI = CGPoint(x: frame.width - width, y: width)

        let bottomLeftO = CGPoint(x: 0, y: frame.height)
        let bottomLeftI = CGPoint(x: width, y: frame.height - width)

        let bottomRightO = CGPoint(x: frame.width, y: frame.height)
        let bottomRightI = CGPoint(x: frame.width - width, y: frame.height - width)

        // Top
        let topPoints = [topLeftO, topLeftI, topRightI, topRightO, topLeftO]
        let topGradientPoints = [CGPoint(x: 0, y: 0), CGPoint(x: 0, y: 1)]
        gradientLayers.append(addGradientToBeizerPath(path: addClosedPathForPoints(points: topPoints), color: color, gradientPoints: topGradientPoints))

        // Left
        let leftPoints = [topLeftO, topLeftI, bottomLeftI, bottomLeftO, topLeftO]
        let leftGradientPoints = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0)]
        gradientLayers.append(addGradientToBeizerPath(path: addClosedPathForPoints(points: leftPoints), color: color, gradientPoints: leftGradientPoints))

        // Right
        let rightPoints = [topRightO, topRightI, bottomRightI, bottomRightO, topRightO]
        let rightGradientPoints = [CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 0)]
        gradientLayers.append(addGradientToBeizerPath(path: addClosedPathForPoints(points: rightPoints), color: color, gradientPoints: rightGradientPoints))

        // Bottom
        let bottomPoints = [bottomLeftO, bottomLeftI, bottomRightI, bottomRightO, bottomLeftO]
        let bottomGradientPoints = [CGPoint(x: 0, y: 1), CGPoint(x: 0, y: 0)]
        gradientLayers.append(addGradientToBeizerPath(path: addClosedPathForPoints(points: bottomPoints), color: color, gradientPoints: bottomGradientPoints))
    }

    func addClosedPathForPoints(points: [CGPoint]) -> UIBezierPath? {
        guard points.count == 5 else { return nil }

        let path = UIBezierPath()
        path.move(to: points[0])

        path.addLine(to: points[1])
        path.addLine(to: points[2])
        path.addLine(to: points[3])
        path.addLine(to: points[4])

        path.close()

        return path
    }

    func addGradientToBeizerPath(path: UIBezierPath?, color: UIColor, gradientPoints: [CGPoint]) -> CAGradientLayer {
        guard let path = path, gradientPoints.count == 2 else { return CAGradientLayer() }

        let gradient = CAGradientLayer()
        gradient.frame = path.bounds
        gradient.bounds = path.bounds // This fixes the code.
        gradient.colors = [color.cgColor, UIColor.white.cgColor]
        gradient.startPoint = gradientPoints[0]
        gradient.endPoint = gradientPoints[1]

        let shapeMask = CAShapeLayer()
        shapeMask.path = path.cgPath
        gradient.mask = shapeMask

        self.layer.insertSublayer(gradient, at: 0)

        return gradient
    }
}
\$\endgroup\$
2
+50
\$\begingroup\$

Nitpick: It is "Bezier", not "Beizer".


Setting the background color

backgroundColor = UIColor.clear

can be shortened to

backgroundColor = .clear

because the type is inferred from the context. But why do you force the view to be fully transparent at all? Shouldn't that be left to the designer to configure in the interface builder? Unless there is a special reason I would just remove that from the init methods.


Your view has three characteristic properties: the outer and inner gradient color, and the width of the border. The values of those properties are spread in the code: width and outer gradient color are arguments in

addGradientInnerBorder(width: 8, color: UIColor.orange)

and passed around, while the inner color is directly set in

gradient.colors = [color.cgColor, UIColor.white.cgColor]

I would define properties instead:

class InnerGradientBorderView: UIView {

    let borderWidth: CGFloat = 8.0
    let outerColor = UIColor.orange
    let innerColor = UIColor.white

    // ...
}

so that they are easily identified and modified. Even better: Use @IBInspectable and @IBDesignable

@IBDesignable
class InnerGradientBorderView: UIView {

    @IBInspectable var borderWidth: CGFloat = 8.0 {
        didSet {
            setNeedsLayout()
        }
    }
    @IBInspectable var outerColor: UIColor = .orange {
        didSet {
            setNeedsLayout()
        }
    }
    @IBInspectable var innerColor: UIColor = .white {
        didSet {
            setNeedsLayout()
        }
    } 

    // ...
}

so that they can be configured directly in the interface builder, with a live preview (compare IBInspectable / IBDesignable:

enter image description here


Closing a path automatically adds a line segment from the last point to the first point of the current path segment, i.e. the final addLine(to:) in addClosedPathForPoints() is not needed, and it suffices to pass 4 points to the function.

Also I would not make the return value optional. Passing a wrong number of arguments is a programming error and should be detect early in the development instead of silently ignored:

func addClosedPathForPoints(points: [CGPoint]) -> UIBezierPath {
    assert(points.count == 4)

    let path = UIBezierPath()
    path.move(to: points[0])
    path.addLine(to: points[1])
    path.addLine(to: points[2])
    path.addLine(to: points[3])
    path.close()

    return path
}

Instead of completely re-creating all gradient layers in layoutSubviews() it might be more efficient to create them once (when the view is initialized), and only update the frame and mask layer when the layout changes.

Then your class would look like this:

@IBDesignable
class InnerGradientBorderView: UIView {

    @IBInspectable var borderWidth: CGFloat = 8.0 {
        didSet {
            setNeedsLayout()
        }
    }
    @IBInspectable var outerColor: UIColor = .orange {
        didSet {
            createGradients()
            setNeedsLayout()
        }
    }
    @IBInspectable var innerColor: UIColor = .white {
        didSet {
            createGradients()
            setNeedsLayout()
        }
    } 

    var topGradient, leftGradient, rightGradient, bottomGradient: CAGradientLayer!

    override init(frame: CGRect) {
        super.init(frame: frame)
        createGradients()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        createGradients()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        layoutGradients()
    }

    func createGradients() {
        topGradient = createGradient(gradientPoints: [CGPoint(x: 0, y: 0), CGPoint(x: 0, y: 1)])
        leftGradient = createGradient(gradientPoints: [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0)])
        rightGradient = createGradient(gradientPoints: [CGPoint(x: 1, y: 0), CGPoint(x: 0, y: 0)])
        bottomGradient = createGradient(gradientPoints: [CGPoint(x: 0, y: 1), CGPoint(x: 0, y: 0)])

        layer.addSublayer(topGradient)
        layer.addSublayer(leftGradient)
        layer.addSublayer(rightGradient)
        layer.addSublayer(bottomGradient)
    }

    func createGradient(gradientPoints: [CGPoint]) -> CAGradientLayer {
        let gradient = CAGradientLayer()
        gradient.colors = [outerColor.cgColor, innerColor.cgColor]
        gradient.startPoint = gradientPoints[0]
        gradient.endPoint = gradientPoints[1]
        return gradient
    }

    func layoutGradients() {
        let topLeftO = CGPoint(x: 0, y: 0)
        let topLeftI = CGPoint(x: borderWidth, y: borderWidth)

        let topRightO = CGPoint(x: frame.width, y: 0)
        let topRightI = CGPoint(x: frame.width - borderWidth, y: borderWidth)

        let bottomLeftO = CGPoint(x: 0, y: frame.height)
        let bottomLeftI = CGPoint(x: borderWidth, y: frame.height - borderWidth)

        let bottomRightO = CGPoint(x: frame.width, y: frame.height)
        let bottomRightI = CGPoint(x: frame.width - borderWidth, y: frame.height - borderWidth)

        layoutGradient(gradient: topGradient, points: [topLeftO, topLeftI, topRightI, topRightO])
        layoutGradient(gradient: leftGradient, points: [topLeftO, topLeftI, bottomLeftI, bottomLeftO])
        layoutGradient(gradient: rightGradient, points: [topRightO, topRightI, bottomRightI, bottomRightO])
        layoutGradient(gradient: bottomGradient, points: [bottomLeftO, bottomLeftI, bottomRightI, bottomRightO])
    }

    func layoutGradient(gradient: CAGradientLayer, points: [CGPoint]) {
        let path = UIBezierPath()
        path.move(to: points[0])
        path.addLine(to: points[1])
        path.addLine(to: points[2])
        path.addLine(to: points[3])
        path.close()

        gradient.frame = path.bounds
        gradient.bounds = path.bounds

        if let shapeMask = gradient.mask as? CAShapeLayer {
            // Update mask.
            shapeMask.path = path.cgPath
        } else {
            // Create and set mask.
            let shapeMask = CAShapeLayer()
            shapeMask.path = path.cgPath
            gradient.mask = shapeMask
        }
    }
}

But this is not yet perfect! The problem is that the sublayers do not animate correctly if the view's position or size changes, e.g. when rotating the device. You'll notice that clearly in the iOS Simulator if you activate "Debug -> Slow Animations":

enter image description here

The solution is to embed the call to layoutGradients() into a Core Animation transaction so that the frame and mask changes are synchronized with the view layer's animation, see for example

Applied to our situation (and translated to Swift) that becomes:

    override func layoutSubviews() {
        super.layoutSubviews()

        if let animation = layer.animation(forKey: "position") {
            CATransaction.begin()
            CATransaction.setAnimationDuration(animation.duration)
            CATransaction.setAnimationTimingFunction(animation.timingFunction)

            let frameAnimation = CABasicAnimation(keyPath: "frame")
            topGradient.add(frameAnimation, forKey: "frame")
            leftGradient.add(frameAnimation, forKey: "frame")
            rightGradient.add(frameAnimation, forKey: "frame")
            bottomGradient.add(frameAnimation, forKey: "frame")

            let pathAnimation = CABasicAnimation(keyPath: "path")
            topGradient.mask?.add(pathAnimation, forKey: "path")
            leftGradient.mask?.add(pathAnimation, forKey: "path")
            rightGradient.mask?.add(pathAnimation, forKey: "path")
            bottomGradient.mask?.add(pathAnimation, forKey: "path")

            layoutGradients()
            CATransaction.commit()
        } else {
            layoutGradients()
        }
    }

And now it animates perfectly:

enter image description here

\$\endgroup\$
  • \$\begingroup\$ This is an amazing and detailed answer. Thank you Martin! \$\endgroup\$ – Zia Jan 2 '18 at 18:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.