AsyncDisplayKit Tutorial: Node Hierarchies
This intermediate level AsyncDisplayKit tutorial will explain how you can make full use of the framework by exploring AsyncDisplayKit node hierarchies. By René Cacheaux.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
AsyncDisplayKit Tutorial: Node Hierarchies
25 mins
AsyncDisplayKit is an iOS framework that was originally designed for Facebook’s Paper. It makes it possible to achieve smoother and more responsive UI behavior than you can get with standard views.
You may have learned a bit about AsyncDisplayKit already in our beginning AsyncDisplayKit tutorial or your own studies; this tutorial will take your knowledge to the next level.
This tutorial will explain how you can make full use of the framework by exploring AsyncDisplayKit node hierarchies. By doing this, you get the benefits of smooth scrolling that AsyncDisplayKit is known for, at the same time as being able to build flexible and reusable UIs.
One of the key concepts of AsyncDisplayKit is the node
. As you’ll learn, AsyncDisplayKit nodes are a thread-safe abstraction layer over UIView
, which is (as you know) not thread safe. You can learn more about AsyncDisplayKit in AsyncDisplayKit’s Quick Start introduction.
The good news is that if you already know UIKit, then you’ll find that you already know the methods and properties in AsyncDisplayKit, because the APIs are almost identical.
By following along, you’ll learn:
- How to build your own
ASDisplayNode
subclass. - How subclassing allows you to encapsulate node hierarchies into a single container node for organization and reuse.
- How using node hierarchies can be superior to view hierarchies, because you automatically reduce the chance of stalling the main thread, keeping your user interface smooth and responsive.
Here’s how you’ll do it: You’ll build a container node that will hold two subnodes — one for the image and one for the title. You’ll see how containers measure their own size and how they lay out their subnodes. By the end, you’ll take your existing UIView
container subclasses and convert them over to ASDisplayNode
subclasses.
This is what you’re aiming towards:
Cool stuff right? The smoother the UI, the better it is for all. With that said, it’s time to dive in!
Note: This is an intermediate tutorial tailored for engineers who have already dabbled a bit with AsyncDisplayKit and are familiar with the basics. If this is your first time using AsyncDisplayKit, first read through our beginning AsyncDisplayKit tutorial and check out AsyncDisplayKit’s Getting Started guide.
Getting Started
The app you’ll build presents a card that shows one of the wonders of the world, the Taj Mahal.
Download and open the starter project.
The project is a basic app with one view controller. Time to get acquainted with it!
The project uses CocoaPods to pull in AsyncDisplayKit. So, in usual CocoaPods style, go ahead and open Wonders.xcworkspace but NOT Wonders.xcodeproj.
Note: If you’re not familiar with CocoaPods, then that’s OK. But if you want to learn more about it then check out this Introduction to CocoaPods Tutorial.
Open ViewController.swift and take notice of the view controller’s constant property named card. It holds a data model value for the Taj Mahal, and you’ll use this model later to create a card node to display this wondrous structure to the user.
Build and run to make sure you have a working project. You should see an empty black screen — the digital equivalent of a blank canvas.
Creating and Displaying a Container Node
Now you’re going to build your very first node hierarchy. It’s very similar to building a UIView
hierarchy, which I’m sure you’re familiar with :].
Open Wonders-Bridging-Header.h, and add the following import statement:
#import <AsyncDisplayKit/ASDisplayNode+Subclasses.h>
ASDisplayNode+Subclasses.h exposes methods that are internal to ASDisplayNode
. You need to import this header so you can override methods in ASDisplayNode
subclasses, but it’s important to note that you should only call these methods within your ASDisplayNode
subclasses. This is a similar pattern to UIGestureRecognizer
which also has a header purely for subclasses.
Open CardNode.swift and add the following ASDisplayNode
subclass implementation:
class CardNode: ASDisplayNode {}
This declares a new ASDisplayNode
subclass that you’ll use as a container to hold the card’s user interface.
Open ViewController.swift and implement viewDidLoad()
:
override func viewDidLoad() {
super.viewDidLoad()
// Create, configure, and lay out container node
let cardNode = CardNode()
cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
let origin = CGPointZero
let size = CGSize(width: 100, height: 100)
cardNode.frame = CGRect(origin: origin, size: size)
// Create container node’s view and add to view hierarchy
view.addSubview(cardNode.view)
}
This code creates a new card node with a hard-coded size. It will sit on the upper-left corner and will have a width and height of 100.
Don’t worry about the odd alignment at this pass. You’ll center the card nicely within the view controller very soon!
Build and run.
Great! You have a custom node subclass that shows up on the screen. The next step is to give your node subclass, named CardNode
, the ability to calculate its own size. This is required to be able to center it in the view. Before doing that, you should understand how the node layout engine works.
Node Layout Engine
The next task is to ask a node to calculate its own size by calling measure(constrainedSize:)
on the node.
You’ll pass the constrainedSize
argument into the method to tell the node to calculate a size that fits within constrainedSize
.
In layman’s terms, this means the calculated size can be no larger than the constrained size provided.
For example, consider the following diagram:
This shows a constrained size with a certain width and height. The calculated size is equal in width, but smaller in height. It could have been equal on both width and height, or smaller on both width and height. But neither the width nor the height are allowed to be greater than the constrained size.
This works similarly to UIView’s sizeThatFits(size:)
. But the difference is that measure(constrainedSize:)
holds on to the size it calculates, allowing you to access the cached value via the node’s calculatedSize
property.
An example of when the calculated size is smaller in width and height than the constrained size is as follows:
Here the image’s size smaller than the constrained size, and without any sizing-to-fit logic, the calculated size is smaller than the constrained size.
The reason AsyncDisplayKit incorporates sizing into its API is because often, it may take a perceivable amount of time to calculate a size. Reading an image from disk to calculate the size can be very slow for example. By incorporating sizing into the node API, which remember is thread safe, means that sizing can all be performed on a background thread! Neat! It’s a sweet little feature that makes the UI smooth as butter, and the user has less of those awkward moments where he wonders if his phone broke.
A node will run size calculations if it has not already done so and has no cached value, or if the constrained size provided is different than the constrained size used to determine the cached calculated size.
In programmers’ terms, it works like this:
-
measure(constrainedSize:)
either returns a cached size or runs a size calculation by callingcalculateSizeThatFits(constrainedSize:)
. - You place all the size calculation logic inside of
calculateSizeThatFits(constrainedSize:)
within yourASDisplayNode
subclass.
calculateSizeThatFits(constrainedSize:)
is internal to ASDisplayNode
, and you shouldn’t call it outside of your subclass.
Measuring a Node’s Size
Now that you understand the method to the madness here, it’s time to apply it and measure some node sizes for yourself.
Open CardNode.swift and replace the class there with the following:
class CardNode: ASDisplayNode {
override func calculateSizeThatFits(constrainedSize: CGSize) -> CGSize {
return CGSize(width: constrainedSize.width * 0.2, height: constrainedSize.height * 0.2)
}
}
For now, this method returns a size that is 20 percent of the constrained size provided, hence, it takes up just 4 percent of the available area.
Open ViewController.swift, delete the viewDidLoad()
implementation, and implement the following createCardNode(containerRect:)
method:
/* Delete this method
override func viewDidLoad() {
super.viewDidLoad()
// 1
let cardNode = CardNode()
cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
let origin = CGPointZero
let size = CGSize(width: 100, height: 100)
cardNode.frame = CGRect(origin: origin, size: size)
// 2
view.addSubview(cardNode.view)
}
*/
func createCardNode(#containerRect: CGRect) -> CardNode {
// 3
let cardNode = CardNode()
cardNode.backgroundColor = UIColor(white: 1.0, alpha: 0.27)
cardNode.measure(containerRect.size)
// 4
let size = cardNode.calculatedSize
let origin = containerRect.originForCenteredRectWithSize(size)
cardNode.frame = CGRect(origin: origin, size: size)
return cardNode
}
Here’s a section-by-section breakdown:
- Delete the old way of creating, configuring, and laying out container node.
- Delete the old way of creating the container node’s view and adding it to the view hierarchy
-
createCardNode(containerRect:)
creates a new card node with the same background color as the old container node, and it uses a provided container rect to constrain the size of the card node, so the card node cannot be any larger thancontainerRect
’s size. - Centers the card within the
containerRect
using theoriginForCenteredRectWithSize(size:)
helper method. Note that the helper method is a custom method provided in the starter project that was added toCGRect
instances via an extension.
Right below the createCardNode(containerRect:)
method, re-implement viewDidLoad()
:
override func viewDidLoad() {
super.viewDidLoad()
let cardNode = createCardNode(containerRect: UIScreen.mainScreen().bounds)
view.addSubview(cardNode.view)
}
When the view controller’s view loads, createCardNode(containerRect:)
creates and sets up a new CardNode
. The card node cannot be any larger than the main screen’s bounds size.
At this point in its lifecycle, the view controller’s view has not been laid out. Therefore, it’s not safe to use the view controller’s view’s bounds size, so you’re using the main screen’s bounds size to constrain the size of the card node.
This approach, albeit less than elegant, works for this view controller because it spans the entire screen. Later in this tutorial, you’ll move this logic to a more appropriate method, but for now, it works, so roll with it!
Build and run, and you’ll see your node properly centered.
Asynchronous Node Setup and Layout
Sometimes it takes a human being a perceivable amount of time to lay out complex hierarchies, if that is happening on the main thread. This blocks UI interaction. You can’t have any perceivable wait times if you expect to please the user.
For this reason, you’ll create, set up and lay out nodes in the background so that you can avoid blocking the main UI thread.
Implement addCardViewAsynchronously(containerRect:)
in between createCardNode(containerRect:)
and viewDidLoad()
:
func addCardViewAsynchronously(#containerRect: CGRect) {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) {
let cardNode = self.createCardNode(containerRect: containerRect)
dispatch_async(dispatch_get_main_queue()) {
self.view.addSubview(cardNode.view)
}
}
}
addCardViewAsynchronously(containerRect:)
creates the CardNode
on a background queue — which is fine because nodes are thread safe! After creating, configuring and framing the node, execution returns to the main queue in order to add the node’s view to the view controller’s view hierarchy — after all, UIKit isn’t thread safe. :]
Note: Once you create the node’s view, all access to the node occurs exclusively on the main thread.
Re-implement viewDidLoad()
by using addCardViewAsynchronously(containerRect:)
:
override func viewDidLoad() {
super.viewDidLoad()
addCardViewAsynchronously(containerRect: UIScreen.mainScreen().bounds)
}
No more blocking the main thread, ensuring the user interface remains responsive!
Build and run. Same as before, but all the sizing of your node is now being done on a background thread! Neat! :]
Constraining the Node Size to View Controller’s View Size
Remember I said that you’d use a more elegant solution to size the node than just relying on the screen size? Well, I’m delivering on that promise right now!
Open ViewController.swift. Add the following property at the top of the class:
var cardViewSetupStarted = false
Then replace viewDidLoad()
with viewWillLayoutSubviews()
:
/* Delete this method
override func viewDidLoad() {
super.viewDidLoad()
addCardViewAsynchronously(containerRect: UIScreen.mainScreen().bounds)
}
*/
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !cardViewSetupStarted {
addCardViewAsynchronously(containerRect: view.bounds)
cardViewSetupStarted = true
}
}
Instead of using the main screen’s bounds size, the logic above uses the view controller’s view’s bounds size to constrain the size of the card node.
Now it’s safe to use the view controller’s views’ bound size since the logic is inside viewWillLayoutSubviews()
instead of viewDidLoad()
. By this time in its lifecycle, the view controller’s view will already have its size set.
This approach is superior because a view controller’s view can be any size, and you don’t want to depend on the fact that this view controller happens to span the entire screen.
The view can be laid out multiple times. So viewWillLayoutSubviews()
can be called multiple times. You only want to create the card node once, and that’s why you need the cardViewSetupStarted
flag to prevent the view controller from creating the card node multiple times.
Build and run.
The Node Hierarchy
Currently you have an empty container card node on screen. Now you want to display some content. The way to do this is to add subnodes to the card node. The following diagram describes the simple node hierarchy you’ll build.
The process of adding a subnode will look very familiar since the process is similar to how you add subviews within custom UIView
subclasses.
The first step is to add the image node, but first, you should know how container nodes lay out their subnodes.
Subnode Layout
You now know how to measure the container node’s size and how to use that calculated size to lay out the container node’s view. That takes care of the container, but how does the container node lay out its subnodes?
It’s a two-step process:
- First, you measure each subnode’s size in
calculateSizeThatFits(constrainedSize:)
. This ensures that each subnode caches a calculated size. - During UIKit’s layout pass on the main thread, AsyncDisplayKit will call
layout()
on your customASDisplayNode
subclass.layout()
works just like UIView’slayoutSubviews()
, except thatlayout()
doesn’t have to calculate the sizes of all of its children.layout()
simply queries each subnode’s calculated size.
Back to the UI. The Taj Mahal’s card size should equal the size of its image, and the title should then fit within that size. The easiest way to accomplish this is to measure the Taj Mahal image node’s size and use the result to constrain the title text node’s size, so that the text node fits within the size of the image.
And that is the logic you’ll use to lay out the card’s subnodes. Now you’re going to make it happen in code. :]
Adding a Subnode
Open CardNode.swift and add the following code to CardNode
above calculateSizeThatFits(constrainedSize:)
:
// 1
let imageNode: ASImageNode
// 2
init(card: Card) {
imageNode = ASImageNode()
super.init()
setUpSubnodesWithCard(card)
buildSubnodeHierarchy()
}
// 3
func setUpSubnodesWithCard(card: Card) {
// Set up image node
imageNode.image = card.image
}
// 4
func buildSubnodeHierarchy() {
addSubnode(imageNode)
}
Here’s what that does:
- Image node property: This line adds a property to hold a reference to the card’s image subnode.
- Designated initializer: This designated initializer takes a card model object that holds the card’s image and title.
- Subnode setup: This method uses the card model object that existed in the starter project to setup subnodes.
- Container’s hierarchy: You can compose node hierarchies just like you can compose view hierarchies. This method builds the card node’s hierarchy by adding all the subnodes to itself.
Next, re-implement calculateSizeThatFits(constrainedSize:)
:
override func calculateSizeThatFits(constrainedSize: CGSize) -> CGSize {
// 1
imageNode.measure(constrainedSize)
// 2
let cardSize = imageNode.calculatedSize
// 3
return cardSize
}
Here’s what that code does:
- The size of the card should match the size of the background image. This line measures the size of the background image fitting inside the constrained size. All of the node subclasses that ship with AsyncDisplayKit know how to size themselves, including
ASImageNode
which is used here. - This line temporarily stores the
imageNode
’s calculated size, which is also the size of the entire card node. Specifically, it uses the image node’s measured size as the card node size to constrain subnodes. You’ll use this value when adding more subnodes. - The last line returns the card node’s calculated size that fits within the constrained size provided.
Next, override layout()
:
override func layout() {
imageNode.frame =
CGRect(origin: CGPointZero, size: imageNode.calculatedSize).integerRect
}
This logic positions the image in the upper-left corner, aka zero origin, of the card node. It also makes sure that the image node’s frame doesn’t have any fractional values, so that you avoid pixel boundary display issues.
Take note of how this method uses the image node’s cached calculated size during layout.
Since the size of this image node determines the size of the card node, the image will span the entire card.
Go back to ViewController.swift, and inside createCardNode(containerRect:)
, replace the line that initializes CardNode
with:
let cardNode = CardNode(card: card)
This line uses the new initializer you added to CardNode
. The card value that passes into the initializer is simply a constant property on ViewController
that stores the Taj Mahal card model.
Build and run. Boom! Huzzah! :]
Awesome. You’ve successfully created a container node that presents a node hierarchy! 👊🎉 Sure, it’s a simple one, but it’s a node hierarchy!
Adding More Subnodes
Hey, where are you going? You’re not done yet! Just how do you expect the user to know what he’s looking at without a title? Nevermind, don’t answer that; we’re moving on now.
You need at least one more subnode to hold the title.
Open CardNode.swift and add the following titleTextNode
property to the class:
let titleTextNode: ASTextNode
Initialize the titleTextNode
property inside init(card:)
above super.init()
:
titleTextNode = ASTextNode()
Add the following line to setUpSubnodesWithCard(card:)
:
titleTextNode.attributedString = NSAttributedString.attributedStringForTitleText(card.name)
This line gives the text node an attributed string that holds the card’s title. attributedStringForTitleText(text:)
is a helper method that was added to NSAttributedString
via extension. It existed in the starter project, and it creates the attributed string with the provided title and with the text styling appropriate for this app’s card titles.
Next, add the following at the end of buildSubnodeHierarchy()
:
addSubnode(titleTextNode)
Make sure it goes below the line adding the image node, otherwise the image would be on top of the title!
And inside calculateSizeThatFits(constrainedSize:)
, add the following right above the return statement:
titleTextNode.measure(cardSize)
This measures the rest of the subnodes by using this card’s size as the constrained size.
Add the following to layout()
:
titleTextNode.frame =
FrameCalculator.titleFrameForSize(titleTextNode.calculatedSize, containerFrame: imageNode.frame)
This line calculates the title text node’s frame with the help of FrameCalculator
, a custom class included in the starter project. FrameCalculator
hides frame calculation to keep things simple.
Build and run. Now there will be no questions about the Taj Mahal.
And that’s…how you do it! That’s a full node hierarchy.
You’ve built a node hierarchy that uses a container with two sub-nodes.
Where To Go From Here?
If you’d like to check out the final project, you can download it here.
To learn more about AsyncDisplayKit, check out the getting started guide and the sample projects in the AsyncDisplayKit Github repo.
The library is entirely open source, so if you’re wondering how something works you can delve into the minutia for yourself!
Now that you’ve got this foundation, you can compose node hierarchies into well organized and reusable containers that make your code easier to read and understand. To sweeten the deal further, you’re ensuring a smoother and more responsive UI by using nodes which perform a lot of work in the background that UIKit can only dream of doing.
If you have any questions, comments or sweet discoveries, please don’t hesitate to jump into the forum discussion below!