0

I have my own cross platform widget layout system that I use for most things so I have never had to use layout constraints before, so this is my first dive into it. My code is in Objective-C not Swift and all of the examples I find are sadly in Swift. By default my UITableView will only show an Icon and Text. However it has extra "column" icons and text that would be in individual columns on MacOS or other desktop platforms. I have a toggle that will allow this additional information to be displayed in an either horizontal or vertical UIStackView. So if toggled I want to add the UIStackView to the UITableViewCell placed under the textLabel expanding the UITableViewCell by whatever size the UIStackView requires. My thought was I could do this by adding 3 constraints connecting the UIStackView, contentView and textLabel together. This didn't produce the expected result. The first two constraints correctly position the UIStackView below the textLabel but did not expand the UITableViewCell. Adding the third (bottom) constraint to expand the cell, instead clips the UIStackView out of the cell entirely. Can someone point out what I am doing wrong?

NSLayoutConstraint *constraint;

stack = [[[UIStackView alloc] init] retain];
[stack setTranslatesAutoresizingMaskIntoConstraints:NO];
[stack setSpacing:5.0];
[[self contentView] addSubview:stack];

/* Leading */
constraint = [NSLayoutConstraint constraintWithItem:stack attribute:NSLayoutAttributeLeft
                                          relatedBy:NSLayoutRelationEqual toItem:[self contentView]
                                          attribute:NSLayoutAttributeLeft multiplier: 1.0 constant:0.0];
[[self contentView] addConstraint:constraint];

/* Top */
constraint = [NSLayoutConstraint constraintWithItem:stack attribute:NSLayoutAttributeTop
                                          relatedBy:NSLayoutRelationEqual toItem:[self textLabel]
                                          attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0.0];
[[self contentView] addConstraint:constraint];

/* Bottom */
constraint = [NSLayoutConstraint constraintWithItem:[self contentView] attribute:NSLayoutAttributeBottom
                                          relatedBy:NSLayoutRelationEqual toItem:stack
                                          attribute:NSLayoutAttributeBottom multiplier:1.0 constant:0.0];
