Note: This is a follow-up question to this question: AutoLayout animate sliding view out and sliding other views over to take its place
I am working on a view that arranges subviews horizontally based on the following:
I have it kind of working, but I think there are some ambiguous layout issues happening because sometimes when adding a view, the items will space out according to the minimum inter-item spacing and sometimes they will space apart based on the preferred inter-item spacing.
Does anyone know how to get the spacing of the items to be more predictable, based on the rules mentioned above? Basically, the items should be spaced out according to the preferred inter-item spacing if possible. Otherwise, the spacing can decrease down to the minimum inter-item spacing. And if there is still not room enough, then the content size can grow and enable scrolling.
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, MyShelfNewItemShape) {
MyShelfNewItemShapeNone = 0,
MyShelfNewItemShapeCircular
};
@interface MyShelf : UIScrollView
UIKIT_EXTERN const CGFloat MyShelfNoMinimumInteritemSpacing;
@property (copy, nonatomic, readonly) NSArray<__kindof UIView *> *arrangedSubviews;
@property (assign, nonatomic) CGSize itemSize;
@property (assign, nonatomic) MyShelfNewItemShape itemShape;
@property (strong, nonatomic, nullable) UIColor *itemBorderColor;
@property (strong, nonatomic, nullable) NSNumber *itemBorderWidth;
@property (strong, nonatomic, nullable) UIColor *frontItemBorderColor;
@property (strong, nonatomic, nullable) NSNumber *frontItemBorderWidth;
@property (assign, nonatomic) CGFloat preferredInteritemSpacing;
@property (assign, nonatomic) CGFloat minimumInteritemSpacing;
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex inFront:(BOOL)inFront animated:(BOOL)animated;
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated;
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex;
- (void)addArrangedSubview:(UIView *)view inFront:(BOOL)inFront animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view;
- (void)removeArrangedSubviewAtIndex:(NSUInteger)index animated:(BOOL)animated;
- (void)removeArrangedSubviewAtIndex:(NSUInteger)index;
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)removeArrangedSubview:(UIView *)view;
- (void)removeAllArrangedSubviewsAnimated:(BOOL)animated;
- (void)removeAllArrangedSubviews;
- (void)bringArrangedSubviewToFront:(UIView *)view;
@end
NS_ASSUME_NONNULL_END
#import "MyShelf.h"
@interface MyShelf ()
@property (strong, nonatomic) UIView *positionView;
@property (strong, nonatomic) UIView *framingView;
@property (strong, nonatomic) NSLayoutConstraint *framingViewTrailingConstraint;
@property (strong, nonatomic) NSArray<NSLayoutConstraint *> *positionViewHorizontalConstraints;
@property (strong, nonatomic, readwrite) NSMutableArray<__kindof UIView *> *mutableArrangedSubviews;
@end
@implementation MyShelf
const CGFloat MyShelfNoMinimumInteritemSpacing = -1e9;
- (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.directionalLayoutMargins = NSDirectionalEdgeInsetsZero;
self.itemShape = MyShelfNewItemShapeNone;
self.itemBorderColor = [UIColor blackColor];
self.itemBorderWidth = @1.0;
self.framingView.backgroundColor = [UIColor systemOrangeColor];
self.positionView.backgroundColor = [UIColor systemBlueColor];
//set this property directly, rather than going through the setter, because the setter
//will update some constraints that don't need to be updated right now
self->_preferredInteritemSpacing = 10.0;
self.minimumInteritemSpacing = MyShelfNoMinimumInteritemSpacing;
//framingView will match the bounds of the items and it will look like their superview,
//but it is not the superview of the items
[self addSubview:self.framingView];
//positionView is used for the item position constraints but it is not seen
[self addSubview:self.positionView];
//horizontally inset the position view
[NSLayoutConstraint activateConstraints:self.positionViewHorizontalConstraints];
[NSLayoutConstraint activateConstraints:@[
[self.positionView.centerYAnchor constraintEqualToAnchor:self.framingView.centerYAnchor],
[self.positionView.heightAnchor constraintEqualToAnchor:self.framingView.heightAnchor],
[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.contentLayoutGuide.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 layoutIfNeeded];
}
- (CGSize)intrinsicContentSize {
NSInteger n = self.mutableArrangedSubviews.count;
return CGSizeMake(n * self.itemSize.width + (n - 1) * self.preferredInteritemSpacing, self.itemSize.height);
}
- (CGSize)minimumIntrinsicContentSize {
NSInteger n = self.mutableArrangedSubviews.count;
return CGSizeMake(n * self.itemSize.width + (n - 1) * self.minimumInteritemSpacing, self.itemSize.height);
}
#pragma mark - Managing the Horizontal Order of Arranged Subviews
- (void)updateHorizontalPositions {
if (self.mutableArrangedSubviews.count == 0) {
//no items, so all we have to do is to update the framing view
self.framingViewTrailingConstraint.active = NO;
self.framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:self.contentLayoutGuide.leadingAnchor];
self.framingViewTrailingConstraint.active = YES;
return;
}
//clear the existing centerX constraints
for (NSLayoutConstraint *constraint in self.positionView.constraints) {
if (constraint.firstAttribute == NSLayoutAttributeCenterX || constraint.firstAttribute == NSLayoutAttributeCenterXWithinMargins) {
constraint.active = NO;
}
}
//the first item will be equal to the positionView's leading
UIView *currentItem = [self.mutableArrangedSubviews firstObject];
[NSLayoutConstraint activateConstraints:@[
[currentItem.centerXAnchor constraintEqualToAnchor:self.positionView.leadingAnchor]
]];
// 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.33%
// item 2 centerX is at 66.66%
// item 3 centerX is at 100%
CGFloat percent = 1.0 / (CGFloat)(self.mutableArrangedSubviews.count - 1);
UIView *previousItem;
for (int x = 1; x < self.mutableArrangedSubviews.count; x++) {
previousItem = currentItem;
currentItem = self.mutableArrangedSubviews[x];
CGFloat currentPercent = percent * x;
//keep items next to each other (left-aligned) when overlap is not needed
[currentItem.centerXAnchor constraintLessThanOrEqualToAnchor:previousItem.centerXAnchor constant:(self.itemSize.width + self.preferredInteritemSpacing)].active = YES;
//this enforces a minimum spacing between items
if (self.minimumInteritemSpacing != MyShelfNoMinimumInteritemSpacing) {
[currentItem.centerXAnchor constraintGreaterThanOrEqualToAnchor:previousItem.centerXAnchor constant:(self.itemSize.width + self.minimumInteritemSpacing)].active = YES;
}
//centerX as a percentage of the positionView width
//note: this method is being used as opposed to the layout anchor API because the layout anchor API does not support setting the multiplier
NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:currentItem
attribute:NSLayoutAttributeCenterX
relatedBy:NSLayoutRelationEqual
toItem:self.positionView
attribute:NSLayoutAttributeTrailing
multiplier:currentPercent
constant:0.0];
//this constraint needs a less-than-required priority so the left-aligned constraint can be enforced
constraint.priority = UILayoutPriorityRequired - 1;
constraint.active = YES;
}
//update the trailing anchor of the framing view to the last shelf item
self.framingViewTrailingConstraint.active = NO;
self.framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:currentItem.trailingAnchor];
self.framingViewTrailingConstraint.active = YES;
}
- (void)addArrangedSubview:(UIView *)view inFront:(BOOL)inFront animated:(BOOL)animated {
[self insertArrangedSubview:view atIndex:self.mutableArrangedSubviews.count inFront:inFront animated:animated];
}
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated {
[self addArrangedSubview:view inFront:NO animated:animated];
}
- (void)addArrangedSubview:(UIView *)view {
[self addArrangedSubview:view animated:NO];
}
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)horizontalIndex inFront:(BOOL)inFront animated:(BOOL)animated {
//if the itemSize is CGSizeZero, then that means to use the size of the provided views
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);
switch (self.itemShape) {
case MyShelfNewItemShapeNone:
break;
case MyShelfNewItemShapeCircular:
view.layer.cornerRadius = height / 2.0;
view.layer.masksToBounds = YES;
view.clipsToBounds = YES;
break;
}
view.translatesAutoresizingMaskIntoConstraints = NO;
[self.mutableArrangedSubviews insertObject:view atIndex:horizontalIndex];
if (inFront) {
[self.positionView addSubview:view];
} else {
//insert the view as a subview of positionView at index zero so it will be underneath existing items
[self.positionView insertSubview:view atIndex:0];
}
[NSLayoutConstraint activateConstraints:@[
[view.centerYAnchor constraintEqualToAnchor:self.positionView.centerYAnchor]
]];
if (animated) {
//store the previous alpha value in case the view should not be fully opaque
CGFloat previousAlpha = view.alpha;
//prepare the view to fade in
view.alpha = 0.0;
//set the initial horizontal position for the view
NSLayoutConstraint *horizontalConstraint;
//if the view is in the front, it just fades in, otherwise it slides in
if (!inFront) {
//if the view is vertically behind the view to the right, then animate in to the right, otherwise animate in to the left
BOOL shouldSlideRight = YES;
if (horizontalIndex < self.mutableArrangedSubviews.count - 1) {
NSInteger verticalIndex = [self.positionView.subviews indexOfObject:view];
NSInteger nextVerticalIndex = [self.positionView.subviews indexOfObject:self.mutableArrangedSubviews[horizontalIndex + 1]];
shouldSlideRight = verticalIndex >= nextVerticalIndex;
}
if (horizontalIndex == 0) {
//if inserting the first item, always slide in from the leadingAnchor
horizontalConstraint = [view.trailingAnchor constraintEqualToAnchor:self.positionView.leadingAnchor];
} else if (shouldSlideRight) {
//slide in from the leading edge of the view
horizontalConstraint = [view.centerXAnchor constraintEqualToAnchor:self.mutableArrangedSubviews[horizontalIndex - 1].centerXAnchor];
} else {
//slide in from the trailing edge of the view
horizontalConstraint = [view.centerXAnchor constraintEqualToAnchor:self.mutableArrangedSubviews[horizontalIndex + 1].centerXAnchor];
}
} else {
if (horizontalIndex == 0) {
//if inserting the first item, always slide in from the leadingAnchor
horizontalConstraint = [view.trailingAnchor constraintEqualToAnchor:self.positionView.leadingAnchor];
} else {
//slide in from the position of the view that precedes this view
horizontalConstraint = [view.centerXAnchor constraintEqualToAnchor:self.mutableArrangedSubviews[horizontalIndex - 1].centerXAnchor];
}
}
[NSLayoutConstraint activateConstraints:@[
horizontalConstraint
]];
[self.superview layoutIfNeeded];
__weak MyShelf *weakSelf = self;
void (^animations)(void) = ^{
//restore the alpha to what it was originally set to
view.alpha = previousAlpha;
horizontalConstraint.active = NO;
[weakSelf invalidateIntrinsicContentSize];
[weakSelf updateHorizontalPositions];
[weakSelf.superview layoutIfNeeded];
};
[UIView animateWithDuration:0.25
delay:0
options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
animations:animations
completion:^(BOOL finished){ [weakSelf updateVerticalPositions]; }];
} else {
[self invalidateIntrinsicContentSize];
[self updateHorizontalPositions];
[self updateVerticalPositions];
}
}
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated {
[self insertArrangedSubview:view atIndex:stackIndex inFront:NO animated:animated];
}
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex {
[self insertArrangedSubview:view atIndex:stackIndex animated:NO];
}
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated {
BOOL wasInFront = NO;
if ([self.positionView.subviews lastObject] == view) {
wasInFront = YES;
}
if (animated) {
NSInteger horizontalIndex = [self.mutableArrangedSubviews indexOfObject:view];
//if the view is vertically behind the view to the right, then animate out to the right, otherwise animate out to the left
BOOL shouldSlideLeft = YES;
if (horizontalIndex < self.mutableArrangedSubviews.count - 1) {
NSInteger verticalIndex = [self.positionView.subviews indexOfObject:view];
NSInteger nextVerticalIndex = [self.positionView.subviews indexOfObject:self.mutableArrangedSubviews[horizontalIndex + 1]];
shouldSlideLeft = verticalIndex >= nextVerticalIndex;
}
[self.mutableArrangedSubviews removeObject:view];
[self invalidateIntrinsicContentSize];
[self layoutIfNeeded];
__weak MyShelf *weakSelf = self;
[UIView animateWithDuration:0.25
delay:0.0
options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
animations:^{
view.alpha = 0.0;
[weakSelf updateHorizontalPositions];
if (shouldSlideLeft) {
[weakSelf.positionView sendSubviewToBack:view];
[NSLayoutConstraint activateConstraints:@[
[view.centerXAnchor constraintEqualToAnchor:view.superview.leadingAnchor constant:-weakSelf.itemSize.width / 2]
]];
} else {
//since this is being moved to the right, horizontalIndex already points to the nextView without having to
//increment the index because the current view has already been removed from mutableArrangedSubviews, so the
//nextView has slid up in position
if (horizontalIndex < weakSelf.mutableArrangedSubviews.count) {
UIView *nextView = weakSelf.mutableArrangedSubviews[horizontalIndex];
[NSLayoutConstraint activateConstraints:@[
[view.centerXAnchor constraintEqualToAnchor:nextView.centerXAnchor constant:weakSelf.itemSize.width]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[view.centerXAnchor constraintEqualToAnchor:view.superview.trailingAnchor constant:weakSelf.itemSize.width / 2]
]];
}
}
[weakSelf.superview layoutIfNeeded];
} completion:^(BOOL finished){
[view removeFromSuperview];
//only reorder the views vertically if the one being removed was the top-most view
if (wasInFront) {
[weakSelf updateVerticalPositions];
}
}];
} else {
[view removeFromSuperview];
[self.mutableArrangedSubviews removeObject:view];
[self invalidateIntrinsicContentSize];
[self updateHorizontalPositions];
//only reorder the views vertically if the one being removed was the top-most view
if (wasInFront) {
[self updateVerticalPositions];
}
}
}
- (void)removeArrangedSubviewAtIndex:(NSUInteger)index animated:(BOOL)animated {
__kindof UIView *view = self.mutableArrangedSubviews[index];
[self removeArrangedSubview:view animated:animated];
}
- (void)removeArrangedSubviewAtIndex:(NSUInteger)index {
[self removeArrangedSubviewAtIndex:index animated:NO];
}
- (void)removeArrangedSubview:(UIView *)view {
[self removeArrangedSubview:view animated:NO];
}
- (void)removeAllArrangedSubviewsAnimated:(BOOL)animated {
//remove the subviews starting at the one that is in the back working towards the front
for (__kindof UIView *subview in self.positionView.subviews) {
[self removeArrangedSubview:subview animated:animated];
}
}
- (void)removeAllArrangedSubviews {
[self removeAllArrangedSubviewsAnimated:NO];
}
- (NSArray<__kindof UIView *> *)arrangedSubviews {
return self.mutableArrangedSubviews;
}
- (nullable UIColor *)frontItemBorderColorOrItemBorderColor {
if (self.frontItemBorderColor) {
return self.frontItemBorderColor;
}
return self.itemBorderColor;
}
- (nullable NSNumber *)frontItemBorderWidthOrItemBorderWidth {
if (self.frontItemBorderWidth) {
return self.frontItemBorderWidth;
}
return self.itemBorderWidth;
}
#pragma mark - Managing the Vertical Order of Arranged Subviews
- (void)updateVerticalPositions {
if (!self.positionView.subviews.count) {
return;
}
//get the view that is on the top and find out what horizontal position it is in
UIView *topView = [self.positionView.subviews lastObject];
NSInteger horizontalIndex = [self.mutableArrangedSubviews indexOfObject:topView];
if (horizontalIndex == NSNotFound) {
return;
}
//set the border color and border width of the frontmost item
UIColor *frontItemBorderColor = [self frontItemBorderColorOrItemBorderColor];
if (frontItemBorderColor) {
topView.layer.borderColor = frontItemBorderColor.CGColor;
} else {
topView.layer.borderColor = nil;
}
NSNumber *frontItemBorderWidth = [self frontItemBorderWidthOrItemBorderWidth];
topView.layer.borderWidth = frontItemBorderWidth ? [frontItemBorderWidth doubleValue] : 0.0;
for (NSInteger x = horizontalIndex - 1; x >= 0; x--) {
UIView *view = self.mutableArrangedSubviews[x];
[self.positionView sendSubviewToBack:view];
view.layer.borderColor = self.itemBorderColor ? self.itemBorderColor.CGColor : nil;
view.layer.borderWidth = self.itemBorderWidth ? [self.itemBorderWidth doubleValue] : 0.0;
}
for (NSInteger x = horizontalIndex + 1; x < self.mutableArrangedSubviews.count; x++) {
UIView *view = self.mutableArrangedSubviews[x];
[self.positionView sendSubviewToBack:view];
view.layer.borderColor = self.itemBorderColor ? self.itemBorderColor.CGColor : nil;
view.layer.borderWidth = self.itemBorderWidth ? [self.itemBorderWidth doubleValue] : 0.0;
}
}
- (void)bringArrangedSubviewToFront:(UIView *)view {
[self.positionView bringSubviewToFront:view];
[self updateVerticalPositions];
}
- (void)setPreferredInteritemSpacing:(CGFloat)preferredInteritemSpacing {
self->_preferredInteritemSpacing = preferredInteritemSpacing;
[self updateHorizontalPositions];
[self invalidateIntrinsicContentSize];
[self layoutIfNeeded];
}
- (void)setItemSize:(CGSize)itemSize {
self->_itemSize = itemSize;
[NSLayoutConstraint deactivateConstraints:self.positionViewHorizontalConstraints];
self.positionViewHorizontalConstraints = nil;
[NSLayoutConstraint activateConstraints:self.positionViewHorizontalConstraints];
for (__kindof UIView *view in self.positionView.subviews) {
//first, go through and remove any width or height constraints
for (NSLayoutConstraint *constraint in view.constraints) {
if (constraint.firstAttribute == NSLayoutAttributeWidth || constraint.firstAttribute == NSLayoutAttributeHeight) {
constraint.active = NO;
}
}
//then, apply new width and height constraints
[NSLayoutConstraint activateConstraints:@[
[view.widthAnchor constraintEqualToConstant:self.itemSize.width],
[view.heightAnchor constraintEqualToConstant:self.itemSize.height]
]];
}
[self updateHorizontalPositions];
[self invalidateIntrinsicContentSize];
[self layoutIfNeeded];
}
- (UIView *)framingView {
if (!self->_framingView) {
self->_framingView = [[UIView alloc] init];
self->_framingView.translatesAutoresizingMaskIntoConstraints = NO;
}
return self->_framingView;
}
- (UIView *)positionView {
if (!self->_positionView) {
self->_positionView = [[UIView alloc] init];
self->_positionView.translatesAutoresizingMaskIntoConstraints = NO;
self->_positionView.backgroundColor = nil;
}
return self->_positionView;
}
- (NSLayoutConstraint *)framingViewTrailingConstraint {
if (!self->_framingViewTrailingConstraint) {
self->_framingViewTrailingConstraint = [self.framingView.trailingAnchor constraintEqualToAnchor:self.positionView.leadingAnchor];
self->_framingViewTrailingConstraint.priority = UILayoutPriorityRequired;
}
return self->_framingViewTrailingConstraint;
}
- (NSArray<NSLayoutConstraint *> *)positionViewHorizontalConstraints {
if (!self->_positionViewHorizontalConstraints) {
self->_positionViewHorizontalConstraints = @[
//both the leading and trailing edges of the position view should be inset by 1/2 of the item width
[self.positionView.leadingAnchor constraintEqualToAnchor:self.framingView.leadingAnchor constant:self.itemSize.width / 2.0],
[self.positionView.trailingAnchor constraintEqualToAnchor:self.framingView.trailingAnchor constant:-self.itemSize.width / 2.0],
];
}
return self->_positionViewHorizontalConstraints;
}
@end
Start simple -- and think about it in terms of "what I want to do" before you think about the code.
So, suppose we have:
360
44
80
40
When you add/remove items:
40
360
(scroll view width) to get the "available space"space = MIN(space, 80)
space = MAX(space, 40)
So it looks like this (the gray rectangle is the scroll view frame):
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.