'UIStackView change item spacing as stack view changes sizes

Overview

I have a stack view that has multiple circle views in it. The circle views could be images (like profile pictures) or anything. These views should be able to overlap if the size of the stack view is too small for the subviews. And the views should spread out if the stack view is too big for the subviews. Also, subviews can be added or removed dynamically, even if the size of the stack view doesn't change.

For example, in the following image the top stack view has these circle views that are overlapping and everything is working fine there (the frame is exactly the size of the subviews views). But then, looking at the second stack view, after adding a few more views, the first view gets compressed. But what I want to happen is for all of the views to overlap a bit more and to not compress any of the views.

enter image description here

Question

What is the best way to implement this behavior? Should I override layoutSubviews, like I am proposing in the next section, or is there a better way to implement this? Again, I just want the views to either spread out, if the stack view is too large for them, or for them to overlap each other, if the stack view is too narrow. And the stack view can change size at any time and also arranged subviews can be added or removed at any time, and all of those things should cause the view spacing to be recalculated.

Proposed Solution

I was considering overriding the layoutSubviews method of the stack view and then somehow measuring all of the views, adding those widths together, and then the spacing that is currently present (I guess go through each of the arranged subviews and see what the spacing is for that subview). So it would be negative spacing for overlap or positive spacing if the items are actually spread out. Then, I would compare that width with the frame in layoutSubviews and if it was too wide, then I would decrease the spacing. Otherwise, if the views did not take up the full stack view, then I would increase their spacing.

Here is my code and the proposed algorithm in layoutSubviews.

Code

MyShelf.h

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, MyShelfItemShape) {
    MyShelfItemShapeNone = 0,
    MyShelfItemShapeCircular
};

@interface MyShelf : UIStackView

@property (assign, nonatomic) CGSize itemSize;
@property (assign, nonatomic) MyShelfItemShape itemShape;
@property (strong, nonatomic) UIColor *itemBorderColor;
@property (assign, nonatomic) CGFloat itemBorderWidth;

@property (assign, nonatomic) CGFloat preferredMinimumSpacing;
@property (assign, nonatomic) CGFloat preferredMaximumSpacing;

#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated;

@end

NS_ASSUME_NONNULL_END

MyShelf.m

#import "MyShelf.h"

@interface MyShelf ()

@property (strong, nonatomic) UIStackView *stackView;

@end

@implementation MyShelf

#pragma mark - Initializing the View
- (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.spacing = -10;
    self.axis = UILayoutConstraintAxisHorizontal;
    self.alignment = UIStackViewAlignmentCenter;
    self.distribution = UIStackViewDistributionFillProportionally;
    self.itemSize = CGSizeZero;
    self.itemShape = MyShelfItemShapeNone;
    self.itemBorderColor = [UIColor blackColor];
    self.itemBorderWidth = 1.0;
}

- (void)layoutSubviews {
    //if the new frame is different from the old frame
        //if the size of the items in the stack view is too large, reduce the spacing down to a minimum of preferredMinimumSpacing
        //else if the size of the items in the stack view is too small, increase the spacing up to a maximum of preferredMaximumSpacing
        //otherwise keep the spacing as-is
    [super layoutSubviews];
}

#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated {
    CGFloat height = MAX(view.bounds.size.height, view.bounds.size.width);
    
    if (!CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
        [NSLayoutConstraint activateConstraints:@[
            [view.widthAnchor constraintEqualToConstant:self.itemSize.width],
            [view.heightAnchor constraintEqualToConstant:self.itemSize.height]
        ]];
        height = MAX(self.itemSize.height, self.itemSize.width);
    }
    
    switch (self.itemShape) {
        case MyShelfItemShapeNone:
            break;
        case MyShelfItemShapeCircular:
            view.layer.cornerRadius = height / 2.0;
            break;
    }
    
    view.layer.borderColor = self.itemBorderColor.CGColor;
    view.layer.borderWidth = self.itemBorderWidth;

    
    if (animated) {
        //prepare the view to be initially hidden so it can be animated in
        view.alpha = 0.0;
        view.hidden = YES;

        [super insertArrangedSubview:view atIndex:stackIndex];

        [UIView animateWithDuration:0.25
                              delay:0
                            options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{ view.alpha = 1.0; view.hidden = NO; }
                         completion:nil];
    } else {
        [super insertArrangedSubview:view atIndex:stackIndex];
    }
    
    [self reorderArrangedSubviews];
}

- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex {
    [self insertArrangedSubview:view atIndex:stackIndex animated:NO];
}

- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated {
    [self insertArrangedSubview:view atIndex:self.arrangedSubviews.count animated:animated];
}

- (void)addArrangedSubview:(UIView *)view {
    [self addArrangedSubview:view animated:NO];
}

- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated {
    if (animated) {
        [UIView animateWithDuration:0.25
                              delay:0
                            options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{ view.alpha = 0.0; view.hidden = YES; }
                         completion:^(BOOL finished) { [super removeArrangedSubview:view]; }];
    } else {
        [super removeArrangedSubview:view];
    }
}


- (void)reorderArrangedSubviews {
    for (__kindof UIView *arrangedSubview in self.arrangedSubviews) {
        [self sendSubviewToBack:arrangedSubview];
    }
}

@end

Requirements

If the view is a fixed width

For this case, the view that contains these circle subviews is a fixed width. It could be that it has a width constraint that specifies the number of points wide it is or it could be constrained by other views such that its width is predetermined.

In this case, the subviews should be arranged next to each other until they can no longer fit in the frame, and at which point they start to collapse (negative spacing between items). enter image description here

If the view is a flexible width

For this case, the view that contains the circular subviews doesn't have a width specified. Instead, its width is determined by the width of the contents. So it should keep growing up until it can no longer grow, and at which point, then the subviews start to overlap. enter image description here



Solution 1:[1]

The general idea is to use centerX constraints on your circle views - I'll call them ShelfItem, and constrain them to an "invisible positioning view."

The reason to do that, is because when the item's centerX is on the leading edge (or trailing edge) half of it will extend to the left or right of the positioning view.

Think about dividing a width into equal parts (all values are in %)...

If we have 3 items, we need 2 equal parts. To get the percentage spacing, we use 1.0 / (numItems - 1):

enter image description here

With 4 items, we need 3 equal parts:

enter image description here

With 5 items, we need 4 equal parts:

enter image description here

And with 6 items, we need 5 equal parts:

enter image description here

So, by making the "item" views subviews of the "positioning" view, we can loop through and set their centerX constraints like this:

UIView *thisItem;
CGFloat pct = 1.0 / (CGFloat)([subviews count] - 1);

for (int i = 0; i < subviews.count; i++) {
    thisItem = subviews[i];
    
    CGFloat thisPCT = pct * i;
    
    // centerX as a percentage of positionView width
    NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                         attribute:NSLayoutAttributeCenterX
                                                         relatedBy:NSLayoutRelationEqual
                                                            toItem:positionView
                                                         attribute:NSLayoutAttributeTrailing
                                                        multiplier:thisPCT
                                                          constant:0.0];
    
    c.active = YES;
}

It's not quite that simple though...

First, auto-layout doesn't like a multiplier: 0.0 ... so the left-most item needs to have centerX equal to positioning view Leading.

The second thing is that your layout requires the item views to be left-aligned when there is enough room, not evenly spaced.

To accomplish that, we'll make each item view's centerX lessThanOrEqualTo the previous item's centerX + itemWidth... and we'll give the "percentage" constraints a less-than-required priority.

So, each time we add (or remove) an item, we'll call a method to update the centerX constraints... it will look about like this:

// clear existing centerX constraints
for (NSLayoutConstraint *oldC in positionView.constraints) {
    if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
        oldC.active = NO;
    }
}

// item views are top-down left-to-right, so reverse the order of the subviews
NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];

