Creating an IBDesignable Gradient View in Swift 4

Last modified date

Comments: 4

IBDesignable gradient view is like mixing colours in a paint palette

 

This tutorial will demonstrate how to create a versatile, @IBDesignable gradient view class in Swift 4. You can drop the CAGradientView into storyboards and preview at design time, or add it programmatically. You can set the colours for two gradient stops (start and end), and easily set the gradient direction (in degrees), so you can easily have horizontal gradients, vertical gradients, or any angle you like. These properties are completely controllable from the IB inspector.

 

Why We Need This

Designers just love gradients. Admittedly, like drop shadows, they go in and out of fashion with the changing wind, and they do tend to be more subtle nowadays, but any developer who’s ever sat through a lot of design meetings has probably had a conversation something like this:-

Developer: Whoa… what’s with all these gradients?
Designer: I don’t think the user will consciously see them, but they’ll direct him to the CTA without him feeling manipulated.
Developer: So you want gradients so subtle the user won’t notice them?
Designer: Yes. I mean, not consciously, no.  The CEO loves them.
Developer: [Strokes beard.] I dunno. That’s a lot of gradient layers.
Designer: Can’t you just do it in CSS? That’s what the web guys at my last gig did.
Developer: [Sigh…]



If you’ve felt this pain, then this article is for you. Creating a gradient can be fiddly, and tweaking it to look like your designer envisioned it can be time-consuming. This tutorial shows you how to build a gradient view component you can drop into storyboards and preview right from within Interface Builder.

Your designers will love you for it!

What Will We Build?

It’s easy to say were going to build a gradient view, but what are the exact requirements? Let’s define them:-

  • It must be a UIView subclass
  • It must be written in Swift 4
  • It must be @IBDesignable so it can be previewed in Xcode/Interface Builder
  • It must be completely configurable either in code or in Interface Builder

Two of the most difficult properties to expose in the storyboard attributes inspector panel are the gradient start and end points.

Get the Example Project

For a head start, or if you don’t want to read the whole tutorial, you can grab the example project from GitHub at any time.

When you load the project into Xcode and open the example ViewController scene in the storyboard, you will be able to select the gradient view and edit it in the Attributes Inspector, as shown in the image below.

Editing the Gradient View in Xcode

About Gradient Layers

NOTE: This is not intended to be an introduction to CAGradientLayer. If you need a more basic introduction, please read our Mastering CAGradientLayer in Swift tutorial which explains all the nitty-gritty details of the code we’re about to write here.

There are several ways to achieve a gradient effect in iOS but in this tutorial we will be using CAGradientLayer. This is a subclass of CALayer, a Core Animation object that is part of the view’s layer hierarchy. In iOS, UIViews are described as layer-backed views because their appearance is controlled by their layer property. Every view has a layer, and just like every UIView can have multiple subviews, every layer can have multiple sublayers.

What this means in practical terms is that each view can have an arbitrarily complex tree of layers to add visual complexity to the view. When working heavily with Core Animation, at some point the developer has to draw the distinction between adding complexity at the CALayer level and simply adding a new UIView to achieve the same effect. Usually the delineation between views and layers is quite obvious, often because some property of a view is required for the functionality of the app (for instance, a UILabel or UIButton is required), but when we create rich UIs with lots of subtle graphics it can become all too easy to increase the complexity of the layer hierarchy. In general, this should be avoided where possible because layers can only be managed in code, not in storyboards, and the logic for managing the layer hierarchy can become quite unwieldy.

For the purposes of this tutorial we will add a single CAGradientLayer as a sublayer on the view’s layer property. This gives a one-to-one mapping between views and layers, and nicely encapsulates each gradient layer inside a UIView so it can be laid out in a storyboard.

Defining the View Subclass

The core of this tutorial is a gradient view called LDGradientView, which is a subclass of UIView and is defined as follows:-

@IBDesignable
class LDGradientView: UIView {

    // ...

}

The class is marked as @IBDesignable which means it can be previewed in Interface Builder (Xcode’s storyboard editor).

The gradient itself is defined as a private property of the class:-

    // the gradient layer
    private var gradient: CAGradientLayer?

This property is created by the function below, which sets the gradient’s frame property to the view’s bounds, thereby taking up the entire view. This is in keeping with the one-to-one mapping between view and layer.

// create gradient layer
    private func createGradient() -> CAGradientLayer {
        let gradient = CAGradientLayer()
        gradient.frame = self.bounds
        return gradient
    }

Then it is added as a subview of the view’s layer as shown:-

    // Create a gradient and install it on the layer
    private func installGradient() {
        // if there's already a gradient installed on the layer, remove it
        if let gradient = self.gradient {
            gradient.removeFromSuperlayer()
        }
        let gradient = createGradient()
        self.layer.addSublayer(gradient)
        self.gradient = gradient
    }

These are both private functions because a view’s layer hierarchy should be its own business.

