7.8 KiB
layout | title | permalink | prev | next |
---|---|---|---|---|
docs | Custom nodes | /guide/2/ | guide/ | guide/3/ |
View hierarchies
Sizing and layout of custom view hierarchies are typically done all at once on the main thread. For example, a custom UIView that minimally encloses a text view and an image view might look like this:
- (CGSize)sizeThatFits:(CGSize)size
{
// size the image
CGSize imageSize = [_imageView sizeThatFits:size];
// size the text view
CGSize maxTextSize = CGSizeMake(size.width - imageSize.width, size.height);
CGSize textSize = [_textView sizeThatFits:maxTextSize];
// make sure everything fits
CGFloat minHeight = MAX(imageSize.height, textSize.height);
return CGSizeMake(size.width, minHeight);
}
- (void)layoutSubviews
{
CGSize size = self.bounds.size; // convenience
// size and layout the image
CGSize imageSize = [_imageView sizeThatFits:size];
_imageView.frame = CGRectMake(size.width - imageSize.width, 0.0f,
imageSize.width, imageSize.height);
// size and layout the text view
CGSize maxTextSize = CGSizeMake(size.width - imageSize.width, size.height);
CGSize textSize = [_textView sizeThatFits:maxTextSize];
_textView.frame = (CGRect){ CGPointZero, textSize };
}
This isn't ideal. We're sizing our subviews twice — once to figure out how big our view needs to be and once when laying it out — and while our layout arithmetic is cheap and quick, we're also blocking the main thread on expensive text sizing.
We could improve the situation by manually cacheing our subviews' sizes, but
that solution comes with its own set of problems. Just adding _imageSize
and
_textSize
ivars wouldn't be enough: for example, if the text were to change,
we'd need to recompute its size. The boilerplate would quickly become
untenable.
Further, even with a cache, we'll still be blocking the main thread on sizing
sometimes. We could try to shift sizing to a background thread with
dispatch_async()
, but even if our own code is thread-safe, UIView methods are
documented to only work on the main
thread:
Manipulations to your application’s user interface must occur on the main thread. Thus, you should always call the methods of the UIView class from code running in the main thread of your application. The only time this may not be strictly necessary is when creating the view object itself but all other manipulations should occur on the main thread.
This is a pretty deep rabbit hole. We could attempt to work around the fact that UILabels and UITextViews cannot safely be sized on background threads by manually creating a TextKit stack and sizing the text ourselves... but that's a laborious duplication of work. Further, if UITextView's layout behaviour changes in an iOS update, our sizing code will break. (And did we mention that TextKit isn't thread-safe either?)
Node hierarchies
Enter AsyncDisplayKit. Our custom node looks like this:
#import <AsyncDisplayKit/AsyncDisplayKit+Subclasses.h>
...
// perform expensive sizing operations on a background thread
- (CGSize)calculateSizeThatFits:(CGSize)constrainedSize
{
// size the image
CGSize imageSize = [_imageNode measure:constrainedSize];
// size the text node
CGSize maxTextSize = CGSizeMake(constrainedSize.width - imageSize.width,
constrainedSize.height);
CGSize textSize = [_textNode measure:maxTextSize];
// make sure everything fits
CGFloat minHeight = MAX(imageSize.height, textSize.height);
return CGSizeMake(constrainedSize.width, minHeight);
}
// do as little work as possible in main-thread layout
- (void)layout
{
// layout the image using its cached size
CGSize imageSize = _imageNode.calculatedSize;
_imageNode.frame = CGRectMake(self.bounds.size.width - imageSize.width, 0.0f,
imageSize.width, imageSize.height);
// layout the text view using its cached size
CGSize textSize = _textNode.calculatedSize;
_textNode.frame = (CGRect){ CGPointZero, textSize };
}
ASImageNode and ASTextNode, like the rest of AsyncDisplayKit, are thread-safe,
so we can size them on background threads. The -measure:
method is like
-sizeThatFits:
, but with side effects: it caches both the argument
(constrainedSizeForCalculatedSize
) and the result (calculatedSize
) for
quick access later on — like in our now-snappy -layout
implementation.
As you can see, node hierarchies are sized and laid out in much the same way as their view counterparts. Custom nodes do need to be written with a few things in mind:
-
Nodes must recursively measure all of their subnodes in their
-calculateSizeThatFits:
implementations. Note that the-measure:
machinery will only call-calculateSizeThatFits:
if a new measurement pass is needed (e.g., if the constrained size has changed). -
Nodes should perform any other expensive pre-layout calculations in
-calculateSizeThatFits:
, cacheing useful intermediate results in ivars as appropriate. -
Nodes should call
[self invalidateCalculatedSize]
when necessary. For example, ASTextNode invalidates its calculated size when itsattributedString
property is changed.
For more examples of custom sizing and layout, along with a demo of
ASTextNode's features, check out BlurbNode
and KittenNode
in the
Kittens
sample project.
Custom drawing
To guarantee thread safety in its highly-concurrent drawing system, the node
drawing API diverges substantially from UIView's. Instead of implementing
-drawRect:
, you must:
-
Define an internal "draw parameters" class for your custom node. This class should be able to store any state your node needs to draw itself — it can be a plain old NSObject or even a dictionary.
-
Return a configured instance of your draw parameters class in
-drawParametersForAsyncLayer:
. This method will always be called on the main thread. -
Implement either
+drawRect::::
or+displayWithParameters::
. Note that these are class methods that will not have access to your node's state — only the draw parameters object. They can be called on any thread and must be thread-safe.
For example, this node will draw a rainbow:
@interface RainbowNode : ASDisplayNode
@end
@implementation RainbowNode
+ (void)drawRect:(CGRect)bounds
withParameters:(id<NSObject>)parameters
isCancelled:(asdisplaynode_iscancelled_block_t)isCancelledBlock
isRasterizing:(BOOL)isRasterizing
{
// clear the backing store, but only if we're not rasterising into another layer
if (!isRasterizing) {
[[UIColor whiteColor] set];
UIRectFill(bounds);
}
// UIColor sadly lacks +indigoColor and +violetColor methods
NSArray *colors = @[ [UIColor redColor],
[UIColor orangeColor],
[UIColor yellowColor],
[UIColor greenColor],
[UIColor blueColor],
[UIColor purpleColor] ];
CGFloat stripeHeight = roundf(bounds.size.height / (float)colors.count);
// draw the stripes
for (UIColor *color in colors) {
CGRect stripe = CGRectZero;
CGRectDivide(bounds, &stripe, &bounds, stripeHeight, CGRectMinYEdge);
[color set];
UIRectFill(stripe);
}
}
@end
This could easily be extended to support vertical rainbows too, by adding a
vertical
property to the node, exporting it in
-drawParametersForAsyncLayer:
, and referencing it in +drawRect::::
.
More-complex nodes can be supported in much the same way.
For more on custom nodes, check out the subclassing header or read on!