Home > Enterprise >  AutoLayout - space views apart a preferred distance and decrease down to a minimum distance before s
AutoLayout - space views apart a preferred distance and decrease down to a minimum distance before s

Time:06-30

Note: This is a follow-up question to this question: enter image description here

You can then use a horizontal stack view and update the .spacing property, or update the Leading/Trailing constraints between the items.

If using a stack view, the stack view's Trailing constraint should equal the scroll view's ContentLayoutGuide Trailing, and scrolling is automatic.

If using constraints between items, the last item should get Trailing equal to scroll view's ContentLayoutGuide Trailing, and again scrolling is automatic.

Stack view is much easier, but which method to use depends on how you want to display adding / removing items. If you want to animate them into place, you probably don't want to use a stack view.


Edit - examples...

Here are two examples: ScrollingShelfA uses subviews with Leading/Trailing constraints; ScrollingShelfB uses a stack view.

ScrollingShelfA.h

#import <UIKit/UIKit.h>

@interface ScrollingShelfA : UIScrollView

@property (assign, nonatomic) CGSize itemSize;

@property (assign, nonatomic) CGFloat preferredInteritemSpacing;
@property (assign, nonatomic) CGFloat minimumInteritemSpacing;

- (void)addView:(UIView *)view;
- (void)removeLastView;

@end

ScrollingShelfA.m

#import "ScrollingShelfA.h"

@interface ScrollingShelfA ()

@property (strong, nonatomic) UIView *framingView;

@property (strong, nonatomic, readwrite) NSMutableArray<__kindof UIView *> *mutableArrangedSubviews;

@end

@implementation ScrollingShelfA

- (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.mutableArrangedSubviews = [[NSMutableArray alloc] init];
    
    self.framingView = [UIView new];
    self.framingView.backgroundColor = [UIColor orangeColor];
    self.framingView.translatesAutoresizingMaskIntoConstraints = NO;
    [self addSubview:self.framingView];

    [NSLayoutConstraint activateConstraints:@[
        
        [self.framingView.leadingAnchor constraintEqualToAnchor:self.contentLayoutGuide.leadingAnchor],
        [self.framingView.topAnchor constraintEqualToAnchor:self.contentLayoutGuide.topAnchor],
        [self.framingView.trailingAnchor constraintEqualToAnchor:self.contentLayoutGuide.trailingAnchor],
        [self.framingView.bottomAnchor constraintEqualToAnchor:self.contentLayoutGuide.bottomAnchor],
        
        [self.framingView.heightAnchor constraintEqualToAnchor:self.frameLayoutGuide.heightAnchor],
        
    ]];
    
    //apply this last because it requires some changes to the constraints of the views involved.
    self.itemSize = CGSizeMake(44, 44);
    
    self.preferredInteritemSpacing = 80.0;
    self.minimumInteritemSpacing = 20.0;

}

- (void)updateHorizontalPositions {
    if (self.mutableArrangedSubviews.count == 0) {
        // no items, so we don't have to do anything
        return;
    }
    
    //clear the existing Leading / Trailing constraints
    for (NSLayoutConstraint *constraint in self.framingView.constraints) {
        if (constraint.firstAttribute == NSLayoutAttributeLeading || constraint.firstAttribute == NSLayoutAttributeTrailing) {
            constraint.active = NO;
        }
    }
    
    //the first item will be equal to the positionView's leading
    UIView *currentItem = [self.mutableArrangedSubviews firstObject];
    [NSLayoutConstraint activateConstraints:@[
        [currentItem.leadingAnchor constraintEqualToAnchor:self.framingView.leadingAnchor]
    ]];
    
    // spacing for remaining items
    
    CGFloat nViews = self.mutableArrangedSubviews.count;
    CGFloat availableSpace = self.frame.size.width - (nViews * self.itemSize.width);
    CGFloat spacing = availableSpace / (nViews - 1);

    spacing = MIN(spacing, self.preferredInteritemSpacing);
    spacing = MAX(spacing, self.minimumInteritemSpacing);
    
    UIView *previousItem;
    for (int x = 1; x < self.mutableArrangedSubviews.count; x  ) {
        previousItem = currentItem;
        currentItem = self.mutableArrangedSubviews[x];
        [currentItem.leadingAnchor constraintEqualToAnchor:previousItem.trailingAnchor constant:spacing].active = YES;
    }
    
    [currentItem.trailingAnchor constraintEqualToAnchor:self.framingView.trailingAnchor].active = YES;
    
}

