2022-05-17

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



No comments:

Post a Comment