[[self contentView] addConstraint:constraint];
DBSoft
  • 21
  • 1
  • 3
  • Do you use dynamic height for you `UITableView` cells? I.e. did you set your table view's `rowHeight` (e.g. `tableView.rowHeight = UITableViewAutomaticDimension`) and `estimatedRowHeight` (e.g. `tableView.estimatedRowHeight = 40`)? – The Dreams Wind Sep 06 '22 at 20:10
  • BTW you don't need to `retain` an object when allocating it (no matter if ARC is enabled or not) – The Dreams Wind Sep 06 '22 at 20:11
  • +if memory serves me right, you don't need to add a constraint to views which are already partake in relationship, but the constraint itself needs to be activated (e.g. via `isActive` property) – The Dreams Wind Sep 06 '22 at 21:29
  • I didn't set the rowHeight or estimatedRowHeight to anything, I assumed that would be returned by the default methods in UITableViewCell.... I also didn't set the isActive property but it seems to be abiding by the first two constraints without activating... the third constraint also has an effect, just not the effect I was hoping for. I'll look into if I need to set the rowHeight to UITableViewAutomaticDimension somewhere... I wasn't even aware of that constant. – DBSoft Sep 07 '22 at 03:40
  • Apparently my calling addConstraint: is activating it... from the Apple isActive property documentation: "Activating or deactivating the constraint calls addConstraint: and removeConstraint: on the view that is the closest common ancestor of the items managed by this constraint. Use this property instead of calling addConstraint: or removeConstraint: directly." – DBSoft Sep 07 '22 at 03:48
  • You are correct, but as you can see documentation explicitly says to avoid using the methods `addConstraint:`/`removeConstraint:` – The Dreams Wind Sep 07 '22 at 07:27
  • Does this answer your question? [Using Auto Layout in UITableView for dynamic cell layouts & variable row heights](https://stackoverflow.com/questions/18746929/using-auto-layout-in-uitableview-for-dynamic-cell-layouts-variable-row-heights) – The Dreams Wind Sep 07 '22 at 07:27
  • I switched to using setActive: instead of addConstraint: ... I added code to setRowHeight:UITableViewAutomaticDimension setEstimatedRowHeight:85.0 .. added delegate methods to do the same but it did not change the behavior... I feel like I just don't understand how these layout constraints work. Here is a screenshot of the view layout / constraints in Xcode: [link](https://dbsoft.org/Screenshots/Screen_Shot_2022-09-07_at_2.56.13_AM.png) – DBSoft Sep 07 '22 at 08:06
  • Adding the bottom constraint causes the UIStackView height to become 0, causing it no not be displayed. Not adding the bottom constraint has the layout correct but the cell size is too small causing it to overflow onto other cells. – DBSoft Sep 07 '22 at 08:17
  • Constraints have verbose and confusing API, it takes time to master it (but doesn't make much sense since everybody just switched to SwiftUI nowadays). I made a minimal project [here](https://github.com/AlexandrSMed/SO-c-130031258_73626059-DynamicHeightCellDemo). Feel free to refer to it for the reference. The code you are interested in is inside of [`TDWDynamicHeightTableViewCell.m`](https://github.com/AlexandrSMed/SO-c-130031258_73626059-DynamicHeightCellDemo/blob/master/DynamicHeightCell/TDWDynamicHeightTableViewCell.m) – The Dreams Wind Sep 07 '22 at 12:00
  • Thanks for the example, but you removed the relationship to the embedded textLabel... I use the UITableViewCell built-in imageView and textLabel ... I only add the stack view when the additional column information is requested. So I want to put the stack below the built-in textLabel. Also I don't want the trailing constraint, because it stretches out the UIStackView... but I think I can work around that with UIStackView properties. – DBSoft Sep 07 '22 at 15:47
  • you can simply switch to `NSLayoutRelationLessThanOrEqual` for trailing constraint. It merely ensures that `UIStackView` never crosses the border of the container. For the first part, it's complicated, because when built-in `UITableViewCell` labels are used, they set their own constraints around the cell (both top and bottom) so you will have to remove them manually first. A way simpler approach is to add your own `UILabel` instance and set the constraints around it – The Dreams Wind Sep 07 '22 at 16:09

1 Answers1

0

You can save yourself a lot of typing, as well as making code much more readable, by using "modern" constraint syntax.

For example, if we've created a label and stack view, and added them to the cell's content view:

    // let's use the default margins
    UILayoutGuide *g = self.contentView.layoutMarginsGuide;

    [NSLayoutConstraint activateConstraints:@[

        // constrain label Top/Leading/Trailing to content margins guide
        [label.topAnchor constraintEqualToAnchor:g.topAnchor constant:0.0],
        [label.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:0.0],
        [label.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:0.0],
        //  no Height, because we'll use the label's intrinsic size

        // constrain stack view Top to label bottom plus 8-points
        [stack.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:8.0],
        // Leading/Trailing/Bottom to content margins guide
        [stack.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:0.0],
        [stack.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:0.0],
        [stack.bottomAnchor constraintEqualToAnchor:g.bottomAnchor constant:0.0],
        //  no Height, because we'll use the arranged subviews heights

    ]];

So, we can write a cell class like this:

StackTableViewCell.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN
@interface StackTableViewCell : UITableViewCell
@end
NS_ASSUME_NONNULL_END

StackTableViewCell.m

#import "StackTableViewCell.h"

@interface StackTableViewCell()
{
    UIStackView *stack;
    UILabel *label;
}
@end

@implementation StackTableViewCell

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        [self commonInit];
    }
    return self;
}
- (instancetype)init
{
    self = [super init];
    if (self) {
        [self commonInit];
    }
    return self;
}
- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self commonInit];
    }
    return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (void)commonInit {

    // create a label
    label = [UILabel new];
    [label setBackgroundColor:UIColor.greenColor];
    [label setNumberOfLines:0];

    // create a vertical stack view
    stack = [UIStackView new];
    [stack setAxis:UILayoutConstraintAxisVertical];
    [stack setSpacing:5.0];

    // add label and stack view to content view
    [label setTranslatesAutoresizingMaskIntoConstraints:NO];
    [stack setTranslatesAutoresizingMaskIntoConstraints:NO];
    [[self contentView] addSubview:label];
    [[self contentView] addSubview:stack];

    // let's use the default margins
    UILayoutGuide *g = self.contentView.layoutMarginsGuide;

    [NSLayoutConstraint activateConstraints:@[

        // constrain label Top/Leading/Trailing to content margins guide
        [label.topAnchor constraintEqualToAnchor:g.topAnchor constant:0.0],
        [label.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:0.0],
        [label.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:0.0],
        //  no Height, because we'll use the label's intrinsic size

        // constrain stack view Top to label bottom plus 8-points
        [stack.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:8.0],
        // Leading/Trailing/Bottom to content margins guide
        [stack.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:0.0],
        [stack.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:0.0],
        [stack.bottomAnchor constraintEqualToAnchor:g.bottomAnchor constant:0.0],
        //  no Height, because we'll use the arranged subviews heights

    ]];

    // let's give the stack view a border so we can see its frame
    stack.layer.borderColor = UIColor.redColor.CGColor;
    stack.layer.borderWidth = 1.0;

}

