GeekerProbe

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

FDStackView —— Downward Compatible UIStackView (Part 2)

| Comments

写完了Part 1就被接踵而至的新项目和新版本忙的不可开交,转眼间一个季度就已经过去了,而这篇Part 2却迟迟还没有出现。实在是抱歉没有及时更新。不过有一个好消息就是FDStackView已经被使用在我们自己的项目中,并且我们的项目也已经经过了两个版本的迭代,FDStackView可以说还是相当稳定的,并且可以顺利的通过苹果的审核机制,对这方面有顾虑的小伙伴们可以放心大胆的使用了。同时我们也将它的版本号从1.0-alpha升级到1.0。在此感谢一下各位热心的小伙伴们在Github上提出的issue,以及着重感谢下@里脊串FDStackView的重度使用及提出的各种隐晦的bug。后续我们将会对性能的优化做出改进,以及对Layout Margins的支持。

回到主题,这篇文章主要介绍StackView的实现,即如何通过现有AutoLayout技术实现StackView这样的一个控件。这里说明一下,当初我们编写FDStackView的时候,UIStackView还没有支持Layout Margins,所以我们也没有添加Layout Margins的支持,不过目前的iOS SDK已经增加了这一部分的支持,所以在打开layoutMarginsRelativeArrangement属性的情况下,StackView创建出的约束会与我后面所介绍的内容有一些出入,不过问题不大,仅仅是部分约束的firstItemStackView本身变成UILayoutGuide的区别。

实现StackView主要包括这几个技术点:

  • alignmentdistribution的约束如何添加和管理;
  • spacingdistribution的关系及约束的创建;
  • 子视图的隐藏显示如何处理;
  • 子视图的intrinsicContentSize发生变化时如何处理。

我们对UIStackView进行了详细的研究,包括dump出所有UIStackView的相关私有类,各个类的方法,实例变量等。还需要添加符号断点来跟踪各个方法的调用顺序及各个实例变量的值得变化情况。同时还需要分析各个状态下UIStackView的约束constraints的情况,包括约束的个数,连接的方式,及约束所添加到的视图等。经过以上的各种分析之后,我们又通过在IB中借助UIView手动连接约束的方式,连出每一个UIStackView所对应的状态。经过这一番调查与研究我们已经大概摸清的UIStackView的工作原理与实现方式。

如上篇文章所说,在进行了详尽的研究之后,总结出大概需要攻克的是这几个技术点,以尽可能的与UIStackView的实现保持一致,在难以完成的地方通过自己的方式实现。在这之前先介绍一下我们使用到的几个私有类。

CATransformLayer

StackView是一个透明不可见的容器,主要就是因为这个layer,我们继承了它并重载了两个方法,setOpaque:setOpaque:,用于避免产生警告⚠️。也就是项目中的FDTransformLayer

_UILayoutSpacer

这是一个私有类,它的主要作用是用了辅助StackView创建alignment方向上的约束,它的父类是UILayoutGuide,并不是一个UIView的子类,所以我们并不能以熟悉的方式对它添加约束。但是在知道了它的作用之后,我们完全可以使用一个UIView来代替它,同时它也是不可见的,所以它的layer自然也是FDTransformLayer。这是项目中的FDLayoutSpacer

_UIOLAGapGuide

_UILayoutSpacer相同是UILayoutGuide的子类,用来辅助distribution方向上的约束创建,并且只有UIStackViewDistributionEqualSpacingUIStackViewDistributionEqualCentering两种模式下它才会出现。在项目中我们通过UIView的子类FDGapLayoutGuide来实现它。

_UILayoutArrangement

同样是一个私有类,用来管理StackView及其子视图的约束的创建。它是一个父类,在FDStackView中我们使用FDStackViewLayoutArrangement来与之对应。

_UIAlignedLayoutArrangement

该类是_UILayoutArrangement的子类,用来控制alignment方向上的约束的创建及管理,它维护了一个_UILayoutSpacer并负责它的生命周期。在FDStackView中我们以更直接的FDStackViewAlignmentLayoutArrangement来对它命名。

_UIOrderedLayoutArrangement

_UIAlignedLayoutArrangement相对,用来控制distribution方向上的约束创建及管理,它维护了一组_UIOLAGapGuide。在FDStackView中我们以更直接的FDStackViewDistributionLayoutArrangement来对它命名。

先提前解释几个后面会提到的名词:

  • canvascanvas是什么?翻译过来是画布的意思,其实就是容器也就是StackView本身
  • Ambiguity Suppression :经常Debug``AutoLayout的同学可能对这个词并木陌生,一般约束产生冲突或者模棱两可的时候,控制台就会输出一组信息,其中就会包含这个词。这里就是抵制模棱两可的约束的意思。StackView中会创建一些低优先级的约束来完成这件事儿,以防止控制台打出AutoLayout异常的log
  • minAttribute :是NSLayoutAttribute一个便捷获取方式,针对不同的axis会对应不同的NSLayoutAttribute,可能是NSLayoutAttributeTop也可能是NSLayoutAttributeLeading
  • centerAttribute :同样针对不同的axis可能是NSLayoutAttributeCenterY或者NSLayoutAttributeCenterX
  • maxAttribute :同样针对不同的axis可能是NSLayoutAttributeBottom或者NSLayoutAttributeTrailing
  • dimensionAttribute :同样针对不同的axis可能是NSLayoutAttributeHeight或者NSLayoutAttributeWidth
FDStackViewAlignmentLayoutArrangement
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (NSLayoutAttribute)minAttributeForCanvasConnections {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeTop : NSLayoutAttributeLeading;
}

- (NSLayoutAttribute)centerAttributeForCanvasConnections {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeCenterY : NSLayoutAttributeCenterX;
}

- (NSLayoutAttribute)maxAttributeForCanvasConnections {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeBottom : NSLayoutAttributeTrailing;
}

- (NSLayoutAttribute)dimensionAttributeForCurrentAxis {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeHeight : NSLayoutAttributeWidth;
}
FDStackViewAlignmentLayoutArrangement
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (NSLayoutAttribute)minAttributeForCanvasConnections {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeLeading : NSLayoutAttributeTop;
}

- (NSLayoutAttribute)centerAttributeForCanvasConnections {
      return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeCenterY : NSLayoutAttributeCenterX;
}

- (NSLayoutAttribute)dimensionAttributeForCurrentAxis {
      return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeWidth : NSLayoutAttributeHeight;
}

- (NSLayoutAttribute)minAttributeForGapConstraint {
    return self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeLeading : NSLayoutAttributeTop;
}

此外UIStackView的约束的管理方式也十分的奇妙。除了一个例外的Ambiguity Suppression的约束,其余不管约束何种关系的约束都是add在canvas上的。既然约束都加在了canvas上,那这么多的约束如何区分何管理呢?

这里有个小技巧,那就是用weakToWeakNSMapTable来管理,key是约束的firstItem,value是约束,而且因为NSMapTableweakToWeak的,所以keyvalue所对应的object并不会增加引用计数,不会带来内存上的管理困难。若要找一个view所关联约束,直接取view作为keyvalue就可以了。_UILayoutArrangement维护了多个这样的NSMapTable,分别来管理不同作用的约束。不得不说这样的设计真的是太巧妙了。


alignmentdistribution的约束如何添加和管理

先给一张图看一下什么是alignmentdistribution以及Spacing:

image

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

  • UIStackViewAlignmentFill:这种就是填充满整个StackView了,用得比较多。

image

  • UIStackViewAlignmentLeading:这种是左对齐。

image

  • UIStackViewAlignmentTop:这种是上部对齐。

image

  • UIStackViewAlignmentFirstBaseline:这种是让arrangedSubviews按照firstBaseline对齐。只能出现在水平的StackView中。

image

  • UIStackViewAlignmentCenter:这种是居中对齐。

image

  • UIStackViewAlignmentTrailing:这种是右部对齐。

image

  • UIStackViewAlignmentBottom:这种是底部对齐。

image

  • UIStackViewAlignmentLastBaseline:这种是让arrangedSubviews按照lastBaseline对齐。同样只能出现在水平的StackView中。

image

下面介绍实现,首先是alignment方向,alignment方向的约束主要包括4种

1
2
3
4
5
6
7
8
9
@interface FDStackViewAlignmentLayoutArrangement : FDStackViewLayoutArrangement
@property (nonatomic, strong) NSMutableArray<NSLayoutConstraint *> *canvasConnectionConstraints;
@property (nonatomic, strong) NSMapTable<UIView *, NSLayoutConstraint *> *hiddingDimensionConstraints;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSMapTable *> *alignmentConstraints;
@end

@interface FDLayoutSpacer : UIView
@property (nonatomic, strong, readonly) NSMutableArray<NSLayoutConstraint *> *systemConstraints;
@end
  • canvasConnectionConstraints:它管理的是arrangedSubviewscanvas之间的约束;
  • hiddingDimensionConstraints:它管理的是当arrangedSubviewshidden的时候,该arrangedSubview的有关dimensionAttribute的约束;
  • systemConstraints:它是由_UILayoutSpacer来管理的,它管理了spacer与arrangedSubviews之间的约束,因为这些约束的firstItem都是spacer自身,所以就不需要使用NSMapTable而直接是NSArray。另外spacer只有在alignment不是UIStackViewAlignmentFill的时候才会被创建,所以当alignmentUIStackViewAlignmentFill时,是没有systemConstraints的
  • alignmentConstraints:它管理的是arrangedSubviews之间的约束,它包括两组NSMapTable,根据alignment的不同具体的约束也不同,具体的NSMapTablekeyalignmentaxis的关系如下表:

可以看到除了UIStackViewAlignmentFill模式以外,都会有一个Ambiguity Suppression的key,这个key对应的NSMapTable的就管理了前面提到的那些低优先级防止布局时出现模棱两可状态的约束。此外Baseline相关的约束是只有在axisHorizontal时才会有的,并且UIStackViewAlignmentFirstBaselineUIStackViewAlignmentTopUIStackViewAlignmentLastBaselineUIStackViewAlignmentBottom的key值是相同的。

这个key的名字之所以这么取也是有讲究的,它代表着它所对应的NSMapTable管理的约束关系。举个例子:axisHorizontalalignmentUIStackViewAlignmentFill时,key为TopBottom,那么Top对应的NSMapTable管理的约束就是arrangedSubviews之间NSLayoutAttributeTop相等的约束。同理Bottom就是NSLayoutAttributeBottom相等的约束。

这样结合alignment的效果来看就很容易理解,UIStackViewAlignmentFill模式需要arrangedSubviews都充满容器,那么自然他们的NSLayoutAttributeTopNSLayoutAttributeBottom需要都相等,而UIStackViewAlignmentTop模式需要top对齐那么只需要NSLayoutAttributeTop相等就OK了。

这里还有一个点就是arrangedSubviews之间的约束不是迭代添加的,而是都与第一个arrangedSubview创建关系。假设有3个view,那就是view2view1建立约束,view3同样与view1建立约束而不是与view2迭代建立约束。

这4种约束的创建顺序是:

  1. FDLayoutSpacer的systemConstraints
  2. canvasConnectionConstraints
  3. alignmentConstraints
  4. hiddingDimensionConstraints

FDLayoutSpacer的systemConstraintsFDStackViewAlignmentLayoutArrangement中被称为spanningLayoutGuideConstraints,创建方法是

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)updateSpanningLayoutGuideConstraintsIfNecessary {
    if (self.mutableItems.count == 0) {
        return;
    }

    if (self.spanningLayoutGuide && self.spanningGuideConstraintsNeedUpdate) {
        [self.canvas removeConstraints:self.spanningLayoutGuide.systemConstraints];
        [self.spanningLayoutGuide.systemConstraints removeAllObjects];

        //FDSV-spanning-fit
        NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:self.spanningLayoutGuide attribute:self.spanningLayoutGuide.isHorizontal ? NSLayoutAttributeWidth : NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
        constraint.priority = 51;
        constraint.identifier = @"FDSV-spanning-fit";
        [self.canvas addConstraint:constraint];
        [self.spanningLayoutGuide.systemConstraints addObject:constraint];

        //FDSV-spanning-boundary
        [self.mutableItems enumerateObjectsUsingBlock:^(UIView *item, NSUInteger idx, BOOL *stop) {
            NSLayoutConstraint *minConstraint = [NSLayoutConstraint constraintWithItem:self.spanningLayoutGuide attribute:self.minAttributeForCanvasConnections relatedBy:[self layoutRelationForItemConnectionForAttribute:self.minAttributeForCanvasConnections] toItem:item attribute:self.minAttributeForCanvasConnections multiplier:1 constant:0];
            minConstraint.identifier = @"FDSV-spanning-boundary";
            minConstraint.priority = 999.5;
            [self.canvas addConstraint:minConstraint];
            [self.spanningLayoutGuide.systemConstraints addObject:minConstraint];

            NSLayoutConstraint *maxConstraint = [NSLayoutConstraint constraintWithItem:self.spanningLayoutGuide attribute:self.maxAttributeForCanvasConnections relatedBy:[self layoutRelationForItemConnectionForAttribute:self.maxAttributeForCanvasConnections] toItem:item attribute:self.maxAttributeForCanvasConnections multiplier:1 constant:0];
            maxConstraint.identifier = @"FDSV-spanning-boundary";
            maxConstraint.priority = 999.5;
            [self.canvas addConstraint:maxConstraint];
            [self.spanningLayoutGuide.systemConstraints addObject:maxConstraint];
        }];
    }
}

首先判断一些不需要创建或者不需要更新这组约束的情况,比如之前提到的alignmentUIStackViewAlignmentFill或者没有arrangedSubview的时候。接下来创建一个宽或高为0的约束给spacer,因为对于后面添加的约束而言,spacer是缺少这样的一个约束以保证它能够正确布局。最后就是把每一个arrangedSubview与spacer分别建立minAttributemaxAttribute的约束,这些约束的constant都是0,但是关系却不一定都是等于,需要根据alignment的属性不同来动态调整,有可能是大于等于,也有可能是小于等于。这需要查表来得到。

下一步创建canvasConnectionConstraints

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
- (void)updateCanvasConnectionConstraintsIfNecessary {
    if (self.mutableItems.count == 0) {
        return;
    }

    [self.canvas removeConstraints:self.canvasConnectionConstraints];
    [self.canvasConnectionConstraints removeAllObjects];

    NSArray<NSNumber *> *canvasAttributes = @[@(self.minAttributeForCanvasConnections), @(self.maxAttributeForCanvasConnections)];
    if (self.alignment == UIStackViewAlignmentCenter) {
        canvasAttributes = [canvasAttributes arrayByAddingObject:@(self.centerAttributeForCanvasConnections)];
    } else if (self.isBaselineAlignment) {
        NSLayoutConstraint *canvasFitConstraint = [NSLayoutConstraint constraintWithItem:self.canvas attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
        canvasFitConstraint.identifier = @"FDSV-canvas-fit";
        canvasFitConstraint.priority = 49;
        [self.canvas addConstraint:canvasFitConstraint];
        [self.canvasConnectionConstraints addObject:canvasFitConstraint];
    }

    [canvasAttributes enumerateObjectsUsingBlock:^(NSNumber *canvasAttribute, NSUInteger idx, BOOL *stop) {
        NSLayoutAttribute attribute = canvasAttribute.integerValue;
        NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:[self viewOrGuideForLocationAttribute:attribute] attribute:attribute relatedBy:[self layoutRelationForCanvasConnectionForAttribute:attribute] toItem:self.canvas attribute:attribute multiplier:1 constant:0];
        constraint.identifier = @"FDSV-canvas-connection";
        [self.canvas addConstraint:constraint];
        [self.canvasConnectionConstraints addObject:constraint];
    }];
}

因为这是alignmentcanvasConnectionConstraints,所以只需关注它自己的minAttributemaxAttribute两个方向与canvas的约束即可,其余两个方向会在distributionLayoutArrangement中创建。

特别的是如果alignmentUIStackViewAlignmentCenter的话需要加上一个centerAttribute的约束。如果是alignmentbaseline相关的话还要给canvas添加一个高为0的低优先级约束,用来满足某些特殊情况下canvas约束不满足的情况。

具体与canvas建立约束关系的firstItemrelation关系是根据alignment类型以及NSLayoutAttribute的不同而不同的,情况比较多我就不一一列举了,同样是根据查表得到,具体可以看代码去查。

最后是alignmentConstraintshiddingDimensionConstraints,虽然前面说它们两个的顺序是一前一后创建,但其实并不是,它们可以说是一起创建的,首先取出第一个arrangedSubview作为guardView,然后循环遍历其余arrangedSubview,先添加alignmentConstraint,如果这个arrangedSubviewhidden的那么就会再添加一个hiddingDimensionConstraint

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
- (void)updateAlignmentItemsConstraintsIfNecessary {
    if (self.mutableItems.count == 0) {
        return;
    }

    [self.alignmentConstraints setObject:[NSMapTable weakToWeakObjectsMapTable] forKey:self.alignmentConstraintsFirstKey];
    [self.alignmentConstraints setObject:[NSMapTable weakToWeakObjectsMapTable] forKey:self.alignmentConstraintsSecondKey];
    [self.canvas removeConstraints:self.hiddingDimensionConstraints.fd_allObjects];
    [self.hiddingDimensionConstraints removeAllObjects];

    UIView *guardView = self.mutableItems.firstObject;
    [self.mutableItems enumerateObjectsUsingBlock:^(UIView *item, NSUInteger idx, BOOL *stop) {
        if (self.alignment != UIStackViewAlignmentFill) {
            NSLayoutConstraint *ambiguitySuppressionConstraint = [NSLayoutConstraint constraintWithItem:item attribute:self.alignmentConstraintsFirstAttribute relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
            ambiguitySuppressionConstraint.identifier = @"FDSV-ambiguity-suppression";
            ambiguitySuppressionConstraint.priority = 25;
            [item addConstraint:ambiguitySuppressionConstraint];
            [self.alignmentConstraints[self.alignmentConstraintsFirstKey] setObject:ambiguitySuppressionConstraint forKey:item];
        } else {
            if (item != guardView) {
                NSLayoutConstraint *firstConstraint = [NSLayoutConstraint constraintWithItem:guardView attribute:self.alignmentConstraintsFirstAttribute relatedBy:NSLayoutRelationEqual toItem:item attribute:self.alignmentConstraintsFirstAttribute multiplier:1 constant:0];
                firstConstraint.identifier = @"FDSV-alignment";
                [self.canvas addConstraint:firstConstraint];
                [self.alignmentConstraints[self.alignmentConstraintsFirstKey] setObject:firstConstraint forKey:item];
            }
        }
        if (item != guardView) {
            NSLayoutConstraint *secondConstraint = [NSLayoutConstraint constraintWithItem:guardView attribute:self.alignmentConstraintsSecondAttribute relatedBy:NSLayoutRelationEqual toItem:item attribute:self.alignmentConstraintsSecondAttribute multiplier:1 constant:0];
            secondConstraint.identifier = @"FDSV-alignment";
            [self.canvas addConstraint:secondConstraint];
            [self.alignmentConstraints[self.alignmentConstraintsSecondKey] setObject:secondConstraint forKey:item];
        }
        if (item.hidden) {
            NSLayoutConstraint *hiddenConstraint = [NSLayoutConstraint constraintWithItem:item attribute:self.axis == UILayoutConstraintAxisHorizontal ? NSLayoutAttributeHeight : NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:0];
            hiddenConstraint.priority = [item contentCompressionResistancePriorityForAxis:self.axis == UILayoutConstraintAxisHorizontal ? UILayoutConstraintAxisVertical : UILayoutConstraintAxisHorizontal];
            hiddenConstraint.identifier = @"FDSV-hiding";
            [self.canvas addConstraint:hiddenConstraint];
            [self.hiddingDimensionConstraints setObject:hiddenConstraint forKey:item];
        }
    }];
}

这里的alignmentConstraint的创建都是guardView与其余的arrangedSubview创建relation关系为相等的约束,而NSLayoutAttribute的选择仍然是查表法,根据axisalignment的不同而选择不同的NSLayoutAttribute

如果alignment不是UIStackViewAlignmentFill模式的话,就会给arrangedSubview创建一个dimensionAttribute0的低优先级约束,称为ambiguitySuppressionConstraint放在上图中keyAmbiguity SuppressionNSMapTable中。


现在解释一下本文章Part 1中最后提到的UIStackViewalignmentUIStackViewAlignmentFill时,最高视图隐藏掉,而其余视图没有变成第二个的视图的高度的bug。原因就是在UIStackView的中实现中AlignmentLayoutArrangement是没有管理hiddingDimensionConstraints的,所以当视图被隐藏了后,那个视图被添加了一个宽为0的约束,视觉上看不到了,但是高方向的约束仍然存在,所以仍然会撑开StackView,所以在FDStackView中我们在alignment方向上同时增加了hiddingDimensionConstraints,视图被hidden后,会在高度方向上也给他加上一个高0为的约束,而且这个优先级也很有讲究需要跟它的contentCompressionResistancePriority设为一样,这样才不会在AutoLayout布局系统中当用户人为添加一个高度约束后产生冲突。

写了这么多,才写完第一个技术点的第一部分,内容确实比较多,我写的也比较乱,时间比较紧所以写作时间是间断的,所以思维也是间断跳跃的,还麻烦各位看官多多包涵。本来打算一篇写完的,但是这么长,还是有必要在分一下的,Part 2就到这吧,其余的内容就在Part 3吧。

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

Comments