// constraints don't like multiplier:0.0
//  so first item centerX will always be equal to positionView's Leading
UIView *thisItem = reversedArray[0];

[NSLayoutConstraint constraintWithItem:thisItem
                             attribute:NSLayoutAttributeCenterX
                             relatedBy:NSLayoutRelationEqual
                                toItem:positionView
                             attribute:NSLayoutAttributeLeading
                            multiplier:1.0
                              constant:0.0].active = YES;

// percentage for remaining item spacing
//  examples:
//      we have 3 items
//          item 0 centerX is at leading
//          item 1 centerX is at 50%
//          item 2 centerX is at 100%
//      we have 4 items
//          item 0 centerX is at leading
//          item 1 centerX is at 33.333%
//          item 2 centerX is at 66.666%
//          item 3 centerX is at 100%

CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);

UIView *prevItem;

for (int i = 1; i < reversedArray.count; i++) {
    prevItem = thisItem;
    thisItem = reversedArray[i];

    CGFloat thisPCT = pct * i;
    
    // keep items next to each other (left-aligned) when overlap is not needed
    [thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
    
    // centerX as a percentage of positionView width
    NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                         attribute:NSLayoutAttributeCenterX
                                                         relatedBy:NSLayoutRelationEqual
                                                            toItem:positionView
                                                         attribute:NSLayoutAttributeTrailing
                                                        multiplier:thisPCT
                                                          constant:0.0];

    // needs less-than-required priority so "left-aligned" constraint can be enforced
    c.priority = UILayoutPriorityRequired - 1;
    c.active = YES;
}

The last task is to add a "framing" view that will match the bounds of the laid-out item views.

Here's a complete example...

ShelfItem.h - a simple round view with a label

#import <UIKit/UIKit.h>
@interface ShelfItem : UIView
@property (strong, nonatomic) UILabel *label;
@end

ShelfItem.m

#import "ShelfItem.h"

@implementation ShelfItem