@end

along with a basic controller...

StackTableViewController.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN
@interface StackTableViewController : UITableViewController
@end
NS_ASSUME_NONNULL_END

StackTableViewController.m

#import "StackTableViewController.h"
#import "StackTableViewCell.h"

@interface StackTableViewController ()

@end

@implementation StackTableViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    [self.tableView registerClass:StackTableViewCell.class forCellReuseIdentifier:@"c"];
}

#pragma mark - Table view data source

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 30;
}


- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    StackTableViewCell *cell = (StackTableViewCell *)[tableView dequeueReusableCellWithIdentifier:@"c" forIndexPath:indexPath];

    // Configure the cell...
    return cell;
}

@end

At this point, because we're not giving the cells any data - and labels and stack view have no intrinsic height when empty - it will look like this:

enter image description here

So, let's add a fillData method:

StackTableViewCell.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN
@interface StackTableViewCell : UITableViewCell
// add this line
- (void)fillData:(NSInteger)n;
@end
NS_ASSUME_NONNULL_END

StackTableViewCell.m

// add this method
- (void)fillData:(NSInteger)n {

    label.text = [NSString stringWithFormat:@"Cell %ld", (long)n];
    
    // cells are reused, so first clear any existing labels in the stack view
    //  this is inefficient, but we're just demonstrating the cell sizing
    for (UIView *v in stack.arrangedSubviews) {
        [v removeFromSuperview];
    }
    
    // now add n number of labels
    for (int i = 0; i < n; i++) {
        UILabel *v = [UILabel new];
        v.text = [NSString stringWithFormat:@"Stack Label %ld", (long)i];
        v.backgroundColor = UIColor.yellowColor;
        [stack addArrangedSubview:v];
    }
    
}

and modify the controller to set some cell data:

StackTableViewController.m

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    StackTableViewCell *cell = (StackTableViewCell *)[tableView dequeueReusableCellWithIdentifier:@"c" forIndexPath:indexPath];

    // Configure the cell...
    [cell fillData:(indexPath.row % 4) + 1];
    
    return cell;
}

and now the output looks like this:

enter image description here

DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks I appreciate the example, it is the first time I have seen the "modern syntax" for constraints in Objective-C (and I've been looking... only seen it in Swift up to this point). Would it be possible to not create a new UILabel and instead use the UITableViewCell's built in textLabel? – DBSoft Sep 07 '22 at 15:08
  • @DBSoft - do you mean you want to use the "built in textLabel" ***and*** add other subviews? If so, no, you don't want to do that. The default cell's `.textLabel` has its own constraints already setup to fill the `contentView` - you'd have to remove at least one of those constraints, which is more work (and far more prone to introducing new layout issues) than adding your own label. – DonMag Sep 07 '22 at 16:05
  • I see that is disappointing... but it may explain some of my troubles with my approach. Thanks. – DBSoft Sep 08 '22 at 03:06
  • @DBSoft - as a general rule... you only want to use the default `UITableViewCell` ***only with its default properties*** ... trying to add subviews - or even modifying properties of the "built-in" subviews - will almost always lead to problems down the road. – DonMag Sep 08 '22 at 12:55
  • I was basically hoping to just use it default in most instances, and then just add the single stack view at the bottom when necessary to display some extra information... that seemed to be the most simple. So it seems I have to make a custom cell, recreating most of the default behavior just so I can occasionally add a stack view at the bottom? Do you have an easy way to design the constraints so I can add the stack view as needed? Or should I just always create the stack view and only put stuff in it as necessary? What do you recommend? – DBSoft Sep 08 '22 at 18:52
  • @DBSoft - difficult to "recommend" anything without some more details about what you're trying to do. Will you have 2 or 3 distinctly different cell layouts? Will you have **50**? And by distinctly different, I don't mean "a cell can have 1 to 10 'lines' of text" -- that would be one layout, not 10. Maybe show some mocked-up layout variations? – DonMag Sep 08 '22 at 19:23
  • Well this is a cross platform API... on desktop platforms there are an arbitrary number of columns with icons or text. On mobile platforms the default will just be text and an optional icon... I want to have two additional modes where any additional columns will be displayed either vertically or horizontally depending on the data. This is why I tried to use a UIStackView and just tack it on to the bottom of the default UITableViewCell. Since you say I shouldn't do that... I will need to have a UIImageView and UILabel... then a UIStackView beneath them when the mode is set to non-default. – DBSoft Sep 09 '22 at 07:40