UIStackView 更改项目间距作为堆栈视图更改大小

UIStackView change item spacing as stack view changes sizes

概览

我有一个堆栈视图,其中包含多个圆形视图。圈子视图可以是图像(如个人资料图片)或任何东西。如果堆栈视图的大小对于子视图来说太小,这些视图应该能够重叠。如果堆栈视图对于子视图来说太大,视图应该展开。此外,即使堆栈视图的大小没有改变,也可以动态添加或删除子视图。

例如,在下图中,顶部堆栈视图具有这些重叠的圆形视图,并且那里一切正常(框架恰好是子视图视图的大小)。但随后,查看第二个堆栈视图,在添加更多视图后,第一个视图被压缩。但我想要发生的是让所有视图重叠更多一点并且不压缩任何视图。

问题

实现此行为的最佳方法是什么?我应该覆盖 layoutSubviews,就像我在下一节中提议的那样,还是有更好的方法来实现它?同样,如果堆栈视图对它们来说太大,我只希望视图展开,或者如果堆栈视图太窄,它们可以相互重叠。堆栈视图可以随时更改大小,也可以随时添加或删除排列的子视图,所有这些都会导致重新计算视图间距。

建议的解决方案

我正在考虑覆盖堆栈视图的 layoutSubviews 方法,然后以某种方式测量所有视图,将这些宽度加在一起,然后是当前存在的间距(我想通过每个排列子视图并查看该子视图的间距)。因此,如果项目实际展开,则重叠的间距为负间距或正间距。然后,我会将该宽度与 layoutSubviews 中的框架进行比较,如果它太宽,那么我会减小间距。否则,如果视图没有占据整个堆栈视图,那么我会增加它们的间距。

这是我的代码和layoutSubviews中提出的算法。

代码

MyShelf.h

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, MyShelfItemShape) {
    MyShelfItemShapeNone = 0,
    MyShelfItemShapeCircular
};

@interface MyShelf : UIStackView

@property (assign, nonatomic) CGSize itemSize;
@property (assign, nonatomic) MyShelfItemShape itemShape;
@property (strong, nonatomic) UIColor *itemBorderColor;
@property (assign, nonatomic) CGFloat itemBorderWidth;

@property (assign, nonatomic) CGFloat preferredMinimumSpacing;
@property (assign, nonatomic) CGFloat preferredMaximumSpacing;

#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated;
- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated;
- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated;

@end

NS_ASSUME_NONNULL_END

MyShelf.m

#import "MyShelf.h"

@interface MyShelf ()

@property (strong, nonatomic) UIStackView *stackView;

@end

@implementation MyShelf

#pragma mark - Initializing the View
- (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.spacing = -10;
    self.axis = UILayoutConstraintAxisHorizontal;
    self.alignment = UIStackViewAlignmentCenter;
    self.distribution = UIStackViewDistributionFillProportionally;
    self.itemSize = CGSizeZero;
    self.itemShape = MyShelfItemShapeNone;
    self.itemBorderColor = [UIColor blackColor];
    self.itemBorderWidth = 1.0;
}

- (void)layoutSubviews {
    //if the new frame is different from the old frame
        //if the size of the items in the stack view is too large, reduce the spacing down to a minimum of preferredMinimumSpacing
        //else if the size of the items in the stack view is too small, increase the spacing up to a maximum of preferredMaximumSpacing
        //otherwise keep the spacing as-is
    [super layoutSubviews];
}

#pragma mark - Managing Arranged Subviews
- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex animated:(BOOL)animated {
    CGFloat height = MAX(view.bounds.size.height, view.bounds.size.width);
    
    if (!CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
        [NSLayoutConstraint activateConstraints:@[
            [view.widthAnchor constraintEqualToConstant:self.itemSize.width],
            [view.heightAnchor constraintEqualToConstant:self.itemSize.height]
        ]];
        height = MAX(self.itemSize.height, self.itemSize.width);
    }
    
    switch (self.itemShape) {
        case MyShelfItemShapeNone:
            break;
        case MyShelfItemShapeCircular:
            view.layer.cornerRadius = height / 2.0;
            break;
    }
    
    view.layer.borderColor = self.itemBorderColor.CGColor;
    view.layer.borderWidth = self.itemBorderWidth;

    
    if (animated) {
        //prepare the view to be initially hidden so it can be animated in
        view.alpha = 0.0;
        view.hidden = YES;

        [super insertArrangedSubview:view atIndex:stackIndex];

        [UIView animateWithDuration:0.25
                              delay:0
                            options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{ view.alpha = 1.0; view.hidden = NO; }
                         completion:nil];
    } else {
        [super insertArrangedSubview:view atIndex:stackIndex];
    }
    
    [self reorderArrangedSubviews];
}

