Search code examples
iosuiviewuiscrollviewautolayout

AutoLayout - space views apart a preferred distance and decrease down to a minimum distance before scrolling


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:

  • Items keep getting added and spaced apart by the preferred inter-item spacing.
  • Once there is not enough room in the view to add more items with the preferred inter-item spacing, the spacing gets reduced down to the minimum inter-item spacing.
  • If all the items are spaced out according to the minimum inter-item spacing and there still isn't room, then a new items is added and the view is scrollable.

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.

Question

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.

Code

MyShelf.h

#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

MyShelf.m

#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

Solution

  • Start simple -- and think about it in terms of "what I want to do" before you think about the code.

    So, suppose we have:

    • scroll view with a Width of 360
    • item views with widths of 44
    • MAX (preferred) spacing of 80
    • MIN spacing of 40

    When you add/remove items:

    • Multiply number of items x 40
    • Subtract that from 360 (scroll view width) to get the "available space"
    • Divide available space by numItems-1 to get the space between items
    • space = MIN(space, 80)
    • space = MAX(space, 40)

    So it looks like this (the gray rectangle is the scroll view frame):

    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.