GeekerProbe

水滴石穿 Keeping faith.      --- wtlucky's Blog

FDStackView —— Downward Compatible UIStackView (Part 3)

| Comments

上一篇Part 2只介绍了第一个技术点alignmentdistribution的约束如何添加和管理alignment这一部分的内容,这一篇继续介绍distribution的约束添加和管理。

同样的在介绍实现之前,我先介绍一下StackView的各种distribution模式都是什么效果的:

  • UIStackViewDistributionFill:这种应该是目前最常用的了,它就是将arrangedSubviews填充满整个StackView,如果设置了spacing,那么这些arrangedSubviews之间的间距就是spacing。如果减去所有的spacing,所有的arrangedSubview的固有尺寸(intrinsicContentSize)不能填满或者超出StackView的尺寸,那就会按照Hugging或者CompressionResistance的优先级来拉伸或压缩一些arrangedSubview。如果出现优先级相同的情况,就按排列顺序来拉伸或压缩。

image

  • UIStackViewDistributionFillEqually:这种就是StackView的尺寸减去所有的spacing之后均分给arrangedSubviews,每个arrangedSubview的尺寸是相同的。

image

  • UIStackViewDistributionFillProportionally:这种跟FillEqually差不多,只不过这个不是讲尺寸均分给arrangedSubviews,而是根据arrangedSubviewsintrinsicContentSize按比例分配。

image

  • UIStackViewDistributionEqualSpacing:这种是使arrangedSubview之间的spacing相等,但是这个spacing是有可能大于StackView所设置的spacing,但是绝对不会小于。这个类型的布局可以这样理解,先按所有的arrangedSubviewintrinsicContentSize布局,然后余下的空间均分为spacing,如果大约StackView设置的spacing那这样就OK了,如果小于就按照StackView设置的spacing,然后按照CompressionResistance的优先级来压缩一个arrangedSubview

image

  • UIStackViewDistributionEqualCentering:这种是使arrangedSubview的中心点之间的距离相等,这样没两个arrangedSubview之间的spacing就有可能不是相等的,但是这个spacing仍然是大于等于StackView设置的spacing的,不会是小于。这个类型布局仍然是如果StackView有多余的空间会均分给arrangedSubviews之间的spacing,如果空间不够那就按照CompressionResistance的优先级压缩arrangedSubview

image

在介绍distribution的约束创建和管理的过程中也涉及到了第二个知识点spacingdistribution的关系及约束的创建的内容,所以这两部都在这里介绍了。

distribution方向同样也包括4种约束,这4种约束也都是添加到canvas上的,除此之外它还包括一组通过NSMapTable维护的FDGapLayoutGuide

1
2
3
4
5
6
7
8
@interface FDStackViewDistributionLayoutArrangement : FDStackViewLayoutArrangement
@property (nonatomic, strong) NSMutableArray<NSLayoutConstraint *> *canvasConnectionConstraints;
@property (nonatomic, strong) NSMapTable<UIView *, NSLayoutConstraint *> *edgeToEdgeConstraints;
@property (nonatomic, strong) NSMapTable<UIView *, NSLayoutConstraint *> *relatedDimensionConstraints;
@property (nonatomic, strong) NSMapTable<UIView *, NSLayoutConstraint *> *hiddingDimensionConstraints;

@property (nonatomic, strong) NSMapTable<UIView *, FDGapLayoutGuide *> *spacingOrCenteringGuides;
@end
  • canvasConnectionConstraints:它管路的是arrangedSubviewscanvas之间的约束;
  • edgeToEdgeConstraints:它管理的是arrangedSubviews之间一个接一个的约束,这里需要注意这些约束的常量是StackView的spacing,但是关系却不一定是相等。还有就是如果有个arrangedSubviewhidden了那么它仍然参与到edgeToEdge的约束创建及布局中,只不过是把它与后一个arrangedSubview之间的edgeToEdgeConstraint的常量由spacing设置为0
  • relatedDimensionConstraints:它管理的是arrangedSubviews之间distribution各种相等关系的约束,这里面的管理的约束是StackViewdistribution布局的精髓所在。如果是UIStackViewDistributionFill模式的话,是没有relatedDimensionConstraint的。UIStackViewDistributionFillEquallyUIStackViewDistributionFillProportionally使用的是一种类型的约束,而UIStackViewDistributionEqualCenteringUIStackViewDistributionEqualSpacing使用的却是另一种类型的约束,后面在详细介绍。
  • hiddingDimensionConstraints:它管理的是当arrangedSubviewshidden的时候,该arrangedSubview的有关dimensionAttribute的约束;
  • spacingOrCenteringGuides:这个管理的就不是约束了,它是一组FDGapLayoutGuide,只用在UIStackViewDistributionEqualCenteringUIStackViewDistributionEqualSpacing这两种模式中,FDGapLayoutGuide用来连接左右两个arrangedSubView,作为一个辅助view来约束左右两个view的位置关系。spacingOrCenteringGuides的key是FDGapLayoutGuide连接的左边的arrangedSubview

最后说明的就是FDGapLayoutGuidearrangedSubView相连接的约束没有被NSMapTable所管理,它们就只是被加到了canvas上。因为当模式改变时,所有的FDGapLayoutGuide会被移除或者重建,所以跟它们相关的约束也会被一并清楚。

那么以上几种约束的创建顺序是怎样的呢?

  1. 首先是canvasConnectionConstraints
  2. 其次是每一种模式都会涉及到的edgeToEdgeConstraints
  3. 然后再遍历所有arrangedSubviews,如果有arrangedSubviewhidden了,那么就会创建hiddingDimensionConstraints
  4. 最后是relatedDimensionConstraints,这里如果是UIStackViewDistributionEqualCenteringUIStackViewDistributionEqualSpacing这两种模式的话,会先创建出spacingOrCenteringGuides

下面具体来看,首先canvasConnectionConstraints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)resetCanvasConnectionsEffect {
    [self.canvas removeConstraints:self.canvasConnectionConstraints];
    if (!self.items.count) return;

    NSMutableArray *canvasConnectionConstraints = [NSMutableArray new];
    NSLayoutAttribute minAttribute = [self minAttributeForCanvasConnections];
    NSLayoutConstraint *head = [NSLayoutConstraint constraintWithItem:self.canvas attribute:minAttribute relatedBy:NSLayoutRelationEqual toItem:self.items.firstObject attribute:minAttribute multiplier:1 constant:0];
    [canvasConnectionConstraints addObject:head];
    head.identifier = @"FDSV-canvas-connection";

    NSLayoutConstraint *end = [NSLayoutConstraint constraintWithItem:self.canvas attribute:minAttribute + 1 relatedBy:NSLayoutRelationEqual toItem:self.items.lastObject attribute:minAttribute + 1 multiplier:1 constant:0];
    [canvasConnectionConstraints addObject:end];
    end.identifier = @"FDSV-canvas-connection";

    self.canvasConnectionConstraints = canvasConnectionConstraints;
    [self.canvas addConstraints:canvasConnectionConstraints];
}

比较简单,先判断一下不需要创建的情况,然后就是根据axis选用不同的NSLayoutAttribute,将第一个和最后一个arrangedSubview分别与StackView创建相等的约束。这样一来再加上FDStackViewAlignmentLayoutArrangement中创建的两个canvasConnectionConstraints,整个canvas的上下左右四个方向的约束就都有了,满足了canvas布局的基本条件。