- (void)insertArrangedSubview:(UIView *)view atIndex:(NSUInteger)stackIndex {
    [self insertArrangedSubview:view atIndex:stackIndex animated:NO];
}

- (void)addArrangedSubview:(UIView *)view animated:(BOOL)animated {
    [self insertArrangedSubview:view atIndex:self.arrangedSubviews.count animated:animated];
}

- (void)addArrangedSubview:(UIView *)view {
    [self addArrangedSubview:view animated:NO];
}

- (void)removeArrangedSubview:(UIView *)view animated:(BOOL)animated {
    if (animated) {
        [UIView animateWithDuration:0.25
                              delay:0
                            options:UIViewAnimationOptionCurveLinear|UIViewAnimationOptionAllowAnimatedContent|UIViewAnimationOptionAllowUserInteraction
                         animations:^{ view.alpha = 0.0; view.hidden = YES; }
                         completion:^(BOOL finished) { [super removeArrangedSubview:view]; }];
    } else {
        [super removeArrangedSubview:view];
    }
}


- (void)reorderArrangedSubviews {
    for (__kindof UIView *arrangedSubview in self.arrangedSubviews) {
        [self sendSubviewToBack:arrangedSubview];
    }
}

@end

要求

如果视图是固定宽度

对于这种情况,包含这些圆形子视图的视图是固定宽度的。可能是它有一个宽度约束来指定它的宽度点数,或者它可能受到其他视图的约束,因此它的宽度是预先确定的。

在这种情况下,子视图应该彼此相邻排列,直到它们不再适合框架,此时它们开始折叠(项目之间的负间距)。

如果视图是灵活宽度

对于这种情况,包含圆形子视图的视图没有指定宽度。相反,它的宽度由内容的宽度决定。所以它应该继续增长,直到它不能再增长,在这一点上,子视图开始重叠。

调整distribution属性即可。

self.distribution = UIStackViewDistributionEqualCentering;

此外,UIStackView

总体思路是在您的圆形视图上使用 centerX 约束 - 我将它们称为 ShelfItem,并将它们约束为“不可见的定位视图”。

之所以这样做,是因为当项目的 centerX 位于前缘(或后缘)时,它的一半将延伸到定位视图的左侧或右侧。

考虑将宽度分成相等的部分(所有值均以 % 为单位)...

如果我们有 3 件商品,我们需要 2 等份。要获得百分比间距,我们使用 1.0 / (numItems - 1):

4 项,我们需要 3 等份:

5 项,我们需要 4 等份:

对于 6 项,我们需要 5 等份:

因此,通过使“项目”视图成为“定位”视图的子视图,我们可以像这样循环并设置它们的 centerX 约束:

UIView *thisItem;
CGFloat pct = 1.0 / (CGFloat)([subviews count] - 1);

for (int i = 0; i < subviews.count; i++) {
    thisItem = subviews[i];
    
    CGFloat thisPCT = pct * i;
    
    // centerX as a percentage of positionView width
    NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                         attribute:NSLayoutAttributeCenterX
                                                         relatedBy:NSLayoutRelationEqual
                                                            toItem:positionView
                                                         attribute:NSLayoutAttributeTrailing
                                                        multiplier:thisPCT
                                                          constant:0.0];
    
    c.active = YES;
}

这不是相当那么简单...

首先,auto-layout 不喜欢 multiplier: 0.0 ... 所以 left-most 项目需要有 centerX 等于定位视图 Leading。

第二件事是,当有足够的空间时,您的布局要求项目视图 left-aligned,而不是均匀分布。