- (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 {
    self.backgroundColor = UIColor.whiteColor;
    _label = [UILabel new];
    _label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightLight];
    _label.translatesAutoresizingMaskIntoConstraints = NO;
    [self addSubview:_label];
    [_label.centerXAnchor constraintEqualToAnchor:self.centerXAnchor].active = YES;
    [_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = YES;
    self.layer.borderColor = UIColor.blueColor.CGColor;
    self.layer.borderWidth = 1.0;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    self.layer.cornerRadius = self.bounds.size.height * 0.5;
}

@end

ShelfView.h - our view that does all the work

#import <UIKit/UIKit.h>

@interface ShelfView : UIView
- (void)addItem:(NSInteger)n;
- (void)removeItem;
@end

ShelfView.m

#import "ShelfView.h"
#import "ShelfItem.h"

@interface ShelfView () {
    UIView *positionView;
    UIView *framingView;
    CGFloat itemWidth;
    NSLayoutConstraint *framingViewTrailingConstraint;
}

@end

@implementation ShelfView

- (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 {
    
    itemWidth = 60.0;
    
    // framingView will match the bounds of the items
    //  it will not be their superView, but will look like it
    framingView = [UIView new];
    framingView.translatesAutoresizingMaskIntoConstraints = NO;
    framingView.backgroundColor = UIColor.systemYellowColor;
    [self addSubview:framingView];
    
    // positionView is used for the item position constraints
    //  but is not seen
    positionView = [UIView new];
    positionView.translatesAutoresizingMaskIntoConstraints = NO;
    positionView.backgroundColor = UIColor.clearColor;
    [self addSubview:positionView];
    
    // initialize framingView trailing constraint -- it will be updated in updatePositions
    framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:positionView.leadingAnchor];
    framingViewTrailingConstraint.priority = UILayoutPriorityRequired;
    
    [NSLayoutConstraint activateConstraints:@[
        
        // positioning view is at vertical center with no height
        [positionView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor],
        [positionView.heightAnchor constraintEqualToConstant:0.0],
        // leading and trailing are 1/2 the item width
        [positionView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:itemWidth * 0.5],
        [positionView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-itemWidth * 0.5],
        
        // framing view leading is at positioning view leading minus 1/2 item width
        [framingView.leadingAnchor constraintEqualToAnchor:positionView.leadingAnchor constant:-itemWidth * 0.5],
        // constrained top and bottom
        [framingView.topAnchor constraintEqualToAnchor:self.topAnchor],
        [framingView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
        
    ]];
    
}

- (void)updatePositions {
    
    if ([positionView.subviews count] == 0) {
        // no items, so all we have to do is update the framing view
        framingViewTrailingConstraint.active = NO;
        framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:self.leadingAnchor];
        framingViewTrailingConstraint.active = YES;
        return;
    }
    
    // clear existing centerX constraints
    for (NSLayoutConstraint *oldC in positionView.constraints) {
        if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
            oldC.active = NO;
        }
    }
    
    // item views are top-down left-to-right, so reverse the order of the subviews
    NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];
    
    // constraints don't like multiplier:0.0
    //  so first item centerX will always be equal to positionView's Leading
    UIView *thisItem = reversedArray[0];
    
    [NSLayoutConstraint constraintWithItem:thisItem
                                 attribute:NSLayoutAttributeCenterX
                                 relatedBy:NSLayoutRelationEqual
                                    toItem:positionView
                                 attribute:NSLayoutAttributeLeading
                                multiplier:1.0
                                  constant:0.0].active = YES;
    
    // percentage for remaining item spacing
    //  examples:
    //      we have 3 items
    //          item 0 centerX is at leading
    //          item 1 centerX is at 50%
    //          item 2 centerX is at 100%
    //      we have 4 items
    //          item 0 centerX is at leading
    //          item 1 centerX is at 33.333%
    //          item 2 centerX is at 66.666%
    //          item 3 centerX is at 100%

    CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);
    
    UIView *prevItem;
    
    for (int i = 1; i < reversedArray.count; i++) {
        prevItem = thisItem;
        thisItem = reversedArray[i];

        CGFloat thisPCT = pct * i;
        
        // keep items next to each other (left-aligned) when overlap is not needed
        [thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
        
        // centerX as a percentage of positionView width
        NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                             attribute:NSLayoutAttributeCenterX
                                                             relatedBy:NSLayoutRelationEqual
                                                                toItem:positionView
                                                             attribute:NSLayoutAttributeTrailing
                                                            multiplier:thisPCT
                                                              constant:0.0];

        // needs less-than-required priority so "left-aligned" constraint can be enforced
        c.priority = UILayoutPriorityRequired - 1;
        c.active = YES;
    }
    
    // update the trailing anchor of the framing view to the last shelf item
    framingViewTrailingConstraint.active = NO;
    framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:thisItem.trailingAnchor];
    framingViewTrailingConstraint.active = YES;

}

- (void)addItem:(NSInteger)n {
    
    // create a new shelf item
    ShelfItem *v = [ShelfItem new];
    v.translatesAutoresizingMaskIntoConstraints = NO;
    v.label.text = [NSString stringWithFormat:@"%ld", (long)n];
    
    // add it as a subview of positionView
    //  at index Zero (so it will be underneath existing items)
    [positionView insertSubview:v atIndex:0];
    
    // width and height
    [v.widthAnchor constraintEqualToConstant:itemWidth].active = YES;
    [v.heightAnchor constraintEqualToAnchor:v.widthAnchor].active = YES;
    
    // vertically centered on positionView
    [v.centerYAnchor constraintEqualToAnchor:positionView.centerYAnchor constant:0.0].active = YES;
    
    // update all shelf items
    [self updatePositions];
    
}
- (void)removeItem {
    
    // remove the last-added item
    [positionView.subviews[0] removeFromSuperview];
    
    // update all shelf items
    [self updatePositions];
    
}

@end

ViewController.h - controller with two ShelfViews and Add / Remove buttons:

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
@end

ViewController.m

#import "ViewController.h"

#import "ShelfView.h"

@interface ViewController ()
{
    ShelfView *shelfViewA;
    ShelfView *shelfViewB;
    NSInteger counter;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    counter = 0;
    
    // top shelf view has systemYellow background, so we see its
    //  full width all the time
    shelfViewA = [ShelfView new];
    shelfViewA.translatesAutoresizingMaskIntoConstraints = NO;
    shelfViewA.backgroundColor = UIColor.systemYellowColor;
    [self.view addSubview:shelfViewA];
    
    // second shelf view has clear background, so we only see its
    //  framing view width when items are added
    shelfViewB = [ShelfView new];
    shelfViewB.translatesAutoresizingMaskIntoConstraints = NO;
    shelfViewB.backgroundColor = UIColor.clearColor;
    [self.view addSubview:shelfViewB];
    
    UIButton *addBtn = [UIButton new];
    addBtn.translatesAutoresizingMaskIntoConstraints = NO;
    addBtn.backgroundColor = UIColor.systemGreenColor;
    [addBtn setTitle:@"Add" forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    [addBtn addTarget:self action:@selector(addTapped) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:addBtn];
    
    UIButton *removeBtn = [UIButton new];
    removeBtn.translatesAutoresizingMaskIntoConstraints = NO;
    removeBtn.backgroundColor = UIColor.systemGreenColor;
    [removeBtn setTitle:@"Remove" forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    [removeBtn addTarget:self action:@selector(removeTapped) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:removeBtn];

    UILabel *info = [UILabel new];
    info.translatesAutoresizingMaskIntoConstraints = NO;
    info.backgroundColor = [UIColor colorWithWhite:0.90 alpha:1.0];
    info.textAlignment = NSTextAlignmentCenter;
    info.numberOfLines = 0;
    info.text = @"Shelf View Width\n60-pts on each side.";
    [self.view addSubview:info];
    
    // respect safeArea
    UILayoutGuide *g = self.view.safeAreaLayoutGuide;
    
    [NSLayoutConstraint activateConstraints:@[
        
        [shelfViewA.topAnchor constraintEqualToAnchor:g.topAnchor constant:60.0],
        [shelfViewA.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [shelfViewA.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
        [shelfViewA.heightAnchor constraintEqualToConstant:60.0],
        
        [info.topAnchor constraintEqualToAnchor:shelfViewA.bottomAnchor constant:8.0],
        [info.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [info.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],

        [shelfViewB.topAnchor constraintEqualToAnchor:info.bottomAnchor constant:8.0],
        [shelfViewB.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [shelfViewB.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
        [shelfViewB.heightAnchor constraintEqualToConstant:60.0],
        
        [addBtn.topAnchor constraintEqualToAnchor:shelfViewB.bottomAnchor constant:20.0],
        [addBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
        [addBtn.widthAnchor constraintEqualToConstant:200.0],
        
        [removeBtn.topAnchor constraintEqualToAnchor:addBtn.bottomAnchor constant:20.0],
        [removeBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
        [removeBtn.widthAnchor constraintEqualToConstant:200.0],
        
    ]];

}

- (void)addTapped {
    ++counter;
    [shelfViewA addItem:counter];
    [shelfViewB addItem:counter];
}
- (void)removeTapped {
    if (counter > 0) {
        --counter;
        [shelfViewA removeItem];
        [shelfViewB removeItem];
    }
}

@end

Running that gives us this - note the "top" shelf view shows its frame, the "bottom" shelf view only shows the "framing view":

enter image description here enter image description here

enter image description here enter image description here

and when the view changes size, such as on device rotation, we don't have to do anything ... auto-layout handles it for us:

enter image description here enter image description here

enter image description here

Solution 2:[2]

You can simply make it by adjust distribution attribute.

self.distribution = UIStackViewDistributionEqualCentering;

What's more, UIStackView

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 DonMag
Solution 2 Elevo