Layout Libraries for iOS. UIKit is Not The Only One

Photo of Piotr Sochalewski

Piotr Sochalewski

Updated Feb 14, 2023 • 7 min read
design
UIKit is an obvious choice when it comes to create layout in iOS. It's solid, stable and quite fast.
On the other hand it gets tricky, when you need to create advanced and fancy table or collection view. It's really hard to achieve 60 FPS, even on recent devices, when a user scrolls really fast. This blog post comes with a solution for this!

Texture (formerly AsyncDisplayKit) and LayoutKit are two third-party libraries which make creating lightweight and smooth UI easy. They both let you move image decoding, text sizing and rendering, layout, and other expensive UI operations off the main thread, to keep the main thread available to respond to user interaction. On the other hand, they let get rid of Auto Layout inside i.e. complicated view hierarchies in scrollable views, as them are not performant enough in most cases and achieve layout performance almost as good as when using manual layout.

As you can see these libraries are pretty much the same and let you help create high-performance layout. Let's see how it looks like in UIKit, Texture and LayoutKit.

Introduction

I'm going to create a collection view with 200 square cells (4 per row), where each of them shows an image view with the same picture. To make it a bit harder there is ImageDelayer which returns an UIImage after a main-thread delay between 0.0 and 0.01 second.

final class ImageDelayer {
    
    private let group = DispatchGroup()
    private let queue = DispatchQueue(label: "delayer", attributes: .concurrent)
    
    func getDelayedImage() -> UIImage {
        let timeout: TimeInterval = Double(arc4random_uniform(11)) / 1000
        
        group.enter()
        
        queue.asyncAfter(deadline: .now() + timeout) {
            self.group.leave()
        }
        
        _ = group.wait(timeout: .distantFuture)
        
        return UIImage(named: "apple")!
    }
}

Disclaimer: The code above is not efficient, but it is not its purpose. It is the shortest code than can simulate complicated computations which require the main thread to be executed on.

UIKit

This represents full Auto Layout and Storyboard usage. This makes a code as simple as this:

final class UIKitViewController: UICollectionViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let layout = collectionView!.collectionViewLayout as! UICollectionViewFlowLayout
        let size = floor(collectionView!.frame.width / 4)
        layout.itemSize = CGSize(width: size, height: size)
        layout.minimumInteritemSpacing = 0
        layout.minimumLineSpacing = 0
    }
    
    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 200
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! UIKitCollectionViewCell
        cell.imageView.image = ImageDelayer().getDelayedImage()
        return cell
    }
}

final class UIKitCollectionViewCell: UICollectionViewCell {
    
    @IBOutlet weak var imageView: UIImageView!
}

This is super-easy, but performance is far away from perfect 60 FPS. When scrolling it's between 10 and 60, with average around 40 (iPhone 8, iOS 12).

Texture

Formerly AsyncDisplayKit maintained by Facebook, today it's Texture as is maintained by TextureGroup/Pinterest. It's all about moving time-consuming UI operations to background threads, but in a smart way – so you won't see warnings from main thread checker. In addition to this, Texture uses frames instead of Auto Layout, as it's significantly faster.

It uses genericASViewController and more AS prefixed objects to make the transition. That's how it looks like in Texture:

final class TextureViewController: ASViewController, ASCollectionDataSource, ASCollectionDelegate {
    
    let flowLayout: UICollectionViewFlowLayout
    let collectionNode: ASCollectionNode
    
    init() {
        flowLayout = UICollectionViewFlowLayout()
        collectionNode = ASCollectionNode(collectionViewLayout: flowLayout)
        
        super.init(node: collectionNode)
        
        collectionNode.delegate = self
        collectionNode.dataSource = self
    }
    
    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let size = floor(collectionNode.frame.width / 4)
        flowLayout.itemSize = CGSize(width: size, height: size)
        flowLayout.minimumInteritemSpacing = 0
        flowLayout.minimumLineSpacing = 0
    }
    
    func numberOfSections(in collectionNode: ASCollectionNode) -> Int {
        return 1
    }
    
    func collectionNode(_ collectionNode: ASCollectionNode, numberOfItemsInSection section: Int) -> Int {
        return 200
    }
    
    func collectionNode(_ collectionNode: ASCollectionNode, nodeBlockForItemAt indexPath: IndexPath) -> ASCellNodeBlock {
        return { () -> ASCellNode in
            let cellNode = TextureCollectionViewCell()
            cellNode.imageView.image = ImageDelayer().getDelayedImage()
            return cellNode
        }
    }
}

