Fancy Animations for Expandable UITableViewCells

February 1, 2015 Rachel Bobbins

Recently, my iOS project was tasked with building a view that included a list of expandable/collapsible elements. Doing this with autolayout and the built-in UITableView animations was straightforward. Later, we were asked to swap out the default animation for a fancy animation that made parts of the cell look like they were folding/unfolding. It took us 3 days—here’s how we did it!

I’ve copied the animation code onto a simple project, which is fully available at Github.

 


Step 1: Setup the UITableViewCell

The first step to creating a fancy animation was creating a UITableViewCell (called BookCell) with flexible constraints. By flexible, I mean that no constraint was absolutely required. The cell included a yellow subview subview with a collapsible height constraint — the height constraint always has a constant of 0, and it initially has a priority of 999. Within the collapsible subview, no vertical constraints are required. We set the priority of all the internal vertical constraints to 998.

When the application runs, it initially has a list of collapsed cells. The 0 height constraint with priority 999 takes precedence over its internal constraints that have priority 998. The contents of the yellow subview end up collapsing, because none of the subviews have constraints with a priority greater than 999.

container_constraintslabel_constraints

 

 

 

The BookCell has a publicly writable property that determines whether or not to show the details in the collapsible view. As a side effect of setting this property, the priority of the 0 height constraint is changed. To expand the collapsed view, we set the height constraint’s priority to 250. It’s lower priority than anything else in the view, so it is effectively ignored, in favor of the collapsed view’s intrinsic content height. This allows the cell to expand!

//In BookCell.h
@property (nonatomic, assign) BOOL withDetails;

//In BookCell.m
- (void)setWithDetails:(BOOL)withDetails {
  _withDetails = withDetails;

  if (withDetails) {
    self.detailContainerViewHeightConstraint.priority = 250;
  } else {
    self.detailContainerViewHeightConstraint.priority = 999;
  }
}

Step 2: Set up the UITableView

In a simple world, when the user tapped a cell, we would toggle the withDetails property of the cell when tableView:didSelectRowAtIndexPath: was called. However, changing the constraints of a cell does not trigger the cell to redraw. This makes sense because if a cell got bigger or smaller, all the cells around it would have to redraw accordingly. So, to force the cell to redraw, we take note of the which indexPath was tapped, and reload that indexPath cell with its updated constraints. This looks something like below:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
  [tableView deselectRowAtIndexPath:indexPath animated:NO];

  //self.expandedIndexPaths is a NSMutableSet that contains the
  //indexPaths of any cells that should be expanded
  if ([self.expandedIndexPaths containsObject:indexPath]) {
    [self.expandedIndexPaths removeObject:indexPath];
    [tableView reloadRowsAtIndexPaths:@[indexPath]
                     withRowAnimation:UITableViewRowAnimationNone];
  } else {
    [self.expandedIndexPaths addObject:indexPath];
    [tableView reloadRowsAtIndexPaths:@[indexPath]
                     withRowAnimation:UITableViewRowAnimationNone];
  }
}

- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
  BookCell *cell = (id)[tableView dequeueReusableCellWithIdentifier:kBookCellIdentifier];
  NSString *bookTitle = self.bookTitles[indexPath.row];

  cell.bookTitleLabel.text = bookTitle;
  cell.bookDescriptionLabel.text = self.bookDescriptions[bookTitle];
  cell.withDetails = [self.expandedIndexPaths containsObject:indexPath];

  return cell;
}

This implementation of expandable cells allows multiple cells to be expanded at once — there’s no limit to how many cells can be in the NSMutableSet. If you want to ensure that only one cell is open at a time, your didSelectRow... method will be a bit more complex.

Step 3: Creating a folding animation

This was by far the coolest part of our project! We knew it could be done, because we’d seen several open-source implementations online, and there are existing apps with similar implementations. None of the open-source solutions worked well for us, but we took inspiration from their implementations. The implementation relies Core Animation.

One strategy that worked really well for us was to isolate the animation in a simple UIView before applying it to a subview within a UITableViewCell. While we were working on the animation, the app showed the animation immediately upon launching. This saved a lot of time, because we didn’t have to click through 3 levels of buttons to get to the part of the app that contained the animation.

We treated the animation as 5 separate problems, which made it easier to tackle.

  1. Split the view we want to animate into a top half and bottom half, so that each half can rotate separately. We did this using functions from UIGraphics.
  2. Top half of the view rotates around its top x-axis
    1. By default, every animation happens around the center of the UIView. To force an animation to happen around the top of the UIView, we adjust its anchor point. The anchor point is a CGPoint with X and Y values normalized between 0 and 1. The default value is (0.5, 0.5). To rotate around the top edge, we change it to (0.5, 1.0).
      topHalfView.layer.anchorPoint = CGPointMake(0.5, 0.0);
    2. Changing the anchor point also changes the position of the view on screen. We don’t want that! To compensate for moving the anchor point halfway up, we also move the view’s Y-origin halfway down:
      CGRect startingFrame = CGRectMake(
        0,
        -topHalfView.frame.size.height / 2,
        topHalfView.frame.size.width,
        topHalfView.frame.size.height);
    3. Rotation was simple enough. We used Core Animation to position the top view 90° from its normal angle to the X-axis. This actually ends up as -90°, or -π/2 radians.
      [CATransaction begin];
      [CATransaction setAnimationDuration:0.3];
      
      CABasicAnimation *topRotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.x"];
      topRotationAnimation.fillMode = kCAFillModeForwards;
      topRotationAnimation.fromValue = @(-M_PI_2);
      topRotationAnimation.toValue = @0;
      
      [topHalfView.layer addAnimation:topRotationAnimation forKey:nil];
      [CATransaction commit];
  3. Bottom half of the view rotates around its bottom x-axis. This was just like the rotation of the top half. When the animation starts, both halves are at the same position. Thus, they’re able to share the same initial frame. There were a couple of small changes to make, to account for the different axis of rotation:
    • Set the anchor point to (0.5, 1.0) to force rotation around the bottom edge.
    • Set animation to go from 90° (not -90°!) to 0°
  4. Bottom half of the view moves down (translates) as the top-half expands.This was the most complicated part of the animation! As the top half of the view rotates inwards, it’s bottom edge is farther down the screen. We wanted this bottom edge to be “sticky” to the top edge of the view below it. Because we’re using Core Animation here, creating an NSLayoutConstraint wasn’t option. Our solution was to translate the bottom half of the view.Our first few attempts at this weren’t great. At various point during the animation, there was either a gap between the top and bottom halves, or there was too much overlap between them. The translation was happening according to the default CAMediaTimingFunction, which is linear. However! While the top half of the view is rotating, its maximum Y-coordinate isn’t changing linearly — it’s a function of the angle of the rotation. We needed a timing function that described this behavior. kCAMediaTimingFunctionEaseOut was good enough, and the final translation animation looked like this:
    CABasicAnimation *bottomTranslationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.translation.y"];
    bottomTranslationAnimation.fromValue = @(CGRectGetMinY(topHalfView.frame));
    bottomTranslationAnimation.toValue = @(2 * bottomHalfView.frame.size.height);
    bottomTranslationAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];
    bottomTranslationAnimation.fillMode = kCAFillModeForwards;
        
  5. Make the animation look realistically 3D.
    • Before the animation began, we added depth to the top and bottom halves of the view
      CATransform3D startingTransform = CATransform3DIdentity;
      startingTransform.m34 = -1 / 500.f;
      topHalfView.layer.transform = startingTransform;
      bottomHalfView.layer.transform = startingTransform;
      

       

    • Adding a shadow to each half, which faded out as the halves rotated inward.
      //Before the CATransaction:
      CAGradientLayer *topShadowLayer = [CAGradientLayer layer];
      topShadowLayer.colors = @[((id)[UIColor clearColor].CGColor), ((id)[UIColor blackColor].CGColor) ];
      topShadowLayer.frame = topHalfView.bounds;
      [topHalfView.layer addSublayer:topShadowLayer];
      
      //Inside the CATransaction:
      CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
      opacityAnimation.fromValue = @(1.0);
      opacityAnimation.toValue = @(0.0);
      [topShadowLayer addAnimation:opacityAnimation forKey:nil];
      [bottomShadowLayer addAnimation:opacityAnimation forKey:nil];
      

       

It took our team a long time to figure out all of the bits and pieces that were necessary for the unfolding animation. Folding was very similar, but the from/to values of each animation were reversed. In the end, we had 2 functions which could be called on any UIView to fold/unfold it. A complete implementation is on Github, in the UIView+Fold.m file.

Step 4: Incorporating our animation with UITableViewCells

To bring the animation into our collapsible BookCells, we changed the didSelectRow:... method slightly. In the new version, when a cell has been toggled to the expanded state, we animate it after it’s been reloaded with its updated constraints. If the animation happens before the cell is reloaded, height of its collapsed section will be 0. We won’t be able to capture a screenshot to animate. For the same reason, when a cell has been toggled to the collapsed state, we animate it before it gets reloaded. The updated method looks like this:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:NO];

    if ([self.expandedIndexPaths containsObject:indexPath]) {
        BookCell *cell = (id)[tableView cellForRowAtIndexPath:indexPath];
        [cell animateClosed];

        [self.expandedIndexPaths removeObject:indexPath];
        [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
    } else {
        [self.expandedIndexPaths addObject:indexPath];
        [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];

        BookCell *cell = (id)[tableView cellForRowAtIndexPath:indexPath];
        [cell animateOpen];
    }
}

You’ll notice that we call animateOpen and animateClosed on the cells. These are wrapper methods around the UIView method that we built above. They enable us to change other parts of the cell during the animation. Specifically, they let us change the cell’s background to clear during the animation. This looks more polished than the alternative, which is seeing some other UIView behind the animation. The implementation of animateOpen is below:


- (void)animateOpen {
    UIColor *originalBackgroundColor = self.contentView.backgroundColor;
    self.contentView.backgroundColor = [UIColor clearColor];
    [self.detailContainerView foldOpenWithTransparency:YES
                                   withCompletionBlock:^{
        self.contentView.backgroundColor = originalBackgroundColor;
    }];
}

The animation is 0.3 second long, which is roughly the same speed as the default animation when reloading a table view cell. Because it’s so quick, small timing mismatches between the table animation and the cell’s subview animation aren’t visible. However, the smoothness breaks down about when you’re dealing with cells that vary a lot in size. It’s challenging to find a timing that looks polished and unglitchy for both small and large cells. If you’ve developed a solution for this, please let me know!

And, that’s it! It took our team a few days to reach this implementation, and I hope that this detailed blog post will save you some time while adding a similar effect. And, as mentioned above, the code is on Github, along with a sample app that uses it.

 

About the Author

Biography

Previous
Christian Tzolov on Open Source Engineering, the ODP and Pivotal
Christian Tzolov on Open Source Engineering, the ODP and Pivotal

Christian Tzolov has worked on some really amazing projects in his life—artificial intelligence, data scien...

Next
The Trouble with Mobile Wallets
The Trouble with Mobile Wallets

With every major device out on the market now containing an NFC antenna, one of the hottest topics in the m...