- (void)addView:(UIView *)view {
    
    if (CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
        self.itemSize = view.bounds.size;
    } else {
        [NSLayoutConstraint activateConstraints:@[
            [view.widthAnchor constraintEqualToConstant:self.itemSize.width],
            [view.heightAnchor constraintEqualToConstant:self.itemSize.height]
        ]];
    }
    
    CGFloat height = MAX(self.itemSize.height, self.itemSize.width);
    
    view.layer.cornerRadius = height / 2.0;
    view.layer.masksToBounds = YES;
    view.clipsToBounds = YES;
    
    view.translatesAutoresizingMaskIntoConstraints = NO;

    [self.mutableArrangedSubviews addObject:view];
    
    view.translatesAutoresizingMaskIntoConstraints = NO;
    
    [self.framingView addSubview:view];
    
    [self.framingView sendSubviewToBack:view];

    [view.centerYAnchor constraintEqualToAnchor:self.framingView.centerYAnchor].active = YES;
    
    [self updateHorizontalPositions];
    
    // animate into view if necessary
    dispatch_async(dispatch_get_main_queue(), ^{
        CGRect r = CGRectMake(self.contentSize.width - 1.0, 0.0, 1.0, 1.0);
        [self scrollRectToVisible:r animated:YES];
    });

}

- (void)removeLastView {
    [self.mutableArrangedSubviews removeLastObject];
    [self updateHorizontalPositions];
}

- (void)layoutSubviews {
    [super layoutSubviews];
    [self updateHorizontalPositions];
}

@end

ScrollingShelfB.h

#import <UIKit/UIKit.h>

@interface ScrollingShelfB : UIScrollView

@property (assign, nonatomic) CGSize itemSize;

@property (assign, nonatomic) CGFloat preferredInteritemSpacing;
@property (assign, nonatomic) CGFloat minimumInteritemSpacing;

- (void)addView:(UIView *)view;
- (void)removeLastView;

@end

ScrollingShelfB.m

#import "ScrollingShelfB.h"

@interface ScrollingShelfB ()

@property (strong, nonatomic) UIStackView *framingStackView;

@end

@implementation ScrollingShelfB