If you are installing the gradient view in a complex hierarchy, or any superview that uses constraints, then every time the frame is set the view must update itself. You can do that by adding these methods:-


    override var frame: CGRect {
        didSet {
            updateGradient()
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        // this is crucial when constraints are used in superviews
        updateGradient()
    }

    // Update an existing gradient
    private func updateGradient() {
        if let gradient = self.gradient {
            let startColor = self.startColor ?? UIColor.clear
            let endColor = self.endColor ?? UIColor.clear
            gradient.colors = [startColor.cgColor, endColor.cgColor]
            let (start, end) = gradientPointsForAngle(self.angle)
            gradient.startPoint = start
            gradient.endPoint = end
            gradient.frame = self.bounds
        }
    }

Finally, we also need some way of instantiating the view and calling the installGradient function, which we do from one of two initializers, the first to initialize from Interface Builder and the second for programmatic instantiation:-

    // initializers
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        installGradient()
    }

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

Defining a Gradient

Now we have a UIView subclass that can install a CAGradientLayer, but that doesn’t achieve a whole lot. Let’s make the gradient view work for us…

There are two main properties of CAGradientLayer that our custom view will be manipulating. These are:-

  • The gradient’s colours
  • The gradient’s direction

Defining the Colours

The colours are defined as a property on CAGradientLayer:-

var colors: [Any]?
// An array of CGColorRef objects defining the color of each gradient stop. Animatable.

A Note on Gradient Stops

The points at which the colour changes in a gradient are called gradient stops. Gradients do support fairly complex behaviour, and can have unlimited stops. Programming this behaviour is straightforward. Creating an @IBInspectable interface for it, however, is much more challenging.

It would be relatively trivial to add another gradient stop or two, but solving the general problem of an arbitrary number of stops is more difficult and the solution would likely be less usable than doing the same job directly in code.

For that reason, this project deals only with “simple” gradients: ones that start with a colour at one edge of the view and fade to another colour at the opposite edge.

So our implementation of the colour stops is simply:-

    // the gradient start colour
    @IBInspectable var startColor: UIColor?

    // the gradient end colour
    @IBInspectable var endColor: UIColor?

These are surfaced in Interface Builder as nice colour controls.

Defining the Direction

The gradient’s direction is defined by two properties on CAGradientLayer:-

var endPoint: CGPoint
The end point of the gradient when drawn in the layer’s coordinate space. Animatable.
var startPoint: CGPoint
The start point of the gradient when drawn in the layer’s coordinate space. Animatable.

A gradient’s start and end points are defined in the unit gradient space, which simply means that whatever the dimensions of a given CAGradientLayer, in the unit gradient space we consider the top left to be position (0, 0) and the bottom right to be position (1, 1), as illustrated below.

The Core Animation coordinate system
The CAGradientLayer coordinate system

The direction is the most challenging part of making a gradient @IBDesignable. Because of the need for a start and end point, the fact that @IBInspectable attributes don’t support the CGPoint data type, not to mention the complete lack of data validation in the UI, our options are a bit limited.

When trying to work out the simplest way to define common gradient directions, a string seemed a potentially useful data type and it seemed that compass points, e.g. “N”, “S”, “E”, “W” might be useful. But for intermediate directions should we support “NW”? What about “NNW” or “WNW”? And beyond that? That would immediately get confusing. And this way of thinking was clearly a long way round to realising that the best way to describe any angle on the compass was using degrees!

The user can forget about unit gradient spaces and all the complexity is reduced to a single property exposed to Interface Builder:-

    // the gradient angle, in degrees anticlockwise from 0 (east/right)
    @IBInspectable var angle: CGFloat = 270

Its default value (270 degrees) points south simply to match the CAGradientLayer default direction. For a horizontal gradient, set it to 0 or 180.

Converting the Angle to Gradient Space

This is the hardest bit. I’m including the code and a description of how it works, but of course you can skip this if you’re just interested in using the class.

The top-level function to convert the angle to start and end points gradient space looks like this:-

    // create vector pointing in direction of angle
    private func gradientPointsForAngle(_ angle: CGFloat) -> (CGPoint, CGPoint) {
        // get vector start and end points
        let end = pointForAngle(angle)
        let start = oppositePoint(end)
        // convert to gradient space
        let p0 = transformToGradientSpace(start)
        let p1 = transformToGradientSpace(end)
        return (p0, p1)
    }

This simply takes the angle that the user specified and uses it to create a vector pointing in that direction, as illustrated below. The angle specifies the rotation of the vector from 0 degrees, which by convention points east in Core Animation, and increases anti-clockwise (counter-clockwise).

Gradient Direction Vector in Unit Circle

The end point is found by calling pointForAngle(), defined thus:-

    private func pointForAngle(_ angle: CGFloat) -> CGPoint {
        // convert degrees to radians
        let radians = angle * .pi / 180.0
        var x = cos(radians)
        var y = sin(radians)
        // (x,y) is in terms unit circle. Extrapolate to unit square to get full vector length
        if (fabs(x) > fabs(y)) {
            // extrapolate x to unit length
            x = x > 0 ? 1 : -1
            y = x * tan(radians)
        } else {
            // extrapolate y to unit length
            y = y > 0 ? 1 : -1
            x = y / tan(radians)
        }
        return CGPoint(x: x, y: y)
    }

