“Art is anything you can do well. Anything you can do with Quality.”
—Robert M. Pirsig
AsyncDisplayKit is a UI framework that was originally born from Facebook’s Paper app. It came as an answer to one of the core questions the Paper team faced: how can you keep the main thread as clear as possible?
Nowadays, many apps have a user experience that relies heavily upon continuous gestures and physics based animations. At the very least, your UI is probably dependent on some form of scroll view.
These types of user interfaces depend entirely on the main thread and are extremely sensitive to main thread stalls. A clogged main thread means dropped frames and an unpleasant user experience.
Some of the big contributors to main thread work include:
- Measurement and Layout: Things like
-heightForRowAtIndexPath:
or calling-sizeThatFits
on aUILabel
as well as the exponential cost of AutoLayout‘s constraint solver. - Image Decoding: Using a
UIImage
in an image view means the image data needs to be decoded first. - Drawing: Intricate text as well as manually drawing gradients and shadows.
- Object Life Cycle: Creating, manipulating and destroying system objects (ie. creating a
UIView
).
When used correctly, AsyncDisplayKit allows you to perform all measurement, layout and rendering asynchronously by default. Without any extra optimization an app can experience roughly an order of magnitude reduction in the amount of work done on the main thread.
In addition to these performance wins, modern AsyncDisplayKit offers an impressive set of developer conveniences that allow implementing complex, sophisticated interfaces with a minimum of code.
In this two part AsyncDisplayKit 2.0 tutorial, you’ll learn all the essentials to build a useful and dynamic application with ASDK. In part one, you’ll learn some big picture ideas you can use when architecting an app. In part two, you’ll learn how to build your own node subclass as well as how to use ASDK’s powerful layout engine. In order to complete this tutorial you will need Xcode 7.3 and familiarity with Objective-C.
Disclaimer: ASDK is incompatible with both Interface Builder and AutoLayout, so you won’t be using them in this tutorial. Although ASDK fully supports Swift (a distinction from ComponentKit), many of its users are still writing Objective-C. At the moment, the majority of the top 100 free apps don’t include any Swift at all (at least 6 use ASDK). For these reasons, this series will focus on Objective-C. That being said, we’ve included a Swift version of the sample project in case you hate staples.
Getting Started
To begin, go ahead and download the starter project.
The project uses CocoaPods to pull in AsyncDisplayKit. So, in usual CocoaPods style, go ahead and open RainforestStarter.xcworkspace but NOT RainforestStarter.xcodeproj.
Note: A network connection is required to work through this tutorial.
Build and run to see an app consisting of one UITableView
containing a list of animals. If you look at the code in AnimalTableController
you’ll see that it’s a normal UITableViewController
class you’ve probably seen plenty of times.
Note: Make sure to run the code in this tutorial on a physical device instead of in the simulator.
Scroll through the animals and notice the number of frames that are being dropped. You don’t need to fire up Instruments to be able to see that this app needs some help in the performance department.
You can fix that, through the power of AsyncDisplayKit.
Introducing ASDisplayNode
ASDisplayNode
is the core class of ASDK and is, at its heart, just an MVC “view” object in the same way as a UIView
or CALayer
. The best way to think about a node is by thinking about the relationship between UIViews
and CALayers
that you should already be familiar with.
Remember that everything onscreen in an iOS app is represented via a CALayer
object. UIViews
create and own a backing CALayer
to which they add touch handling and other functionality. UIViews
themselves are not a CALayer
subclass. Instead, they wrap around a layer object, extending its functionality.
This abstraction is extended in the case of ASDisplayNode
: you can think of them as wrapping a view, just like a view wraps a layer.
What nodes bring to the table over a regular view is the fact that they can be created and configured on background queues and are concurrently rendered by default.
Luckily, the API for dealing with nodes should be incredibly familiar to anyone who’s used UIViews
or CALayers
. All the view properties you would normally use are available on the equivalent node class. You can even access the underlying view or layer itself — just as you can access the .layer
of a UIView
.
The Node Containers
While nodes themselves provide the possibility of vast performance improvements, the real magic happens when they’re used in conjunction with one of the four container classes.
These classes include:
- ASViewController: A
UIViewController
subclass that allows you to provide the node you want to be managed. -
ASCollectionNode and ASTableNode: Node equivalents to
UICollectionView
andUITableView
, a subclass of which is actually maintained under the hood. -
ASPagerNode: A subclass of
ASCollectionNode
which offers great swiping performance compared toUIKit
’sUIPageViewController
.
Fair enough, but the real magic comes from the ASRangeController
each of these classes uses to influence the behavior of the contained nodes. For now, just trust me and keep that in the back of your head for later.
Converting the TableView
The first thing you’ll do is to convert the current table view into a table node. Doing this is relatively straightforward.
Replacing tableView with tableNode
First, navigate to AnimalTableController.m. Add the following line below the other imports in this class:
#import <AsyncDisplayKit/AsyncDisplayKit.h> |
This imports ASDK in order to use the framework.
Then, go ahead and replace the following property declaration of tableView
:
@property (strong, nonatomic) UITableView *tableView; |
with the following tableNode
:
@property (strong, nonatomic) ASTableNode *tableNode; |
This will cause a lot of code in this class to break, but do not panic!
Seriously, don’t worry. These errors and warnings will serve as your guide in the task of converting what you currently have into what you really want.
The errors in -viewDidLoad
are, of course, to do with the fact that the tableView
doesn’t exist anymore. I’m not going to make you go through and change all the instances of tableView
to tableNode (I mean, find and replace isn’t that hard so feel free to) but if you did you’d see that:
-
You should be assigning an
ASTableNode
to the property. -
A table node doesn’t have a method called
-registerClass:forCellReuseIdentifier:
. - You can’t add a node as a subview.
At this point you should just replace -viewDidLoad
with the following:
- (void)viewDidLoad { [super viewDidLoad]; [self.view addSubnode:self.tableNode]; [self applyStyle]; } |
The interesting thing to note here is that you’re calling -addSubnode:
on a UIView
. This method has been added to all UIView
s via a category, and is exactly equivalent to:
[self.view addSubview:self.tableNode.view]; |
Next, fix -viewWillLayoutSubviews
by replacing that method definition with the following:
- (void)viewWillLayoutSubviews { [super viewWillLayoutSubviews]; self.tableNode.frame = self.view.bounds; } |
All this does is replace self.tableView
with self.tableNode
to set the table’s frame.
Next, find the -applyStyle
method and replace the implementation with the following:
- (void)applyStyle { self.view.backgroundColor = [UIColor blackColor]; self.tableNode.view.separatorStyle = UITableViewCellSeparatorStyleNone; } |
The line that sets the table’s separatorStyle
is the only line that changed. Notice how the table node’s view
property is accessed in order to set the table’s separatorStyle
. ASTableNode
does not expose all the properties of UITableView
, so you have to access the table node’s underlying UITableView
instance in order to change UITableView
specific properties.
Then, add the following line at the very beginning of -initWithAnimals:
_tableNode = [[ASTableNode alloc] initWithStyle:UITableViewStylePlain]; |
and add the following at the end, before the initializer’s return statement:
[self wireDelegation]; |
This initializes AnimalTableController
with a table node and calls -wireDelegation
to wire up the table node’s delegates.
Setting the Table Node’s Data Source & Delegate
Just like UITableView
, ASTableNode
uses a data source and delegate to get information about itself. Table node’s ASTableDataSource
and ASTableDelegate
protocols are very similar to UITableViewDataSource
and UITableViewDelegate
. As a matter of fact, they define some of the exact same methods such as -tableNode:numberOfRowsInSection:
. The two sets of protocols don’t match up perfectly because ASTableNode
behaves a bit differently than UITableView
.
Find -wireDelegation
and replace tableView
with tableNode
in the implementation:
- (void)wireDelegation { self.tableNode.dataSource = self; self.tableNode.delegate = self; } |
Now, you’ll be told that AnimalTableController
doesn’t actually conform to the correct protocol. Currently, AnimalTableController
conforms to to UITableViewDataSource
and UITableViewDelegate
. In the following sections you will conform to and implement each of these protocols so that the view controller’s table node can function.
Conforming to ASTableDataSource
Towards the top of AnimalTableController.m, find the following DataSource
category interface declaration:
@interface AnimalTableController (DataSource)<UITableViewDataSource> @end |
and replace UITableViewDataSource
with ASTableDataSource
:
@interface AnimalTableController (DataSource)<ASTableDataSource> @end |
Now that AnimalTableController
declares conformance to ASTableDataSource
, it’s time to make it so.
Navigate toward the bottom of AnimalTableController.m and find the implementation of the DataSource
category.
First, change the UITableViewDataSource
method -tableView:numberOfRowsInSection:
to the ASTableDataSource
version by replacing it with the following.
- (NSInteger)tableNode:(ASTableNode *)tableNode numberOfRowsInSection:(NSInteger)section { return self.animals.count; } |
Next, ASTableNode
s expect their cells to be returned in a different way than a UITableView
would. To accommodate the new paradigm replace -tableView:cellForRowAtIndexPath:
with the following method:
//1 - (ASCellNodeBlock)tableNode:(ASTableView *)tableView nodeBlockForRowAtIndexPath:(NSIndexPath *)indexPath { //2 RainforestCardInfo *animal = self.animals[indexPath.row]; //3 return ^{ //4 CardNode *cardNode = [[CardNode alloc] initWithAnimal:animal]; //You'll add something extra here later... return cardNode; }; } |
Let’s review this section by section:
-
An
ASCellNode
is the ASDK equivalent to aUITableViewCell
or aUICollectionViewCell
. The more important thing to notice is that this method returns anASCellNodeBlock
. This is because anASTableNode
maintains all of its cells internally and by giving it a block for each index path, it can concurrently initialize all of its cells when it’s ready. -
The first thing you do is grab a reference to the data model needed to populate this cell. This is a very important pattern to take note of. You grab the data and then capture it inside the following block. The
indexPath
shouldn’t be used inside the block, in case the data changes before the block is run. -
You then return a block whose return value must be an
ASCellNode
. -
There is no need to worry about cell reuse so just chill and initialize a cell the easy way. You may notice that you’re returning a
CardNode
now instead of aCardCell
.
This brings me to an important point. As you may have gathered, there is no cell reuse when using ASDK. Alright, maybe I already basically said that twice, but it’s a good thing to keep in mind. Feel free to go to the top of the class and delete
static NSString *kCellReuseIdentifier = @"CellReuseIdentifier"; |
You won’t be needing it anymore.
Maybe take a second to mull that over. You never have to worry about -prepareForReuse
again…
Conforming to ASTableDelegate
Towards the top of AnimalTableController.m, find the following Delegate
category interface declaration:
@interface AnimalTableController (Delegate)<UITableViewDelegate> @end |
and replace UITableViewDelegate
with ASTableDelegate
:
@interface AnimalTableController (Delegate)<ASTableDelegate> @end |
Now that AnimalTableController
declares conformance to ASTableDelegate
, it’s time to handle the implementation. Navigate towards the bottom of AnimalTableController.m and find the implementation of this Delegate
category.
As I’m sure you’re aware, with a UITableView you usually need to, at least, provide an implementation of -tableView:heightForRowAtIndexPath:
. This is because, with UIKit
, the height of each cell is calculated and returned by the table’s delegate.
ASTableDelegate
lacks -tableView:heightForRowAtIndexPath:
. In ASDK, all ASCellNode
s are responsible for determining their own size. Instead of being providing a static height, you can optionally define a minimum and maximum size for your cells. In this case, you want each cell to at least be as tall as 2/3rds of the screen.
Don’t worry about this too much right now; it’s covered in detail in part two of this series.
For now, just replace -tableView:heightForRowAtIndexPath:
with:
- (ASSizeRange)tableView:(ASTableView *)tableNode constrainedSizeForRowAtIndexPath:(NSIndexPath *)indexPath { CGFloat width = [UIScreen mainScreen].bounds.size.width; CGSize min = CGSizeMake(width, ([UIScreen mainScreen].bounds.size.height/3) * 2); CGSize max = CGSizeMake(width, INFINITY); return ASSizeRangeMake(min, max); } |
After all your hard work, go ahead and build and run to see what you have.
That is one smooth table! Once you’ve composed yourself a little, get ready to make it even better.
Infinite Scrolling with Batch Fetching
In most apps, the server has more data points available than the number of cells you’d want to show in your average table. This means that darn near every app you work on will have some mechanism set up to load another batch of objects from the server as the user approaches the end of the current data set.
Many times, this is handled by manually observing the content offset in the scroll view delegate method -scrollViewDidScroll:
. With ASDK, there is a more declarative way of doing things. Instead, you can describe how many pages in advance you’d like to load new content.
The first thing you’ll do, is uncomment the helper methods that have been included. Go to the end of AnimalTableController.m and uncomment the two methods in the Helpers
category. You can think of -retrieveNextPageWithCompletion:
as your networking call, while -insertNewRowsInTableNode:
is a pretty standard method for adding new elements to a table.
Next, add the following line to -viewDidLoad:
.
self.tableNode.view.leadingScreensForBatching = 1.0; // overriding default of 2.0 |
Setting leadingScreensForBatching
to 1.0
means that you want new batches to be fetched whenever the user has scrolled to the point where only 1 screenful of content is left in the table before they would reach the end.
Next, add the following method to the Delegate
category implementation:
- (BOOL)shouldBatchFetchForTableNode:(ASTableNode *)tableNode { return YES; } |
This method is used to tell the table whether or not it should keep making requests for new batches after this one. If you know you’ve reached the end of your API’s data, return NO
and no more requests will be made.
Since you really do want this table to scroll forever, just return YES to ensure new batches will always be requested.
Next, also add:
- (void)tableNode:(ASTableNode *)tableNode willBeginBatchFetchWithContext:(ASBatchContext *)context { //1 [self retrieveNextPageWithCompletion:^(NSArray *animals) { //2 [self insertNewRowsInTableNode:animals]; //3 [context completeBatchFetching:YES]; }]; } |
This method is called when the user has neared the end of the table and the table has received a YES
from -shouldBatchFetchForTableNode:
.
Let’s review this section by section:
- First, you make a request for the next batch of animals to show. Usually this is an array of objects coming back from an API.
- On completion, update the table with the newly downloaded data.
- Finally, make sure to call
-completeBatchFetching:
withYES
when you’re done. New batch fetching requests won’t be made until this one has been completed.
Build, run, and just start swiping. Don’t stop until you don’t care to see another bird. They are infinite.
Intelligent Preloading
Have you ever worked on an app where you decided to load content in advance in some kind of scroll view or page view controller? Maybe you were working on a full-screen image gallery and you decided you always wanted the next few images to be loaded and waiting so your users rarely saw a placeholder.
When you do work on a system like this, you soon realize there’s a lot to think about.
- How much memory are you taking up?
- How far in advance should you be loading content?
- When do you decide to dump what you have in response to user interaction?
And this gets quite a lot more complex when you factor in multiple dimensions of content. Do you have a page view controller with a collection view inside of each of the view controllers? Now you need to think of how you’re going to dynamically load content in both directions… Also, go ahead and tune that for each device you’re supporting. K, thanks.
Remember how I told you to to keep that ASRangeController
thing on the back burner of your mind? Well move it to the front burner!
Within each of the container classes there is a concept of the interface state for each of the contained nodes. At any given time, a node can be in any combination of:
- Preload Range: Usually the furthest range out from being visible. This is when content for each subnode in a cell, such as an ASNetworkImageNode, should be loaded from some external source; an API or a local cache for example. This is in contrast to batch fetching which should be used to fetch model objects representing cells themselves.
- Display Range: Here, display tasks such as text drawing and image decoding take place.
- Visible Range: At this point, the node is onscreen by at least one pixel.
These ranges also work on the metric of “screenfuls” and can be easily tuned using the ASRangeTuningParameters
property.
For example, you’re using an ASNetworkImageNode
to display the image in each page of the gallery. Each one will request data from the network when it enters the Preload Range and decode the image it has retrieved when it enters the Display Range.
In general, you don’t have to think too hard about these ranges if you don’t want to. The built in components, such as ASNetworkImageNode
and ASTextNode
, take full advantage of them which means you will see huge benefits by default.
In general, the leading side of the range is larger than the trailing side. When the user changes their scroll direction, the sizes of the ranges reverse as well in order to favor the content the user is actually moving toward.
Node Interface State Callbacks
You’re probably wondering how exactly these ranges work right? I’m glad you asked.
Every node in the system has an interfaceState
property which is a “bitfield” (NS_OPTION) type ASInterfaceState
. As the ASCellNode
moves through a scroll view managed by an ASRangeController
, each subnode has its interfaceState
property updated accordingly. This means that even the deepest nodes in the tree can respond to interfaceState
changes.
Luckily, it’s rarely necessary to fiddle with the bits of a node’s interfaceState
directly. More often, you’ll just want to react to a node changing to or from a certain state. That’s where the interface state callbacks come in.
Naming Nodes
In order to see a node move through the various states, it is useful to give it a name. This way, you’ll be able to watch as each node loads its data, displays its content, comes on-screen and then does the whole thing in reverse as it leaves.
Go back to -tableNode:nodeBlockForRowAtIndexPath:
, and find the comment that says:
//You'll add something extra here later... |
Right below it, add the following line to give each cell a debugName
.
cardNode.debugName = [NSString stringWithFormat:@"cell %zd", indexPath.row]; |
Now you’ll be able to track the cells’ progression through the ranges.
Observing the Cells
Navigate to CardNode_InterfaceCallbacks.m. Here you’ll find six methods you can use to track a node’s progress through the various ranges. Uncomment them, and then build and run. Make sure your console in Xcode is visible and then scroll slowly. As you do, watch as the various cells react to their changing states.
Note: In most cases, the only ASInterfaceState change method you’ll care about is -didEnterVisibleState
or -didExitVisibleState
. That said, a lot of work is going on under the hood for you. To check out what you can do by integrating with the Preload and Display states, take a look at the code in ASNetworkImageNode
. All network image nodes will automatically fetch and decode their content, as well as free up memory, without you needing to lift a finger.
(Intelligent Preloading)2
In the 2.0 release, the concept of intelligently preloading content in multiple directions was introduced. Say you have a vertically scrolling table view, and at some point a cell comes onscreen that contains a horizontal collection view.
Though this collection is now technically in the visible region, you wouldn’t want to load the entire collection up front. Instead, both scroll views have their own ASRangeController
complete with separately configurable range tuning parameters.
Entering the Second Dimension
Now that you have completed AnimalTableController
, you’re able to use it as a page in an ASPagerNode
.
The view controller you’ll use to contain this pager is already in the project so the first thing you need to do is navigate to AppDelegate.m.
Find -installRootViewController
and replace:
AnimalTableController *vc = [[AnimalTableController alloc] initWithAnimals:[RainforestCardInfo allAnimals]]; |
with:
AnimalPagerController *vc = [[AnimalPagerController alloc] init]; |
Then, go into AnimalPagerController.m and add the following lines to the initializer right before the return statement. All you need to do is create a new pager and set its dataSource
to be this view controller.
_pagerNode = [[ASPagerNode alloc] init]; _pagerNode.dataSource = self; |
The pager node is actually a subclass of an ASCollectionNode
preconfigured to be used in the same way you’d use a UIPageViewController
. The nice thing about this is that the API is actually quite a bit simpler to think about than UIPageViewController
‘s.
The next thing you have to do is to implement the pager’s data source methods. Navigate to the ASPagerDataSource
category implementation at the bottom of this file.
First, tell the pager that its number of pages is equal to the number of animal arrays, in this case, three by replacing the existing -numberOfPagesInPagerNode:
.
- (NSInteger)numberOfPagesInPagerNode:(ASPagerNode *)pagerNode { return self.animals.count; } |
Then, you need to implement -pagerNode:nodeAtIndex:
, similar to the node block data source method you implemented for the ASTableNode
earlier.
- (ASCellNode *)pagerNode:(ASPagerNode *)pagerNode nodeAtIndex:(NSInteger)index { //1 CGSize pagerNodeSize = pagerNode.bounds.size; NSArray *animals = self.animals[index]; //2 ASCellNode *node = [[ASCellNode alloc] initWithViewControllerBlock:^{ return [[AnimalTableController alloc] initWithAnimals:animals]; } didLoadBlock:nil]; return node; } |
Let’s review this section by section:
- Although this version isn’t block-based, it’s good practice to grab your data model first.
-
This time, you’re using the powerful
-initWithViewControllerBlock:
initializer. All you need to do is return a block that returns the table node controller you fixed up earlier and the managed view will automatically be used as the view for each page. Pretty cool if you ask me. ;]
Once you’ve added this method you’ll have a fully functioning pager whose cells are generated from the tableNodeController
you created earlier. This comes fully stocked with two dimensional preloading based on the vertical and horizontal scrolling performed by the user!
Where To Go From Here?
To see the completed project for this AsyncDisplayKit 2.0 tutorial, download it here. If you’re wanting to see all this in Swift, we’ve got that too.
When you’re ready, move on to part 2 of this project to learn about the powerful new layout system introduced with AsyncDisplayKit 2.0.
If you’d rather do a little more research before moving on, you can check out AsyncDisplayKit’s home page and read through some of the documentation. Scott Goodson (the original author of AsyncDisplayKit) also has a few talks you may be interested in, listed in chronological order on the AsyncDisplayKit.org Resources page.
You may also be interested in the Building Paper event. Although none of this was open sourced at that time, and a lot has changed, it’s pretty interesting to see where it all started.
Lastly, as part of the AsyncDisplayKit community’s legendary reputation for welcoming newcomers, there is a public Slack channel where anyone is invited to come and ask questions!
Hopefully you enjoyed this tutorial, let us know if you have any questions or comments by joining the forum discussion below!
The post AsyncDisplayKit 2.0 Tutorial: Getting Started appeared first on Ray Wenderlich.