- (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 {
    NSLog(@"init");
    
    self.framingStackView = [UIStackView new];
    self.framingStackView.alignment = UIStackViewAlignmentCenter;
    self.framingStackView.backgroundColor = [UIColor cyanColor];
    self.framingStackView.translatesAutoresizingMaskIntoConstraints = NO;
    [self addSubview:self.framingStackView];
    
    [NSLayoutConstraint activateConstraints:@[
        
        [self.framingStackView.leadingAnchor constraintEqualToAnchor:self.contentLayoutGuide.leadingAnchor],
        [self.framingStackView.topAnchor constraintEqualToAnchor:self.contentLayoutGuide.topAnchor],
        [self.framingStackView.trailingAnchor constraintEqualToAnchor:self.contentLayoutGuide.trailingAnchor],
        [self.framingStackView.bottomAnchor constraintEqualToAnchor:self.contentLayoutGuide.bottomAnchor],
        
        [self.framingStackView.heightAnchor constraintEqualToAnchor:self.frameLayoutGuide.heightAnchor],
        
    ]];
    
    //apply this last because it requires some changes to the constraints of the views involved.
    self.itemSize = CGSizeMake(44, 44);
    
    self.preferredInteritemSpacing = 80.0;
    self.minimumInteritemSpacing = 20.0;
    
}

- (void)updateHorizontalPositions {
    if (self.framingStackView.arrangedSubviews.count == 0) {
        // no items, so we don't have to do anything
        return;
    }
    
    // spacing for stack view
    
    CGFloat nViews = self.framingStackView.arrangedSubviews.count;
    CGFloat availableSpace = self.frame.size.width - (nViews * self.itemSize.width);
    CGFloat spacing = availableSpace / (nViews - 1);
    
    spacing = MIN(spacing, self.preferredInteritemSpacing);
    spacing = MAX(spacing, self.minimumInteritemSpacing);
    
    self.framingStackView.spacing = spacing;
}

- (void)addView:(UIView *)view {
    
    if (CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
        self.itemSize = view.bounds.size;
    } else {
        [NSLayoutConstraint activateConstraints:@[
            [view.widthAnchor constraintEqualToConstant:self.itemSize.width],
            [view.heightAnchor constraintEqualToConstant:self.itemSize.height]
        ]];
    }
    
    CGFloat height = MAX(self.itemSize.height, self.itemSize.width);
    
    view.layer.cornerRadius = height / 2.0;
    view.layer.masksToBounds = YES;
    view.clipsToBounds = YES;
    
    [self.framingStackView addArrangedSubview:view];
    
    [self updateHorizontalPositions];
    
    // animate into view if necessary
    dispatch_async(dispatch_get_main_queue(), ^{
        CGRect r = CGRectMake(self.contentSize.width - 1.0, 0.0, 1.0, 1.0);
        [self scrollRectToVisible:r animated:YES];
    });
    
}

- (void)removeLastView {
    [self.framingStackView.arrangedSubviews.lastObject removeFromSuperview];
    [self updateHorizontalPositions];
}
@end

and an example view controller that adds an instance of each, with Add View / Remove View buttons:

ViewController.h

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
@end

ViewController.m

#import "ViewController.h"
#import "ScrollingShelfA.h"
#import "ScrollingShelfB.h"

@interface ViewController ()
{
    ScrollingShelfA *scShelf;
    ScrollingShelfA *stShelf;
    ScrollingShelfB *ssShelf;
    NSArray <UIColor *>*colors;
    NSInteger idx;
}
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    scShelf = [ScrollingShelfA new];
    scShelf.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];

    // no overlap on first example
    scShelf.minimumInteritemSpacing = 20.0;
    
    stShelf = [ScrollingShelfA new];
    stShelf.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
    
    // allow overlap on second example
    stShelf.minimumInteritemSpacing = -22.0;
    
    ssShelf = [ScrollingShelfB new];
    ssShelf.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
    
    ssShelf.minimumInteritemSpacing = 20.0;
    
    UIFont *fnt = [UIFont systemFontOfSize:14.0 weight:UIFontWeightLight];
    
    UILabel *labelA = [UILabel new];
    labelA.text = @"Subviews with Constraints - min spacing: 20";
    labelA.font = fnt;
    
    UILabel *labelB = [UILabel new];
    labelB.text = @"Constraints with Overlap - min spacing: -22";
    labelB.font = fnt;

    UILabel *labelC = [UILabel new];
    labelC.text = @"Using Stack View";
    labelC.font = fnt;

    for (UIView *v in @[labelA, scShelf, labelB, stShelf, labelC, ssShelf]) {
        v.translatesAutoresizingMaskIntoConstraints = NO;
        [self.view addSubview:v];
    }

    // respect safeArea
    UILayoutGuide *g = self.view.safeAreaLayoutGuide;
    
    [NSLayoutConstraint activateConstraints:@[

        [labelA.topAnchor constraintEqualToAnchor:g.topAnchor constant:40.0],
        [labelA.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],

        [scShelf.topAnchor constraintEqualToAnchor:labelA.bottomAnchor constant:4.0],
        [scShelf.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [scShelf.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        [scShelf.heightAnchor constraintEqualToConstant:60.0],
        
        [labelB.topAnchor constraintEqualToAnchor:scShelf.bottomAnchor constant:40.0],
        [labelB.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        
        [stShelf.topAnchor constraintEqualToAnchor:labelB.bottomAnchor constant:4.0],
        [stShelf.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [stShelf.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        [stShelf.heightAnchor constraintEqualToConstant:60.0],
        
        [labelC.topAnchor constraintEqualToAnchor:stShelf.bottomAnchor constant:40.0],
        [labelC.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        
        [ssShelf.topAnchor constraintEqualToAnchor:labelC.bottomAnchor constant:4.0],
        [ssShelf.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20.0],
        [ssShelf.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20.0],
        [ssShelf.heightAnchor constraintEqualToConstant:60.0],
        
    ]];

    // let's add AddView and RemoveView buttons
    UIButton *addBtn = [UIButton new];
    [addBtn setTitle:@"Add View" forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    addBtn.backgroundColor = UIColor.systemRedColor;
    addBtn.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:addBtn];

    UIButton *removeBtn = [UIButton new];
    [removeBtn setTitle:@"Remove View" forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    removeBtn.backgroundColor = UIColor.systemRedColor;
    removeBtn.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:removeBtn];
    
    [NSLayoutConstraint activateConstraints:@[
        
        [addBtn.topAnchor constraintEqualToAnchor:ssShelf.bottomAnchor constant:40.0],
        [addBtn.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:40.0],
        [addBtn.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-40.0],
        
        [removeBtn.topAnchor constraintEqualToAnchor:addBtn.bottomAnchor constant:20.0],
        [removeBtn.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:40.0],
        [removeBtn.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-40.0],
        
    ]];
    
    [addBtn addTarget:self action:@selector(addView) forControlEvents:UIControlEventTouchUpInside];
    [removeBtn addTarget:self action:@selector(removeView) forControlEvents:UIControlEventTouchUpInside];

    colors = @[
        UIColor.redColor, UIColor.blueColor, UIColor.greenColor, UIColor.yellowColor,
        UIColor.systemRedColor, UIColor.systemBlueColor, UIColor.systemGreenColor, UIColor.systemYellowColor,
    ];
    
    idx = -1;
    
}
- (void)addView {
    idx  ;
    UIView *v = [UIView new];
    v.backgroundColor = colors[idx % colors.count];
    [scShelf addView:v];
    v = [UIView new];
    v.backgroundColor = colors[idx % colors.count];
    [stShelf addView:v];
    v = [UIView new];
    v.backgroundColor = colors[idx % colors.count];
    [ssShelf addView:v];
}
- (void)removeView {
    if (idx > 0) {
        [scShelf removeLastView];
        [stShelf removeLastView];
        [ssShelf removeLastView];
        idx--;
    }
}

@end

Edit 2

If you want to allow overlap to a maximum distance, you can use the "Subviews with Constraints" approach and set a minimumInteritemSpacing to a negative value (such as 1/2 the width of an item).

Since you'll probably also want the items to overlap left-to-right, send the new item view to the back in addView:

[self.framingView addSubview:view];

// add this line    
[self.framingView sendSubviewToBack:view];

[view.centerYAnchor constraintEqualToAnchor:self.framingView.centerYAnchor].active = YES;

Nothing else would need to change in ScrollingShelfA

Edit 3

I updated the code above, adding the "send to back" line and adding a 2nd instance of ScrollingShelfA with a min spacing of -22. Also implemented layoutSubviews to auto-update the positions when the frame changes (such as on device rotation).

Looks like this:

enter image description here

enter image description here

enter image description here

All three examples use .contentLayoutGuide constraints to "auto-enable" scrolling as needed.

  • Related