Skip to content

Instantly share code, notes, and snippets.

@levi
Last active November 29, 2019 16:06
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save levi/09c6442722c2c5a8d0b3 to your computer and use it in GitHub Desktop.
Save levi/09c6442722c2c5a8d0b3 to your computer and use it in GitHub Desktop.
AsyncDisplayKit Layout Transition API

AsyncDisplayKit Layout Transition API

Animating between layouts

The layout transition API makes it easy to animate between a node's generated layouts in response to some internal state change in a node.

Imagine you wanted to implement this sign up form and animate in the new field when tapping the next button:

Imgur

A standard way to implement this would be to create a container node called SignupNode that includes two FieldNodes and a button node as subnodes. We'll include a property on the SignupNode called fieldState that will be used to select which FieldNode to show when the node calculates its layout. The internal layout spec of the SignupNode container would look something like this:

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize
{
  FieldNode *field;
  if (self.fieldState == SignupNodeName) {
    field = self.nameField;
  } else {
    field = self.ageField;
  }

  ASStackLayoutSpec *stack = [[ASStackLayoutSpec alloc] init];
  [stack setChildren:@[field, self.buttonNode]];

  UIEdgeInsets insets = UIEdgeInsetsMake(15.0, 15.0, 15.0, 15.0);
  return [ASInsetLayoutSpec insetLayoutSpecWithInsets:insets child:stack];
}

To trigger a transition from the nameField to the ageField in this example, we'll update the SignupNode's fieldState property and begin the transition with transitionLayoutWithAnimation:. This method will invalidate the current calculated layout and recompute a new layout with the ageField now in the stack.

self.signupNode.fieldState = SignupNodeAge;
[self.signupNode transitionLayoutWithAnimation:YES];

In the default implementation of this API, the layout will recalculate the new layout and use its sublayouts to size and position the SignupNode's subnodes without animation. Future versions of this API will likely include a default animation between layouts and we're open to feedback on what you'd like to see here. However, we'll need to implement a custom animation block to handle the signup form case.

The example below represents an override of animateLayoutTransition: in SignupNode. This method is called after the new layout has been calculated via transitionLayoutWithAnimation: and in the implementation we'll perform a specific animation based upon the fieldState property that was set before the animation was triggered.

- (void)animateLayoutTransition:(id<ASContextTransitioning>)context
{
  if (self.fieldState == SignupNodeName) {
    CGRect initialNameFrame = [context initialFrameForNode:self.ageField];
    initialNameFrame.origin.x += initialNameFrame.size.width;
    self.nameField.frame = initialNameFrame;
    self.nameField.alpha = 0.0;
    CGRect finalEmailFrame = [context finalFrameForNode:self.nameField];
    finalEmailFrame.origin.x -= finalEmailFrame.size.width;
    [UIView animateWithDuration:0.4 animations:^{
      self.nameField.frame = [context finalFrameForNode:self.nameField];
      self.nameField.alpha = 1.0;
      self.ageField.frame = finalEmailFrame;
      self.ageField.alpha = 0.0;
    } completion:^(BOOL finished) {
      [context completeTransition:finished];
    }];
  } else {
    CGRect initialAgeFrame = [context initialFrameForNode:self.nameField];
    initialAgeFrame.origin.x += initialAgeFrame.size.width;
    self.ageField.frame = initialAgeFrame;
    self.ageField.alpha = 0.0;
    CGRect finalNameFrame = [context finalFrameForNode:self.ageField];
    finalNameFrame.origin.x -= finalNameFrame.size.width;
    [UIView animateWithDuration:0.4 animations:^{
      self.ageField.frame = [context finalFrameForNode:self.ageField];
      self.ageField.alpha = 1.0;
      self.nameField.frame = finalNameFrame;
      self.nameField.alpha = 0.0;
    } completion:^(BOOL finished) {
      [context completeTransition:finished];
    }];
  }
}

The passed ASContextTransitioning context object in this method contains relevant information to help you determine the state of the nodes before and after the transition. It includes getters into old and new constrained sizes, inserted and removed nodes, and even the raw old and new ASLayout objects. In the SignupNode example, we're using it to determine the frame for each of the fields and animate them in an out of place.

It is imperative to call completeTransition: on the context object once your animation has finished, as it will perform the necessary internal steps for the newly calculated layout to become the current calculatedLayout.

Note that there hasn't been a use of addSubnode: or removeFromSupernode during the transition. AsyncDisplayKit's layout transition API analyzes the differences in the node hierarchy between the old and new layout, implicitly performing node insertions and removals for automatically. Nodes are inserted before your implementation of animateLayoutTransition: is called and this is a good place to manually manage the hierarchy before you begin the animation. Removals are preformed in didCompleteLayoutTransition: after you call completeTransition: on the context object. If you need to manually perform deletions, override didCompleteLayoutTransition: and perform your custom operations. Note that this will override the default behavior and it is recommended to either call super or walk through the removedSubnodes getter in the context object to perform the cleanup.

Passing NO to transitionLayoutWithAnimation: will still run through your animateLayoutTransition: and didCompleteLayoutTransition: implementations with the [context isAnimated] property set to NO. It is your choice on how to handle this case — if at all. An easy way to provide a default implementation this is to call super:

- (void)animateLayoutTransition:(id<ASContextTransitioning>)context
{
  if ([context isAnimated]) {
    // perform animation
  } else {
    [super animateLayoutTransition:context];
  }
}

Animating constrained size changes

There will be times you'll simply want to respond to bounds changes to your node and animate the recalculation of its layout. To handle this case, call transitionLayoutWithSizeRange:animated: on your node. This method is similar to transitionLayoutWithAnimation:, but will not trigger an animation if the passed ASSizeRange is equal to the current constrainedSizeForCalculatedLayout value. This is great for responding to rotation events and view controller size changes:

- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
{
  [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
  [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
    [self.node transitionLayoutWithSizeRange:ASSizeRangeMake(size, size) animated:YES];
  } completion:nil];
}
@aaronschubert0
Copy link

@levi this is looking amazing! Makes the recent work you did much more accessible. Excited to try this out. Having read this, I would say it may be good to define what kind of type self.fieldState is (or specify that the internal state can be handled by anything the developer wishes, since this API will just re-render - (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize), so that developers have a complete understanding on how they can re-create this example. I'm sure I'll have more feedback as I use it. In terms of having a default transition animation, I am strongly in favour for this, I imagine a simple fade would suffice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment