Note: This is a follow-up question to this question:
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:
All three examples use .contentLayoutGuide
constraints to "auto-enable" scrolling as needed.