final class TextureCollectionViewCell: ASCellNode {
    
    let imageView = ASImageNode()
    
    override init() {
        super.init()
        imageView.contentMode = .scaleAspectFill
        addSubnode(imageView)
    }
    
    override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
        return ASWrapperLayoutSpec(layoutElement: imageView)
    }
}
It's pretty easy to read, even if it's your first time with Texture. Definitely the most interesting part is collectionNode(_:nodeBlockForItemAt:). It returns a closure which executes asynchronously and this makes Texture fast.

LayoutKit

LayoutKit syntax is approachable too. It doesn't force you to use custom types to represent i.e. view controllers, which makes it more universal, at least for me.

Instead it uses layout adapters and size layouts as you can see below.

final class LayoutKitViewController: UICollectionViewController {
    
    private var reloadableViewLayoutAdapter: ReloadableViewLayoutAdapter!
    private var cachedItems: [Layout]?
    
    convenience init() {
        self.init(collectionViewLayout: UICollectionViewFlowLayout())
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView?.backgroundColor = .white
        let layout = collectionView!.collectionViewLayout as! UICollectionViewFlowLayout
        layout.minimumInteritemSpacing = 0
        layout.minimumLineSpacing = 0
        
        reloadableViewLayoutAdapter = ReloadableViewLayoutAdapter(reloadableView: collectionView!)
        collectionView?.delegate = reloadableViewLayoutAdapter
        collectionView?.dataSource = reloadableViewLayoutAdapter
        
        layoutFeed(width: collectionView!.frame.width, synchronous: false)
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        layoutFeed(width: size.width, synchronous: true)
    }
    
    private func layoutFeed(width: CGFloat, synchronous: Bool) {
        let size = floor(collectionView!.frame.width / 4)
        reloadableViewLayoutAdapter.reload(synchronous: synchronous) { [weak self] in
            return [Section(header: nil, items: self?.getItems(size: size) ?? [], footer: nil)]
        }
    }
    
    private func getItems(size: CGFloat) -> [Layout] {
        if let cachedItems = cachedItems {
            return cachedItems
        }
        
        let cell = LayoutKitCollectionViewCellLayout(image: ImageDelayer().getDelayedImage(),
                                                     size: CGSize(width: size, height: size))
        let items = [Layout](repeating: cell, count: 200)
        cachedItems = items
        return items
    }
}

final class LayoutKitCollectionViewCellLayout: SizeLayout {
    init(image: UIImage, size: CGSize) {
        let imageView = SizeLayout(size: size, viewReuseId: "imageView") {
            $0.image = image
            $0.contentMode = .scaleAspectFill
            $0.clipsToBounds = true
        }
        super.init(alignment: .fill,
                   viewReuseId: "cell",
                   sublayout: imageView)
    }
}

LayoutKit, like Texture, not only computes layouts in the background. It also gets rid of Auto Layout and uses frame-based system, similar to creating it on your own, when it comes to performance, but much easier to maintain, especially with different devices.

Results

As you can see above both Texture and LayoutKit are significantly faster than UIKit. The demo app uses WatchdogInspector to show the current FPS at the top. iPhone 8 with iOS 12 gets around 30-40 FPS with UIKit when scrolling, but solid 60 FPS with Texture and LayoutKit.

You should know that Texture and LayoutKit are not only two alternative choices to make layout. There are also solutions like Facebook's Yoga (and its nice Swift interface called FlexLayout), PinLayout, and a bit different one – Schibsted's Layout, which depends on XML files and allows live reloading.

You can take a look at the most comprehensive benchmark the performance of various Swift layout frameworks here. The results are clear – a stack view is the slowest, Auto Layout is in the middle, and different methods writing layout from code are much faster, but there is no significant difference between them. It's up to you if you prefer LayoutKit, Texture, PinLayout or Yoga. But you should in keep mind that them let you get the highest UI performance when you need it.

Tags

Photo of Piotr Sochalewski

More posts by this author

Piotr Sochalewski

Piotr's programming journey started around 2003 with simple Delphi/Pascal apps. He has loved it...
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business