26

My UITableViewCell will animate it's height when recognizing a tap. In iOS 9 and below this animation is smooth and works without issues. In iOS 10 beta there's a jarring jump during the animation. Is there a way to fix this?

Here is a basic example of the code.

- (void)cellTapped {
    [self.tableView beginUpdates];
    [self.tableView endUpdates];
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return self.shouldExpandCell ? 200.0f : 100.0f;
}

EDIT: 9/8/16

The issue still exists in the GM. After more debugging I have discovered the issue is related to the cell immediately jumping to the new height and then will animate the relevant cells offset. This means any CGRect based animation which is dependent on the cells bottom will not work.

For instance if I have a view constrained to the cells bottom, it will jump. The solution would involve a constraint to the top with a dynamic constant. Or think of another way to position your views other then related to the bottom.

cnotethegr8
  • 7,342
  • 8
  • 68
  • 104

7 Answers7

13

The solution for iOS 10 in Swift with AutoLayout :

Put this code in your custom UITableViewCell

override func layoutSubviews() {
    super.layoutSubviews()

    if #available(iOS 10, *) {
        UIView.animateWithDuration(0.3) { self.contentView.layoutIfNeeded() }
    }
}

In Objective-C:

- (void)layoutSubviews
{
    [super layoutSubviews];

    NSOperatingSystemVersion ios10 = (NSOperatingSystemVersion){10, 0, 0};
    if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:ios10]) {
        [UIView animateWithDuration:0.3
                         animations:^{
                             [self.contentView layoutIfNeeded];
                         }];
    }
}

This will animate UITableViewCell change height if you have configured your UITableViewDelegate like below :

func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
    return selectedIndexes.contains(indexPath.row) ? Constants.expandedTableViewCellHeight : Constants.collapsedTableViewCellHeight
}

func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    tableView.deselectRowAtIndexPath(indexPath, animated: true)
    tableView.beginUpdates()
    if let index = selectedIndexes.indexOf(indexPath.row) {
        selectedIndexes.removeAtIndex(index)
    } else {
        selectedIndexes.append(indexPath.row)
    }
    tableView.endUpdates()
}
Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
GiomGiom
  • 131
  • 3
  • 1
    First part of this answer is the best solution for me! – chrilith Oct 23 '16 at 10:38
  • calling `layoutIfNeeded` inside `layoutSubviews` is causing a lot of animations when the cell is initially loaded and set up. I went with the answer from Markus below where he calls `layoutIfNeeded` inside `setFrame` instead of `layoutSubviews`. http://stackoverflow.com/a/39329522/1570970 – Toland Hon Nov 02 '16 at 23:32
  • Thanks bro, awesome answer! – Flavio Kruger Mar 20 '17 at 18:42
9

Better Solution:

The issue is when the UITableView changes the height of a cell, most likely from -beginUpdates and -endUpdates. Prior to iOS 10 an animation on the cell would take place for both the size and the origin. Now, in iOS 10 GM, the cell will immediately change to the new height and then will animate to the correct offset.

The solution is pretty simple with constraints. Create a guide constraint which will update it's height and have the other views which need to be constrained to the bottom of the cell, now constrained to this guide.

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        UIView *heightGuide = [[UIView alloc] init];
        heightGuide.translatesAutoresizingMaskIntoConstraints = NO;
        [self.contentView addSubview:heightGuide];
        [heightGuide addConstraint:({
            self.heightGuideConstraint = [NSLayoutConstraint constraintWithItem:heightGuide attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:0.0f];
        })];
        [self.contentView addConstraint:({
            [NSLayoutConstraint constraintWithItem:heightGuide attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeTop multiplier:1.0f constant:0.0f];
        })];

        UIView *anotherView = [[UIView alloc] init];
        anotherView.translatesAutoresizingMaskIntoConstraints = NO;
        anotherView.backgroundColor = [UIColor redColor];
        [self.contentView addSubview:anotherView];
        [anotherView addConstraint:({
            [NSLayoutConstraint constraintWithItem:anotherView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0f constant:20.0f];
        })];
        [self.contentView addConstraint:({
            [NSLayoutConstraint constraintWithItem:anotherView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeLeft multiplier:1.0f constant:0.0f];
        })];
        [self.contentView addConstraint:({
            // This is our constraint that used to be attached to self.contentView
            [NSLayoutConstraint constraintWithItem:anotherView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:heightGuide attribute:NSLayoutAttributeBottom multiplier:1.0f constant:0.0f];
        })];
        [self.contentView addConstraint:({
            [NSLayoutConstraint constraintWithItem:anotherView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeRight multiplier:1.0f constant:0.0f];
        })];
    }
    return self;
}

Then update the guides height when needed.

- (void)setFrame:(CGRect)frame {
    [super setFrame:frame];

    if (self.window) {
        [UIView animateWithDuration:0.3 animations:^{
            self.heightGuideConstraint.constant = frame.size.height;
            [self.contentView layoutIfNeeded];
        }];

    } else {
        self.heightGuideConstraint.constant = frame.size.height;
    }
}

Note that putting the update guide in -setFrame: might not be the best place. As of now I have only built this demo code to create a solution. Once I finish updating my code with the final solution, if I find a better place to put it I will edit.

Original Answer:

With the iOS 10 beta nearing completion, hopefully this issue will be resolved in the next release. There's also an open bug report.

My solution involves dropping to the layer's CAAnimation. This will detect the change in height and automatically animate, just like using a CALayer unlinked to a UIView.

The first step is to adjust what happens when the layer detects a change. This code has to be in the subclass of your view. That view has to be a subview of your tableViewCell.contentView. In the code, we check if the view's layer's actions property has the key of our animation. If not just call super.

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
    return [layer.actions objectForKey:event] ?: [super actionForLayer:layer forKey:event];
}

Next you want to add the animation to the actions property. You might find this code is best applied after the view is on the window and laid out. Applying it beforehand might lead to an animation as the view moves to the window.

- (void)didMoveToWindow {
    [super didMoveToWindow];

    if (self.window) {
        CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"bounds"];
        self.layer.actions = @{animation.keyPath:animation};
    }
}

And that's it! No need to apply an animation.duration since the table view's -beginUpdates and -endUpdates overrides it. In general if you use this trick as a hassle-free way of applying animations, you will want to add an animation.duration and maybe also an animation.timingFunction.

cnotethegr8
  • 7,342
  • 8
  • 68
  • 104
  • Wow really advanced stuff. It works half ways for me: my view first jumps to a different position but the size is animated correctly. Looks like my "bounds" and "center" changes, where the latter is not animated. I tried to use a CAAnimationGroup for self.layer.actions, but it didn't work... Any idea how to handle it? – Markus Sep 05 '16 at 11:21
  • One more question: So this code needs to be added to each subview of the contentView that should be animated? – Markus Sep 05 '16 at 11:22
  • I was not able to get an exact animation like in iOS9, lots of iOS10 bugs so since this solution significantly reduced the jump and being that 10 is still beta, I didn't proceed further. However you could try adding other animations to the actions dictionary. An animation group is irrelevant for this code. Try adding an animation for `bounds.origin` and `bounds.size`, if that does nothing try `bounds` and `position`. – cnotethegr8 Sep 05 '16 at 18:35
  • Now this will animate the key value of your view (such as it's bounds). So if you have subviews to this view and their layouts are set up correctly, then the animation will work as expected on the subviews. If however you have multiple sibling views which need animations, then you'll need to add the code to each of them. Again, I wouldn't spend too much time on it. Hopefully this animation bug will be fixed in one of the iOS10 GM's. – cnotethegr8 Sep 05 '16 at 18:43
  • 1
    Cool - "bounds" and "position" did the trick! I just created two animations and put them into the dictionary: self.layer.actions = @{animation1.keyPath:animation1, animation2.keyPath:animation2 }; – Markus Sep 06 '16 at 19:44
  • Actually I'm not very optimistic that they will still fix it... well let's see tomorrow... – Markus Sep 06 '16 at 19:45
  • Not fixed in the GM. My gut feeling is we have to wait at least until 10.1 for this :( – Markus Sep 07 '16 at 21:59
  • What do you mean with "if you have subviews to this view and their layouts are set up correctly"? I tried adding a subview to contentView that implements "actionForLayer" and "didMoveToWindow", and add all other views of the cell to this subview. Didn't work :( – Markus Sep 07 '16 at 22:01
  • It sounds like you did what I said, I hadn't tested it though. I made the comment based on how UIKit is supposed to work. Did you try laying your subviews with constraints instead of auto layout? Also keep in mind, this only will work if your subviews of the `actionForLayer` view are implementing the same animation. If they need different animations, then you will need to apply separate animations to those views in their `actionForLayer` method. – cnotethegr8 Sep 08 '16 at 04:20
  • I just created a full solution. I'm traveling home now so I'll update this answer with the working example in a few hours. – cnotethegr8 Sep 08 '16 at 13:12
  • @cnotethegr8 do you have a source that told you about the changes in iOS 10 that you reference...I'm seeing other related issues and hoping to go to the source for info. – TahoeWolverine Sep 08 '16 at 22:33
  • No source, just through my understanding of UIKit and knowing about the new animation object in iOS 10 and experimenting with the issues was i able to provide all this information. If you ask maybe I can answer. – cnotethegr8 Sep 09 '16 at 05:13
  • Hi, thanks for the answer - presumably this doesnt work if you cell height is defined by layout constraints of the content? – Sam Oct 07 '16 at 10:02
  • @Sam, I'm not sure if I understand your question. It sounds like your talking about a scenario where the table view is unaware of the cells height and the content view is defining the cells height. However that would be way to much unnecessary work to manage so it couldn't be that I understood you correctly. – cnotethegr8 Oct 07 '16 at 12:06
  • @cnotethegr8 That's exactly the situation I'm talking about. i.e. when using `UITableViewAutomaticDimension` - see my answer.... – Sam Oct 07 '16 at 12:09
  • @Sam, ah of course. Momentarily forgot about the automatic dimension. I was thinking you were managing each cells origin etc. – cnotethegr8 Oct 07 '16 at 12:14
6

I don't use auto layout, instead I derive a class from UITableViewCell and use its "layoutSubviews" to set the frames of the subviews programmatically. So I tried to modify cnotethegr8's answer to fit my needs, and I came up with the following.

Reimplement "setFrame" of the cell, set the cell's content height to the cell height and trigger a relayout of the subviews in an animation block:

@interface MyTableViewCell : UITableViewCell
...
@end

@implementation MyTableViewCell

...

- (void) setFrame:(CGRect)frame
{
    [super setFrame:frame];

    if (self.window)
    {
        [UIView animateWithDuration:0.3 animations:^{
            self.contentView.frame = CGRectMake(
                                        self.contentView.frame.origin.x,
                                        self.contentView.frame.origin.y,
                                        self.contentView.frame.size.width,
                                        frame.size.height);
            [self setNeedsLayout];
            [self layoutIfNeeded];
        }];
    }
}

...

@end

That did the trick :)

Markus
  • 436
  • 3
  • 13
2

So I had this issue for a UITableViewCell that used layout constraints and a UITableView that used automatic cell heights (UITableViewAutomaticDimension). So I'm not sure how much of this solution will work for you but I'm putting it here as it's closely related.

The main realisation was to lower the priority of the constraints that cause the height change to happen:

self.collapsedConstraint = self.expandingContainer.topAnchor.constraintEqualToAnchor(self.bottomAnchor).priority(UILayoutPriorityDefaultLow)
self.expandedConstraint = self.expandingContainer.bottomAnchor.constraintEqualToAnchor(self.bottomAnchor).priority(UILayoutPriorityDefaultLow)

self.expandedConstraint?.active = self.expanded
self.collapsedConstraint?.active = !self.expanded

Secondly, the way in which I animate the tableview was very specific:

UIView.animateWithDuration(0.3) {

    let tableViewCell = tableView.cellForRowAtIndexPath(viewModel.index) as! MyTableViewCell
    tableViewCell.toggleExpandedConstraints()
    tableView.beginUpdates()
    tableView.endUpdates()
    tableViewCell.layoutIfNeeded()
}

Note that the layoutIfNeeded() had to come AFTER beginUpdates/endUpdates

I think this last part might be helpful to you....

Sam
  • 3,453
  • 1
  • 33
  • 57
  • Definitely an undervalued answer here. Lowering the priority on the constraints that alter the height made the difference for me. Thanks! – A Springham Sep 28 '18 at 10:37
1

Swift 4^

Had the jump issue when I was at the bottom of the table and tried on expanding and collapsing some rows in my tableView. I tried this solution first:

var cellHeights: [IndexPath: CGFloat] = [:]

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    cellHeights[indexPath] = cell.frame.size.height
}

