Search code examples
iosuitableviewuikituistackviewuianimation

UITableView in UIStackView - smooth expand and collapse animation


I am trying to implement a table view that allows the user to "show more" rows to toggle the full number of rows or a smaller number of rows. I have all the data upfront when the view is loaded, so I do not need to fetch any extra data.

I am able to get it working, but the problem I am having is that my table view does not smoothly animate when expanding or collapsing. What happens is that if the "show more" action is triggered, the size of the table is updated all at once to the full height of the table with all the data in it. Then the rows will animate.

Likewise when hiding rows, the table height will shrink all at once to the end height and then the rows will animate.

Here is a picture of what is happening. It just "jumps" to the full height and then it animates the rows. But what I would like to happen would be for it to smoothly expand the height of the table unveiling the data as it smoothly expands downward. I would like the opposite, where it smoothly slides upward when pressing "show less".

enter image description here

The way my app is laid out is as follows:

  • UIScrollView
    • UIStackView
      • UIViewController
      • UIViewController
      • UITableView <-- the section I am working on here
      • UIView
      • ...

Basically, I have a scrollable list of sections of different data. Some sections are tables, some are just ad-hoc views arranged with AutoLayout, others have collection views or page view controllers or other types of views.

But this section is represented by the UITableView in the previous list.

#import "MyTableView.h"
#import "MyAutosizingTableView.h"

@interface MyTableView ()

@property (strong, nonatomic) UILabel *titleLabel;
@property (strong, nonatomic) MyAutosizingTableView *tableView;

@end

@implementation MyTableView

- (instancetype)init {
    return [self initWithFrame:CGRectZero];
}

- (instancetype)initWithCoder:(NSCoder *)coder {
    if (self = [super initWithCoder:coder]) {
        [self initialize];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        [self initialize];
    }
    return self;
}

- (void)initialize {
    [self.titleLabel setText:@"Details"];
    self.numberOfRows = 3;
    [self addSubview:self.titleLabel];
    [self addSubview:self.tableView];
    
    [NSLayoutConstraint activateConstraints:@[
        [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.leadingAnchor],
        [self.titleLabel.topAnchor constraintEqualToAnchor:self.layoutMarginsGuide.topAnchor],
        [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.layoutMarginsGuide.trailingAnchor],
        
        [self.tableView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
        [self.tableView.topAnchor constraintEqualToSystemSpacingBelowAnchor:self.titleLabel.bottomAnchor multiplier:1.0f],
        [self.tableView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
        [self.tableView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
        [self.tableView.widthAnchor constraintEqualToAnchor:self.widthAnchor]
    ]];
}

- (UITableView *)tableView {
    if (!self->_tableView) {
        self->_tableView = [[MyAutosizingTableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
        self->_tableView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_tableView.rowHeight = UITableViewAutomaticDimension;
        self->_tableView.estimatedRowHeight = UITableViewAutomaticDimension;
        self->_tableView.allowsSelection = NO;
        self->_tableView.scrollEnabled = NO;
        self->_tableView.delegate = self;
        self->_tableView.dataSource = self;
        [self->_tableView registerClass:[MyTableViewCell class] forCellReuseIdentifier:@"dataCell"];
    }
    return self->_tableView;
}

- (UILabel *)titleLabel {
    if (!self->_titleLabel) {
        self->_titleLabel = [[UILabel alloc] init];
        self->_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
        self->_titleLabel.numberOfLines = 0;
        self->_titleLabel.userInteractionEnabled = YES;
        self->_titleLabel.textAlignment = NSTextAlignmentNatural;
        self->_titleLabel.lineBreakMode = NSLineBreakByWordWrapping;
        self->_titleLabel.baselineAdjustment = UIBaselineAdjustmentAlignBaselines;
        self->_titleLabel.adjustsFontSizeToFitWidth = NO;
        
        UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showMore)];
        [self->_titleLabel addGestureRecognizer:tapGesture];
    }
    return self->_titleLabel;
}

- (void)showMore {
    if (self.numberOfRows == 0) {
        self.numberOfRows = 3;
    } else {
        self.numberOfRows = 0;
    }
    
    NSIndexSet *indexes = [NSIndexSet indexSetWithIndex:0];
    [self.tableView reloadSections:indexes withRowAnimation:UITableViewRowAnimationFade];
}

- (void)setData:(NSArray<NSString *> *)data {
    self->_data = data;
    [self.tableView reloadData];
}


- (void)didMoveToSuperview {
    //this has no effect unless the view is already in the view hierarchy
    self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
}

#pragma mark - UITableViewDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if (self.numberOfRows <= 0) {
        return self.data.count;
    }
    return MIN(self.data.count, self.numberOfRows);
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSString *data = self.data[indexPath.row];
    MyTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"dataCell" forIndexPath:indexPath];
    cell.data = data;
    return cell;
}

@end