This function looks more complicated than it is: at its core is simply takes the sine and cosine of the angle to determine the end point on a unit circle. Because Swift’s trigonometry functions (in common with most other languages) require angles to be specified in radians rather than degrees, then we have to do that conversion first. Then the x value is calculated by x = cos(radians), and the y value by y = sin(radians).

The rest of the function is concerned with the fact that the resulting point is on the unit circle. The points we need, however, are in a unit square. Angles along the compass points (i.e. 0, 90, 180 and 270 degrees) will yield the correct result, at the edge of the square, but for intermediate angles the point will be inset from the edge of the square, so the vector must be extrapolated to the edge of the square to give the correct visual appearance. This is illustrated below.

Extrapolated Gradient Vector

Now we have the end point in a signed unit square, the start point of the vector is found by the simple function below. Because the point is in a signed unit space, it is trivial to find the start point by simply reversing the sign of the components of the end point.

    private func oppositePoint(_ point: CGPoint) -> CGPoint {
        return CGPoint(x: -point.x, y: -point.y)
    }

Note that another way to achieve this would have been to add 180 degrees to the original angle and call pointForAngle() again, but the sign reversal method is so simple that it is slightly more efficient to do it that way.

Now we have have the start and end points in the signed unit space, all that remains is to translate them to the unsigned gradient space. Note that the signed space has a y axis that increases northwards, where as in the Core Animation space y increases southwards, so the y component must be flipped as part of this translation. The location (0, 0) in our signed unit space becomes (0.5, 0.5) in the gradient space. The function is very straightforward:-

    private func transformToGradientSpace(_ point: CGPoint) -> CGPoint {
        // input point is in signed unit space: (-1,-1) to (1,1)
        // convert to gradient space: (0,0) to (1,1), with flipped Y axis
        return CGPoint(x: (point.x + 1) * 0.5, y: 1.0 - (point.y + 1) * 0.5)
    }

Phew!

And that is all the hard work done – phew! Congratulations for getting this far – go get yourself a coffee to celebrate…



Interface Builder Support

All that remains of the gradient view class is the prepareForInterfaceBuilder() function. This function is only run from with Interface Builder when it needs to render a view. A properly-designed @IBDesignable view can actually work quite well without it, but there will be times – for instance when adding a new view to a storyboard – when it will not render properly until this function is present. You can force it to run by selecting the view in the storyboard and choosing Editor|Debug Selected Views from the menu.

Our implementation of the function simply makes sure the gradient is installed and updated.

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        installGradient()
        updateGradient()
    }

Lee

A veteran programmer, evolved from the primordial soup of 1980s 8-bit game development. I started coding in hex because I couldn't afford an assembler. Later, my software helped drill the Channel Tunnel, and I worked on some of the earliest digital mobile phones. I was making mobile apps before they were a thing, and I still am.

4 Responses

  1. Hello, for some reason this code doesn’t cover the whole UIView for me. I’m doing the following:
    – Adding a UIView in the .storyboard to the ViewController.
    – UIView – Aspect Fill, adding constrains to 0 for left, right, top, bottom, aligning it horizontally in container.
    – In Custom class for UIView, I’m adding LDGradientView, so that I can change the Gradient View variables in the Attributes Inspector.

    It all looks perfect in the .storyboard, but when I test on the device, the ‘gradient rectangle’ is smaller than the device area, if it’s iPad.
    I fact, it seems that the ‘gradient rectangle’ has some fixed size that I can’t control.

    • Hi Anton,

      Thanks very much for your question. I realise I have changed the code slightly since first publishing this article. The problem you’re seeing is because layoutSubviews and/or setFrame are not overridden in the LDGradientView class. That can be fixed by adding the following code to the class:-

      
          override var frame: CGRect {
              didSet {
                  updateGradient()
              }
          }
      
          override func layoutSubviews() {
              super.layoutSubviews()
              // this is crucial when constraints are used in superviews
              updateGradient()
          }
      

      Hope this helps! I have updated the article to include these changes also.

      Regards,
      Lee

    • Hi Kiprop,

      Thanks for the feedback! Sorry to hear that you’re having trouble.

      It may be something to do with your view and constraints layout. Sometimes, for complex constraints, layoutSubviews() gets called too early, before the constraints have fully resolved. Normally in this case you’d see the frame set to some suspicious dimensions like a square 600×600 or something. This means the constraints are coming from the storyboard default value.

      If you set a breakpoint in layoutSubviews(), does it it get hit multiple times? If it does then the constraints are still being calculated and you have to do something hackish to determine when the frame is finally correct.

      I have an answer on StackOverflow that addresses this problem.

      Let me know if that helps or not.

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment