CALayer and Auto Layout With Swift

Recently, I had to work with CALayer to make some effects in an iOS application. Unfortunately, I had some problems with the auto layout and I had to find a workaround. In this article, I propose some approaches I tried. You can find the best one at the end of this article.

This article is not supposed to be a guide for CALayer, just an explanation of a specific scenario: a workaround for the missing auto layout in sublayers.

Happy reading!

What Is a CALayer?

We can consider CALayer a graphic context of any UIView object where we can add corners radius, borders, shadows and so on. Then, we can also apply animations to some layer properties to get nice effects—like a corner radius animation when we highlight a button.

Core animation provides several layer types by default, the main ones are:

What About Auto Layout?

As we saw previously, a layer is a context of an UIView object. It means that any UIView object has a main layer which we can use to change its corner radius, border and so on. We don’t need to set any constraint to this main layer, since it fills automatically its view—we cannot change the frame of this layer manually since it will always fill its view.

At this point, you may be wondering: why should we bother about constraints if the main layer doesn’t need auto layout? Well, let’s consider that we want to use a sublayer in our view to add an additional text, shape or gradient with a specific frame. Unfortunately, iOS doesn’t allow the use of constraints for sublayers. This means that we need a workaround to replace the missing auto layout.

We can use 3 approaches to achieve our goals. To simplify our code, we use a gradient layer which fills the parent view.

Please note that we can add a sublayer with whatever frame we want. We have just to set the CALayer property frame. In this example, the sublayer fills its parent view to keep the example easy to understand.

Update in viewDidLayoutSubviews/layoutSubviews

In this approach, we add the new gradient layer and set its frame to the parent view bounds. Then, to keep the frame updated, we must use the callback which says that the view layout has been updated. If we are inside an UIViewController we can use viewDidLayoutSubviews, otherwise layoutSubviews inside UIView.

In the following example, we use the implementation inside an UIViewController:

class ViewController: UIViewController {
 
    let gradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.colors = [
            UIColor.red.cgColor,
            UIColor.green.cgColor
        ]
        return layer
    }()
 
    override func viewDidLoad() {
        super.viewDidLoad()
 
        view.layer.addSublayer(gradientLayer)
        gradientLayer.frame = view.bounds
    }
 
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
 
        gradientLayer.frame = view.bounds
    }
}

Update With KVO

The second approach is using KVO to observe the parent view bounds. When bounds changes, we manually update the layer frame to fill its parent view:

class ViewController: UIViewController {
 
    let gradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.colors = [
            UIColor.yellow.cgColor,
            UIColor.brown.cgColor
        ]
        return layer
    }()
 
    override func viewDidLoad() {
        super.viewDidLoad()
 
        view.layer.addSublayer(gradientLayer)
        gradientLayer.frame = view.bounds
    }
 
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
 
        view.addObserver(self, forKeyPath: #keyPath(UIView.bounds), options: .new, context: nil)
    }
 
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
 
        view.removeObserver(self, forKeyPath: #keyPath(UIView.bounds))
    }
 
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if let objectView = object as? UIView,
            objectView === view,
            keyPath == #keyPath(UIView.bounds) {
            gradientLayer.frame = objectView.bounds
        }
    }
}

Remember to remove the observer when you use KVO.

Custom UIView

The last approach is using a custom UIView.

We know that the main layer of any UIView fills automatically its view, this means that, if we have a view with a specific frame, we are sure that we have also its main layer at that frame. We perfectly know that when we apply the constraints to a subview, the latter has the frame automatically updated to satisfy the constraints. At this point, we can deduce that, if we set proper constraints to a subview, we’ll have also its main layer at the specific frame since the layer is always in the same frame of its view.

The problem with this approach is that we must replace every sublayers with a custom UIView. On the other hand, in this way, we can take advantage of the constraints used for this custom view which will be applied automatically also to its main layer.

For this approach, we must create a custom UIView class:

class LayerContainerView: UIView

We said that the view has a main layer which is a CALayer by default. For our custom view we want a main layer of type CAGradientLayer, therefore we must override the layerClass property which says the type of the main layer:

override public class var layerClass: Swift.AnyClass {
    return CAGradientLayer.self
}

If we don’t override it, the main layer will always be CALayer without any way to change its type.

And, finally, we can set our gradient in awakeFromNib:

override func awakeFromNib() {
    super.awakeFromNib()
 
    guard let gradientLayer = self.layer as? CAGradientLayer else { return }
    gradientLayer.colors = [
        UIColor.blue.cgColor,
        UIColor.cyan.cgColor
    ]
}
 

At the end, the class should be like this:


class LayerContainerView: UIView {
 
    override public class var layerClass: Swift.AnyClass {
        return CAGradientLayer.self
    }
 
    override func awakeFromNib() {
        super.awakeFromNib()
 
        guard let gradientLayer = self.layer as? CAGradientLayer else { return }
        gradientLayer.colors = [
            UIColor.blue.cgColor,
            UIColor.cyan.cgColor
        ]
    }
}

Final Comparison

At this point, we have three valid workarounds which we can use. The next step is testing the performance to understand what is the best approach. To compare them, we can create a sample project where we use all of them and check how they behave.

If you don’t want to test by yourself, you can watch the following video with the final result:


In this sample app, I added three sublayers and rotated the simulator—with slow animations enabled—to check how the layers behave when the parent view bounds changes.

Spoiler:

As seen in the video, the approach with the custom UIView performs better than the other ones. The reason is that we are relying on the auto layout applied to the view instead of updating the sublayer frame manually. Therefore, creating a custom UIView is an acceptable trade-off to obtain the best result so far.

Conclusion

We have just seen some workarounds for the auto layout and the best approach to use. Unfortunately, it’s just a workaround and not an official approach. This means that we may find an approach which may be better than the custom UIView. For this reason, if you have a better approach, feel free to leave a comment with a description of your solution. I would be more than happy to discuss alternatives. Thank you.

 

 

 

 

Top