Swiftgram/docs/guide/2-custom-nodes.md
Nadine Salter f83f113493 Documentation.
Generated with jekyll & appledoc.  Image assets are temporary.
2014-10-07 19:31:46 -07:00

7.8 KiB
Raw Blame History

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 applications 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 its attributedString 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:

  1. 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.

  2. Return a configured instance of your draw parameters class in -drawParametersForAsyncLayer:. This method will always be called on the main thread.

  3. 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!