为了实现这一点,我们将使每个项目视图的 centerX lessThanOrEqualTo 成为前一个项目的 centerX + itemWidth...并且我们将为“百分比”约束赋予 less-than-required 优先级。

所以,每次我们添加(或删除)一个项目时,我们都会调用一个方法来更新 centerX 约束......它看起来像这样:

// clear existing centerX constraints
for (NSLayoutConstraint *oldC in positionView.constraints) {
    if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
        oldC.active = NO;
    }
}

// item views are top-down left-to-right, so reverse the order of the subviews
NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];

// constraints don't like multiplier:0.0
//  so first item centerX will always be equal to positionView's Leading
UIView *thisItem = reversedArray[0];

[NSLayoutConstraint constraintWithItem:thisItem
                             attribute:NSLayoutAttributeCenterX
                             relatedBy:NSLayoutRelationEqual
                                toItem:positionView
                             attribute:NSLayoutAttributeLeading
                            multiplier:1.0
                              constant:0.0].active = YES;

// 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.333%
//          item 2 centerX is at 66.666%
//          item 3 centerX is at 100%

CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);

UIView *prevItem;

for (int i = 1; i < reversedArray.count; i++) {
    prevItem = thisItem;
    thisItem = reversedArray[i];

    CGFloat thisPCT = pct * i;
    
    // keep items next to each other (left-aligned) when overlap is not needed
    [thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
    
    // centerX as a percentage of positionView width
    NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                         attribute:NSLayoutAttributeCenterX
                                                         relatedBy:NSLayoutRelationEqual
                                                            toItem:positionView
                                                         attribute:NSLayoutAttributeTrailing
                                                        multiplier:thisPCT
                                                          constant:0.0];

    // needs less-than-required priority so "left-aligned" constraint can be enforced
    c.priority = UILayoutPriorityRequired - 1;
    c.active = YES;
}

最后一个任务是添加一个“框架”视图,它将匹配 laid-out 项视图的边界。

这是一个完整的例子...

ShelfItem.h - 带有标签的简单圆形视图

#import <UIKit/UIKit.h>
@interface ShelfItem : UIView
@property (strong, nonatomic) UILabel *label;
@end

ShelfItem.m

#import "ShelfItem.h"

@implementation ShelfItem

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (void) commonInit {
    self.backgroundColor = UIColor.whiteColor;
    _label = [UILabel new];
    _label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightLight];
    _label.translatesAutoresizingMaskIntoConstraints = NO;
    [self addSubview:_label];
    [_label.centerXAnchor constraintEqualToAnchor:self.centerXAnchor].active = YES;
    [_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = YES;
    self.layer.borderColor = UIColor.blueColor.CGColor;
    self.layer.borderWidth = 1.0;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    self.layer.cornerRadius = self.bounds.size.height * 0.5;
}

@end

ShelfView.h - 我们的视图完成所有工作

#import <UIKit/UIKit.h>

@interface ShelfView : UIView
- (void)addItem:(NSInteger)n;
- (void)removeItem;
@end

ShelfView.m

#import "ShelfView.h"
#import "ShelfItem.h"

@interface ShelfView () {
    UIView *positionView;
    UIView *framingView;
    CGFloat itemWidth;
    NSLayoutConstraint *framingViewTrailingConstraint;
}

@end

@implementation ShelfView

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (void) commonInit {
    
    itemWidth = 60.0;
    
    // framingView will match the bounds of the items
    //  it will not be their superView, but will look like it
    framingView = [UIView new];
    framingView.translatesAutoresizingMaskIntoConstraints = NO;
    framingView.backgroundColor = UIColor.systemYellowColor;
    [self addSubview:framingView];
    
    // positionView is used for the item position constraints
    //  but is not seen
    positionView = [UIView new];
    positionView.translatesAutoresizingMaskIntoConstraints = NO;
    positionView.backgroundColor = UIColor.clearColor;
    [self addSubview:positionView];
    
    // initialize framingView trailing constraint -- it will be updated in updatePositions
    framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:positionView.leadingAnchor];
    framingViewTrailingConstraint.priority = UILayoutPriorityRequired;
    
    [NSLayoutConstraint activateConstraints:@[
        
        // positioning view is at vertical center with no height
        [positionView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor],
        [positionView.heightAnchor constraintEqualToConstant:0.0],
        // leading and trailing are 1/2 the item width
        [positionView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:itemWidth * 0.5],
        [positionView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-itemWidth * 0.5],
        
        // framing view leading is at positioning view leading minus 1/2 item width
        [framingView.leadingAnchor constraintEqualToAnchor:positionView.leadingAnchor constant:-itemWidth * 0.5],
        // constrained top and bottom
        [framingView.topAnchor constraintEqualToAnchor:self.topAnchor],
        [framingView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
        
    ]];
    
}