接下来是edgeToEdgeConstraints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- (void)resetFillEffect {
    // spacing - edge to edge
    [self.canvas removeConstraints:self.edgeToEdgeConstraints.fd_allObjects];
    [self.edgeToEdgeConstraints removeAllObjects];
    [self.canvas removeConstraints:self.hiddingDimensionConstraints.fd_allObjects];
    [self.hiddingDimensionConstraints removeAllObjects];

    UIView *offset = self.items.car;
    UIView *last = self.items.lastObject;
    for (UIView *view in self.items.cdr) {
        NSLayoutAttribute attribute = [self minAttributeForGapConstraint];
        NSLayoutRelation relation = [self edgeToEdgeRelation];
        NSLayoutConstraint *spacing = [NSLayoutConstraint constraintWithItem:view attribute:attribute relatedBy:relation toItem:offset attribute:attribute + 1 multiplier:1 constant:self.spacing];
        spacing.identifier = @"FDSV-spacing";
        [self.canvas addConstraint:spacing];
        [self.edgeToEdgeConstraints setObject:spacing forKey:offset];
        if (offset.hidden || (view == last && view.hidden)) {
            spacing.constant = 0;
        }
        offset = view;
    }
    // hidding dimensions
    for (UIView *view in self.items) {
        if (view.hidden) {
            NSLayoutAttribute dimensionAttribute = [self dimensionAttributeForCurrentAxis];
            NSLayoutConstraint *dimensionConstraint = [NSLayoutConstraint constraintWithItem:view attribute:dimensionAttribute relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
            dimensionConstraint.identifier = @"FDSV-hiding";
            [self.canvas addConstraint:dimensionConstraint];
            [self.hiddingDimensionConstraints setObject:dimensionConstraint forKey:view];
        }
    }
}

先移去旧的相关约束,然后将arrangedSubviews依次迭代遍历,根据axis选择正确的NSLayoutAttribute创建首尾相接的约束,常量为StackView的spacing,关系则根据distribution的不同而或等于或大于等于。

这里如前面介绍的一样,如果这个arrangedSubviewhidden的那么它仍然参与edgeToEdgeConstraints的创建,只不过它与后一个arrangedSubview的约束常量不再是spacing而是0。还有一个特殊的就是如果是最后一个arrangedSubviewhidden了,那么它与前一个arrangedSubview的约束的常量也同样是0

最后再遍历所有arrangedSubviews,如果有arrangedSubviewhidden了,那就根据axis给这个arrangedSubview创建一个常量为0dimensionConstraint

如果是UIStackViewDistributionFill的话,那么到这里所有distribution的约束就已经创建完了,已经满足需求了。但是其他几种还要有后续的步骤。


先来看UIStackViewDistributionFillEquallyUIStackViewDistributionFillProportionally这两种类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- (void)resetEquallyEffect {
    [self.canvas removeConstraints:self.relatedDimensionConstraints.fd_allObjects];
    [self.relatedDimensionConstraints removeAllObjects];

    NSArray<UIView *> *visiableViews = self.visiableItems;
    UIView *offset = visiableViews.car;
    CGFloat order = 0;
    for (UIView *view in visiableViews.cdr) {
        NSLayoutAttribute attribute = [self dimensionAttributeForCurrentAxis];
        NSLayoutRelation relation = NSLayoutRelationEqual;
        CGFloat multiplier = self.distribution == UIStackViewDistributionFillEqually ? 1 : ({
            CGSize size1 = offset.intrinsicContentSize;
            CGSize size2 = view.intrinsicContentSize;
            CGFloat multiplier = 1;
            if (attribute == NSLayoutAttributeWidth) {
                multiplier = size1.width / size2.width;
            } else {
                multiplier = size1.height / size2.height;
            }
            multiplier;
        });
        NSLayoutConstraint *equally = [NSLayoutConstraint constraintWithItem:offset attribute:attribute relatedBy:relation toItem:view attribute:attribute multiplier:multiplier constant:0];
        equally.priority = UILayoutPriorityRequired - (++order);
        equally.identifier = self.distribution == UIStackViewDistributionFillEqually ? @"FDSV-fill-equally" : @"FDSV-fill-proportionally";
        [self.canvas addConstraint:equally];
        [self.relatedDimensionConstraints setObject:equally forKey:offset];

        offset = view;
    }
}

仍然是先干掉旧的约束,然后跟前面不同的是要取出所有的非hiddenarrangedSubview添加约束,而不是所有arrangedSubview

这两个distribution类型是将当前axis所对应的dimensionAttribute的约束作用在arrangedSubviews上,如果是UIStackViewDistributionFillEqually,那么约束的比例(multiplier)就是1,如果是UIStackViewDistributionFillProportionally,那multiplier就需要通过计算得出,是通过两个arrangedSubviewintrinsicContentSize做比值,这样就能保证arrangedSubview最终会按照intrinsicContentSize的比例来分配StackView的空间布局。

再来看UIStackViewDistributionEqualCenteringUIStackViewDistributionEqualSpacing这两种类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
- (void)resetSpacingOrCenteringGuides {
    [self.spacingOrCenteringGuides.fd_allObjects makeObjectsPerformSelector:@selector(removeFromSuperview)];
    [self.spacingOrCenteringGuides removeAllObjects];
    NSArray<UIView *> *visiableItems = self.visiableItems;
    if (visiableItems.count <= 1) {
        return;
    }

    [[visiableItems subarrayWithRange:(NSRange){0, visiableItems.count - 1}] enumerateObjectsUsingBlock:^(UIView *item, NSUInteger idx, BOOL *stop) {
        FDGapLayoutGuide *guide = [FDGapLayoutGuide new];
        [self.canvas addSubview:guide];
        guide.translatesAutoresizingMaskIntoConstraints = NO;
        UIView *relatedToItem = visiableItems[idx+1];

        NSLayoutAttribute minGapAttribute = [self minAttributeForGapConstraint];
        NSLayoutAttribute minContentAttribute;
        NSLayoutAttribute maxContentAttribute;
        if (self.distribution == UIStackViewDistributionEqualCentering) {
            minContentAttribute = self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeCenterX : NSLayoutAttributeCenterY;
            maxContentAttribute = minContentAttribute;
        } else {
            minContentAttribute = minGapAttribute;
            maxContentAttribute = minGapAttribute + 1;
        }

        NSLayoutConstraint *beginGap = [NSLayoutConstraint constraintWithItem:guide attribute:minGapAttribute relatedBy:NSLayoutRelationEqual toItem:item attribute:maxContentAttribute multiplier:1 constant:0];
        beginGap.identifier = @"FDSV-distributing-edge";
        NSLayoutConstraint *endGap = [NSLayoutConstraint constraintWithItem:relatedToItem attribute:minContentAttribute relatedBy:NSLayoutRelationEqual toItem:guide attribute:minGapAttribute + 1 multiplier:1 constant:0];
        endGap.identifier = @"FDSV-distributing-edge";
        [self.canvas addConstraint:beginGap];
        [self.canvas addConstraint:endGap];

        [self.spacingOrCenteringGuides setObject:guide forKey:item];
    }];
}

- (void)resetSpacingOrCenteringGuideRelatedDimensionConstraints {
    [self.canvas removeConstraints:self.relatedDimensionConstraints.fd_allObjects];
    NSArray<UIView *> *visiableItems = self.visiableItems;
    if (visiableItems.count <= 1) return;

    FDGapLayoutGuide *firstGapGuide = [self.spacingOrCenteringGuides objectForKey:visiableItems.car];
    [self.spacingOrCenteringGuides.fd_allObjects enumerateObjectsUsingBlock:^(UIView *obj, NSUInteger idx, BOOL *stop) {
        if (firstGapGuide == obj) return;
        NSLayoutAttribute dimensionAttribute = [self dimensionAttributeForCurrentAxis];
        NSLayoutConstraint *related = [NSLayoutConstraint constraintWithItem:firstGapGuide attribute:dimensionAttribute relatedBy:NSLayoutRelationEqual toItem:obj attribute:dimensionAttribute multiplier:1 constant:0];
        related.identifier = @"FDSV-fill-equally";
        [self.relatedDimensionConstraints setObject:related forKey:obj];
        [self.canvas addConstraint:related];
    }];
}

先创建spacingOrCenteringGuides,开始是干掉旧的spacingOrCenteringGuides。这里使用的仍然是visiableItemsFDGapLayoutGuide用来连接左右相连的两个可见arrangedSubview

这两个distribution不同的地方就是UIStackViewDistributionEqualSpacingFDGapLayoutGuide连接的是arrangedSubviewminAttributemaxAttribute,而UIStackViewDistributionEqualCenteringFDGapLayoutGuide连接的却是arrangedSubviewcenterAttribute

接下来就是创建relatedDimensionConstraints,就是根据axis不同给对应的dimensionAttribute创建相等的约束即可,这些约束是作用在FDGapLayoutGuide上的,而与前面那两种distribution类型不同。这就是一开始说的relatedDimensionConstraints中的两种类型的约束。

到此整个distribution方向的约束也都创建完了。加上alignment方向创建的约束,StackView已经可以使用了。


介绍完这些再回过头来看本文章Part 1中后面提到的UIStackView的第一个bug,当存在spacing的时候UIStackViewDistributionFillProportionally这个类型的StackView是烂掉的。我刚才看了一下,苹果仍然没有修复这个bug。

具体的原因那篇文章中已经解释了,现在说下为什么FDStackView没有这个问题,相信看完前面创建约束的过程,读者朋友应该就能发现我们并没有像UIStackView那样将canvasdimensionAttribute乘以一个系数作为arrangedSubviewdimensionConstraint。我们的arrangedSubviewdimensionConstraint是与canvas无关的,是arrangedSubviews之间的比例关系,而且spacing在之前的edgeToEdgeConstraints中就已经创建了,这两者是分开创建的,所以算法不同,自然也就不会出现这个bug。


下面看其余的知识点:

子视图的隐藏显示如何处理

如果一个已经布好局的StackView,在一个arrangedSubviewhidden或者show之后,那么其余的arrangedSubviews也要做出相应变化,来相应这种变化。

FDStackView这里我们是通过KVO监测每一个arrangedSubviewhidden属性,当任何一个arrangedSubview属性发生变化后,我们就通过rebuild的方式重新创建整个StackView的约束,就是重新布局一遍。这是目前1.0版本的处理方式,这样势必会带来性能的损失,这也是我们后续优化性能的关键。

子视图的intrinsicContentSize发生变化时如何处理

什么叫子视图的intrinsicContentSize发生变化呢?举个例子,一个已经布好局的StackView,其中有一个arrangedSubview是一个UILabel,但是这个UILabel被重新setText了,那么它的intrinsicContentSize就会发生变化,自然StackView的布局如果不发生变化的话就是错误的。所以在这种情况下StackView也要做出处理。

这里我们研究了UIStackView的实现方式,一个arrangedSubviewintrinsicContentSize发生变化如何被捕捉到,是我们未知的,UIKit并没有暴露任何方法给我们,我们只能通过下符号断点的方式给dump出来的UIStackView的私有类。

研究发现当一个arrangedSubviewintrinsicContentSize发生变化时,UIStackView总会调用到_intrinsicContentSizeInvalidatedForChildView:这个私有方法,参数为发生变化的arrangedSubview。所以我们就把这一私有方法给替换了,借助UIKit内部的机制来帮我们通知一个arrangedSubviewintrinsicContentSize发生变化的这种情况。

1
2
3
4
5
6
7
8
9
// Use non-public API in UIView directly is dangerous, so we inject at runtime.
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL selector = NSSelectorFromString(@"_intrinsicContentSizeInvalidatedForChildView:");
        Method method = class_getInstanceMethod(self, @selector(intrinsicContentSizeInvalidatedForChildView:));
        class_addMethod(self, selector, method_getImplementation(method), method_getTypeEncoding(method));
    });
}

接到这种通知之后,我们目前也是通过rebuild的方式来重建StackView的约束的。其实对于这种情况以及上面提到的hidden的情况,我们都能得到具体发生变化的那个arrangedSubview,这也将会是后续优化的突破口。


到此整个FDStackView的设计实现过程都介绍完了,当然还有一些零零碎碎的点没有说,都在源码里了。后续版本会增加Layout Margins的支持,以及性能优化。

最后在附一张UIStackViewFDStackView在不同iOS系统上加载运行图:

全文完,转载请注明出处,谢谢阅读。

————————————

Comments