I have a UITableView subclass that I used in the previous code. The reason I have the table view subclass is to get the table to resize when the content changes. If anyone knows a better way to do this without the subclass, I would be very interested in learning that too.

#import "MyAutosizingTableView.h"

@implementation MyAutosizingTableView

- (CGSize)intrinsicContentSize {
    return self.contentSize;
}

- (void)setContentSize:(CGSize)contentSize {
    [super setContentSize:contentSize];
    [self invalidateIntrinsicContentSize];
}

@end

Does anyone know what I can do to get smooth expansion animations? I would really like the table to expand smoothly and reveal the data that is in the new rows.


Update - a high level overview of what I am trying to do

Here I am going to try to describe what it is I am trying to accomplish and how I am going about it, so if anyone has any alternative approaches or improvements for laying this out, I would be very open to suggestions. Thank you in advance for reading this.

So I am working on a view controller that displays data about a product, imagine a car for example. The view controller has different sections responsible for displaying different data. For example, the first section displays pictures of the item and for this section, I am using a UIPageView that displays basically a carousel of product images.

Another section displays options about the current product. For example, someone might choose a different color for the car or different wheels. For this section, I am using a table view to display the list of available attributes, where each attribute is in a different section of the table. Upon pressing the section header, the table view adds a row that displays the available options for that attribute. For instance, assume the car has different colors available (red, green, blue, and yellow). Upon pressing the section header, the table view adds a row and animates that in (using the technique discussed in chapter 8 of "Programming iOS 12"). The row that is shown then contains a UICollectionView that scrolls horizontally allowing the user to choose between the colors (or wheel options or whatever attribute is being changed).

Another section displays what other customers have said about this product. For example, if someone leaves a writeup on the car, then that would go in this section. This section is has a table showing how the product fares across different criteria. Using the car example still, it might have a row for comfort and another for gas mileage and another for performance and so on. Then there is also a horizontal UICollectionView displaying the write ups about the product.

Yet another section is a list of attributes for the product. For example, with the car it might show engine size on the left and V12 on the right.

And there are other sections as well, but then to tie them all together I have a scroll view with a vertical UIStackView inside it. And this is what I am really curious about. What is the best way to display or layout all these sections? Is a stack view the way to go or should I use a table view or should I just have a scroll view with these views hooked together just using AutoLayout directly or is there some better way to do it than all of these?

One thing that is very important is that each section can be different sizes between products. For example, some sections might be one height for one product but then a completely different height for other products. So I need it to be able to dynamically size each section and I also need to be able to animate some size changes (for example, the section where it adds a row to the table and then removes it, that needs to animate and make the whole section larger in an animated fashion).

Also, I would like each section to be modular and to not end up having a giant view controller that manages everything. I would like each section to essentially be self contained. I just pass data to that section and then it handles everything related to layout and interaction. But again, the real issue is getting those views to be able to resize and to have that update in the parent view (stack view currently).

But I am really new to iOS development, and while I have been trying to learn as fast as I can, I am still not sure if the way that I am doing this currently (the scroll view with the stack view inside it) is the best approach or if there are better approaches. I tried this initially in a table view with static cells where each row was a different section. But then getting the child view controllers to resize the row in the parent table view was not working that well. Since then I have also changed from making each section be a separate view controller and just have each section be a view (rather than view controller) to make resizing hopefully easier, but I am still running into issues. I have also considered collection views, but I also need to be able to support iOS 12 (or I could drop iOS 12 support if I really need to and just support iOS 13+).

But to restate, my overall goals are to have some way to layout this interface which consists of different views that each handle different data. I would like each view to be able to resize and for that to be able to be smooth. And I would like each section of the interface to be modular so as to avoid one large view controller.

Update 2

After following the suggestion to wrap the table view in a UIView and then set the two bottom constraints with different priorities, this is what I am getting. I also get this effect anytime I try to animate things like the height constraint of views. I think this is because the overall view here is contained inside a stack view, so I think the stack view is doing something when its arranged subview changes size.

The picture here shows both expanding the table view and collapsing it. When it is collapsed it has three rows visible and when it is expanded it has five rows.

The yellow and blue views are representative of other views on the page. They are not part of this view and neither is the stack view they are contained within. They are just other parts of the page included here to show this issue. All the views here are contained in a stack view and I think that is what is causing the issue with the animation, but I am not sure how to fix it.

enter image description here


Solution

  • You really have a bunch of questions here - but to address specifically the issue with trying to expand/collapse your table view...

    I think you'll be fighting a losing battle with this approach. By trying to use an "auto-sizing" table view, you're counter-acting much of a table view's behaviors/ And, since you are not getting the benefit of memory management with reusable cells, you're probably better off formatting your "spec list" with a vertical stack view.

    In either case, to create a collapse / expand animation with a "reveal / hide" effect, you may want to embed the tableView or stackView in a "container" view, and then toggle constraint priorities for the height (the bottom) of the container.

    Here is a quick example...

    • create a "container" UIView
    • add a vertical stack view to the container
    • add labels to the stack view
    • add a red UIView to place above the container
    • add a blue UIView to place below the container
    • define constraints for the height of the container

    On each tap, the container will expand / collapse to reveal / hide the labels.

    #import <UIKit/UIKit.h>
    
    @interface ViewController : UIViewController
    @end
    
    @interface ViewController ()
    
    @property (strong, nonatomic) NSLayoutConstraint *collapsedConstraint;
    @property (strong, nonatomic) NSLayoutConstraint *expandedConstraint;
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        UIStackView *labelsStackView = [UIStackView new];
        labelsStackView.axis = UILayoutConstraintAxisVertical;
        labelsStackView.spacing = 4;
        
        // let's add 12 labels to the stack view (12 "rows")
        for (int i = 1; i < 12; i++) {
            UILabel *v = [UILabel new];
            v.text = [NSString stringWithFormat:@"Label %d", i];
            [labelsStackView addArrangedSubview:v];
        }
        
        // add a view to show above the stack view
        UIView *redView = [UIView new];
        redView.backgroundColor = [UIColor redColor];
        
        // add a view to show below the stack view
        UIView *blueView = [UIView new];
        blueView.backgroundColor = [UIColor blueColor];
    
        // add a "container" view for the stack view
        UIView *cView = [UIView new];
        
        // clip the container's subviews
        cView.clipsToBounds = YES;
        
        for (UIView *v in @[redView, cView, blueView]) {
            v.translatesAutoresizingMaskIntoConstraints = NO;
            [self.view addSubview:v];
        }
        
        // add the stackView to the container
        labelsStackView.translatesAutoresizingMaskIntoConstraints = NO;
        [cView addSubview:labelsStackView];
        
        // constraints
        UILayoutGuide *g = [self.view safeAreaLayoutGuide];
        
        // when expanded, we'll use the full height of the stack view
        _expandedConstraint = [cView.bottomAnchor constraintEqualToAnchor:labelsStackView.bottomAnchor];
        
        // when collapsed, we'll use the bottom of the 3rd label in the stack view
        UILabel *v = labelsStackView.arrangedSubviews[2];
        _collapsedConstraint = [cView.bottomAnchor constraintEqualToAnchor:v.bottomAnchor];
        
        // start collapsed
        _expandedConstraint.priority = UILayoutPriorityDefaultLow;
        _collapsedConstraint.priority = UILayoutPriorityDefaultHigh;
        
        [NSLayoutConstraint activateConstraints:@[
            
            // redView Top / Leading / Trailing / Height=120
            [redView.topAnchor constraintEqualToAnchor:g.topAnchor constant:20.0],
            [redView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
            [redView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
            [redView.heightAnchor constraintEqualToConstant:120.0],
            
            // container Top==redView.bottom / Leading / Trailing / no height
            [cView.topAnchor constraintEqualToAnchor:redView.bottomAnchor constant:0.0],
            [cView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
            [cView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
            
            // blueView Top==stackView.bottom / Leading / Trailing / Height=160
            [blueView.topAnchor constraintEqualToAnchor:cView.bottomAnchor constant:0.0],
            [blueView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
            [blueView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
            [blueView.heightAnchor constraintEqualToConstant:160.0],
            
            // stackView Top / Leading / Trailing
            [labelsStackView.topAnchor constraintEqualToAnchor:cView.topAnchor],
            [labelsStackView.leadingAnchor constraintEqualToAnchor:cView.leadingAnchor],
            [labelsStackView.trailingAnchor constraintEqualToAnchor:cView.trailingAnchor],
    
            _expandedConstraint,
            _collapsedConstraint,
            
        ]];
    
    }
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
        // toggle priority between expanded / collapsed constraints
        if (_expandedConstraint.priority == UILayoutPriorityDefaultHigh) {
            _expandedConstraint.priority = UILayoutPriorityDefaultLow;
            _collapsedConstraint.priority = UILayoutPriorityDefaultHigh;
        } else {
            _collapsedConstraint.priority = UILayoutPriorityDefaultLow;
            _expandedConstraint.priority = UILayoutPriorityDefaultHigh;
        }
        // animate the change
        [UIView animateWithDuration:0.5 animations:^{
            [self.view layoutIfNeeded];
        }];
        
    }
    
    @end
    

    You may be able to implement that with your "auto-sizing" table view, but again, since you're not using the "normal" table view behaviors (scrolling, reusable cells, etc), this might be a better approach.

    Hopefully, it can also give you some insight to sizing other views in your layout - particularly if you want to animate them in or out of view.

    Edit - added a more complex example here: https://github.com/DonMag/Scratch2021

    It uses a vertical stack view in a vertical scroll view as the "main view" UI, adding child view controllers as "components" in the stack view.

    enter image description hereenter image description here