- (void)updatePositions {
    
    if ([positionView.subviews count] == 0) {
        // no items, so all we have to do is update the framing view
        framingViewTrailingConstraint.active = NO;
        framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:self.leadingAnchor];
        framingViewTrailingConstraint.active = YES;
        return;
    }
    
    // clear existing centerX constraints
    for (NSLayoutConstraint *oldC in positionView.constraints) {
        if (oldC.firstAttribute == NSLayoutAttributeCenterX) {
            oldC.active = NO;
        }
    }
    
    // item views are top-down left-to-right, so reverse the order of the subviews
    NSArray *reversedArray = [positionView.subviews.reverseObjectEnumerator allObjects];
    
    // constraints don't like multiplier:0.0
    //  so first item centerX will always be equal to positionView's Leading
    UIView *thisItem = reversedArray[0];
    
    [NSLayoutConstraint constraintWithItem:thisItem
                                 attribute:NSLayoutAttributeCenterX
                                 relatedBy:NSLayoutRelationEqual
                                    toItem:positionView
                                 attribute:NSLayoutAttributeLeading
                                multiplier:1.0
                                  constant:0.0].active = YES;
    
    // 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.333%
    //          item 2 centerX is at 66.666%
    //          item 3 centerX is at 100%

    CGFloat pct = 1.0 / (CGFloat)([reversedArray count] - 1);
    
    UIView *prevItem;
    
    for (int i = 1; i < reversedArray.count; i++) {
        prevItem = thisItem;
        thisItem = reversedArray[i];

        CGFloat thisPCT = pct * i;
        
        // keep items next to each other (left-aligned) when overlap is not needed
        [thisItem.centerXAnchor constraintLessThanOrEqualToAnchor:prevItem.centerXAnchor constant:itemWidth].active = YES;
        
        // centerX as a percentage of positionView width
        NSLayoutConstraint *c = [NSLayoutConstraint constraintWithItem:thisItem
                                                             attribute:NSLayoutAttributeCenterX
                                                             relatedBy:NSLayoutRelationEqual
                                                                toItem:positionView
                                                             attribute:NSLayoutAttributeTrailing
                                                            multiplier:thisPCT
                                                              constant:0.0];

        // needs less-than-required priority so "left-aligned" constraint can be enforced
        c.priority = UILayoutPriorityRequired - 1;
        c.active = YES;
    }
    
    // update the trailing anchor of the framing view to the last shelf item
    framingViewTrailingConstraint.active = NO;
    framingViewTrailingConstraint = [framingView.trailingAnchor constraintEqualToAnchor:thisItem.trailingAnchor];
    framingViewTrailingConstraint.active = YES;

}

- (void)addItem:(NSInteger)n {
    
    // create a new shelf item
    ShelfItem *v = [ShelfItem new];
    v.translatesAutoresizingMaskIntoConstraints = NO;
    v.label.text = [NSString stringWithFormat:@"%ld", (long)n];
    
    // add it as a subview of positionView
    //  at index Zero (so it will be underneath existing items)
    [positionView insertSubview:v atIndex:0];
    
    // width and height
    [v.widthAnchor constraintEqualToConstant:itemWidth].active = YES;
    [v.heightAnchor constraintEqualToAnchor:v.widthAnchor].active = YES;
    
    // vertically centered on positionView
    [v.centerYAnchor constraintEqualToAnchor:positionView.centerYAnchor constant:0.0].active = YES;
    
    // update all shelf items
    [self updatePositions];
    
}
- (void)removeItem {
    
    // remove the last-added item
    [positionView.subviews[0] removeFromSuperview];
    
    // update all shelf items
    [self updatePositions];
    
}