func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    return cellHeights[indexPath] ?? UITableView.automaticDimension
}

But then, the issue recurred when I try to expand the row, scrolled up, then suddenly collapse the row. So I tried this:

let savedContentOffset = contentOffset

tableView.beginUpdates()
tableView.endUpdates()
tableView.layer.removeAllAnimations()

tableView.setContentOffset(savedContentOffset, animated: false)

This somehow fix it. Try both solutions and see what works for you. Hope this helps.

arvinq
  • 656
  • 6
  • 12
0

Faced the same issue on iOS 10 (everything was fine on iOS 9), the solution was to provide actual estimatedRowHeight and heightForRow via UITableViewDelegate methods implementation.

Cell's subviews positioned with AutoLayout, so I used UITableViewAutomaticDimension for height calculation (you can try set it as a tableView's rowHeight property).

- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {

    CGFloat result = kDefaultTableViewRowHeight;

    if (isAnimatingHeightCellIndexPAth) {
        result = [self precalculatedEstimatedHeight];
    }

    return result;
 }


- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return UITableViewAutomaticDimension;
}
0

Can't comment because of reputation but sam's solution worked for me on IOS 10.

 - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {

    EWGDownloadTableViewCell *cell = (EWGDownloadTableViewCell *)[tableView cellForRowAtIndexPath:indexPath];

    if (self.expandedRow == indexPath.row) {
        self.expandedRow = -1;
        [UIView animateWithDuration:0.3 animations:^{
            cell.constraintToAnimate.constant = 51;
            [tableView beginUpdates];
            [tableView endUpdates];
            [cell layoutIfNeeded];
        } completion:nil];
    } else {
        self.expandedRow = indexPath.row;
        [UIView animateWithDuration:0.3 animations:^{
            cell.constraintToAnimate.constant = 100;
            [tableView beginUpdates];
            [tableView endUpdates];
            [cell layoutIfNeeded];
        } completion:nil];
    } }

where constraintToAnimate is a height constraint on a subview added to the cells contentView.

byJeevan
  • 3,728
  • 3
  • 37
  • 60