AutoLayout-空间视图与首选距离不同,并在滚动之前降低至最小距离

发布于 2025-02-11 22:26:16 字数 25772 浏览 0 评论 0原文

注意:这是此问题的后续问题: autoLayout动画滑动视图,并滑过其他视图以


  • 取 通过首选的项目间距不断添加并分开。
  • 一旦在视图中没有足够的空间来添加更多的项目和首选的项目间间距,则间距会降低到最小项目间间距。
  • 如果所有项目都根据最小项目间距的最小间距进行间隔,并且仍然没有空间,则会添加新项目,并且可以滚动视图。

我有点工作,但是我认为发生了一些模棱两可的布局问题,因为有时在添加视图时,这些物品会根据最小项目间距的最小间距空间,有时它们会根据首选的Inter-Inem隔离间距。

问题

是否知道如何根据上述规则使项目的间距更为可预测?基本上,如果可能的话,应根据首选的项目间距进行间隔。否则,间距可以降低至最小项目间距。而且,如果仍然没有足够的空间,那么内容大小就可以增长并启用滚动。

编码

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

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

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

听不够的曲调 2025-02-18 22:26:17

开始简单 - 在考虑代码之前,请先考虑一下“我想做的事情”。

因此,假设我们有:

  • scroll视图360
  • 间距的宽度> 项目视图
  • 44 max(首选)80
  • 最小 40

当您添加/删除项目时:

  • 乘以项目x 40
  • 360(滚动视图宽度)中删除该项目以获取“可用空间“
  • 将可用空间除以NumItems-1,以获取项目之间的空间
  • space = min = min(Space,80)
  • space = max(space,40)

因此看起来像这样(灰色矩形是滚动视图框架):

”在此处输入图像描述

然后您可以使用水平堆栈视图并更新.spacing属性,或更新项目之间的领先/尾随约束。

如果使用堆栈视图,则堆栈视图的尾随约束应等于滚动视图的contentlayoutguide tailling,并且滚动是自动的。

如果使用项目之间的约束,则最后项目应获得等于滚动视图的contentLayoutguide trafing的尾随,并且滚动又是自动的。

堆栈视图要容易得多,但是使用哪种方法取决于您要如何显示添加 /删除项目。如果您想将它们动画到位,则可能不想使用堆栈视图。


编辑 - 示例...

以下是两个示例:scrollingshelfa使用带有领先/尾随约束的子视图; scrollingshelfb使用堆栈视图。

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

和一个示例视图控制器,并添加了实例每个,添加视图/删除视图按钮:

viewController.h

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
@end

view controller.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

编辑2

如果要允许重叠在最大距离处,您可以使用“带有约束的子视图”方法,并将最小inmumimenInterItemspacing设置为负值(例如,项目的宽度1/2)。

由于您可能还希望这些项目从左到右重叠,请在addView中将新项目视图发送到后面:

[self.framingView addSubview:view];

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

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

ScrollingsHelfa>

编辑3

我更新了上面的代码,添加了“向后”行,并添加了scrollingshelfa的第二个实例,最小间距为-22。还实现了layoutsubviews在帧更改时自动更新位置(例如设备旋转)。

看起来像这样:

“在这里输入图像描述”

​“ https://i.sstatic.net/ivwnj.png” alt =“在此处输入图像说明”>

所有三个示例均使用.contentlayoutguide约束对“自动启用”滚动的约束需要。

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.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文