@end

ViewController.h - 带有两个 ShelfView 和添加/删除按钮的控制器:

#import <UIKit/UIKit.h>

@interface ViewController : UIViewController
@end

ViewController.m

#import "ViewController.h"

#import "ShelfView.h"

@interface ViewController ()
{
    ShelfView *shelfViewA;
    ShelfView *shelfViewB;
    NSInteger counter;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    counter = 0;
    
    // top shelf view has systemYellow background, so we see its
    //  full width all the time
    shelfViewA = [ShelfView new];
    shelfViewA.translatesAutoresizingMaskIntoConstraints = NO;
    shelfViewA.backgroundColor = UIColor.systemYellowColor;
    [self.view addSubview:shelfViewA];
    
    // second shelf view has clear background, so we only see its
    //  framing view width when items are added
    shelfViewB = [ShelfView new];
    shelfViewB.translatesAutoresizingMaskIntoConstraints = NO;
    shelfViewB.backgroundColor = UIColor.clearColor;
    [self.view addSubview:shelfViewB];
    
    UIButton *addBtn = [UIButton new];
    addBtn.translatesAutoresizingMaskIntoConstraints = NO;
    addBtn.backgroundColor = UIColor.systemGreenColor;
    [addBtn setTitle:@"Add" forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [addBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    [addBtn addTarget:self action:@selector(addTapped) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:addBtn];
    
    UIButton *removeBtn = [UIButton new];
    removeBtn.translatesAutoresizingMaskIntoConstraints = NO;
    removeBtn.backgroundColor = UIColor.systemGreenColor;
    [removeBtn setTitle:@"Remove" forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
    [removeBtn setTitleColor:UIColor.lightGrayColor forState:UIControlStateHighlighted];
    [removeBtn addTarget:self action:@selector(removeTapped) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:removeBtn];

    UILabel *info = [UILabel new];
    info.translatesAutoresizingMaskIntoConstraints = NO;
    info.backgroundColor = [UIColor colorWithWhite:0.90 alpha:1.0];
    info.textAlignment = NSTextAlignmentCenter;
    info.numberOfLines = 0;
    info.text = @"Shelf View Width\n60-pts on each side.";
    [self.view addSubview:info];
    
    // respect safeArea
    UILayoutGuide *g = self.view.safeAreaLayoutGuide;
    
    [NSLayoutConstraint activateConstraints:@[
        
        [shelfViewA.topAnchor constraintEqualToAnchor:g.topAnchor constant:60.0],
        [shelfViewA.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [shelfViewA.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
        [shelfViewA.heightAnchor constraintEqualToConstant:60.0],
        
        [info.topAnchor constraintEqualToAnchor:shelfViewA.bottomAnchor constant:8.0],
        [info.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [info.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],

        [shelfViewB.topAnchor constraintEqualToAnchor:info.bottomAnchor constant:8.0],
        [shelfViewB.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:60.0],
        [shelfViewB.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-60.0],
        [shelfViewB.heightAnchor constraintEqualToConstant:60.0],
        
        [addBtn.topAnchor constraintEqualToAnchor:shelfViewB.bottomAnchor constant:20.0],
        [addBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
        [addBtn.widthAnchor constraintEqualToConstant:200.0],
        
        [removeBtn.topAnchor constraintEqualToAnchor:addBtn.bottomAnchor constant:20.0],
        [removeBtn.centerXAnchor constraintEqualToAnchor:g.centerXAnchor],
        [removeBtn.widthAnchor constraintEqualToConstant:200.0],
        
    ]];

}

- (void)addTapped {
    ++counter;
    [shelfViewA addItem:counter];
    [shelfViewB addItem:counter];
}
- (void)removeTapped {
    if (counter > 0) {
        --counter;
        [shelfViewA removeItem];
        [shelfViewB removeItem];
    }
}

@end

运行 这给了我们这个 - 注意“顶部”货架视图显示其框架,“底部”货架视图仅显示“框架视图”:

并且当视图改变大小时,例如在设备旋转时,我们不需要做任何事情......auto-layout 为我们处理它: