iOS 利用 Autolayout 实现 view 间隔自动调整

4,295 阅读4分钟
原文链接: nathanli.cn

1、需求

不知道大家是否常有这样的需求:一个界面中,有多个 view,每个 view 的大小由其内容决定。当一个 view 有内容时,下一个 view 与它之间会一个间隔。如果没有内容的话,下一个 view 就会紧挨着它。如下图所示:

example1
[图1]

图1 中,四个 label 的大小是自适应的,且每个 label 相隔 10px。这种情况下,视图看起来是很正常的。但如果其中某些 label 没有文字呢?看下图:

example2
[图2] label2.text = nil

example3
[图3] label2.text = nil; label3.text= nil;

图2 是 label3.textnil,中间有一个明显过大的间隔,这是由于 label3 的高度虽然为 0,但由于它与 label2 的间隔为 10px,而 label4 与它的间隔又是 10px,所以造成了图中 label2label4 的过大的间隔。

图3 则更为惨不忍睹。

这时,咱们的需求就出来了:labellabel 之间的间隔能随着它们自身内容的变化而变化。当有文本时,间隔存在;当没有文本时,则紧挨在一起。当然,这里的间隔希望不仅是垂直的,水平方向也应该是一样的。

2、解决方案

来看看各种解决方法。

2.1 动态更新约束

这是最直观、容易想到的办法,就是在 label2 等内容有变化时,去调整相关的间隔约束:更改 constants优先级 等。

这种方法存在的问题是维护成本太高,这里只有四个 label ,但是要维护这种间隔约束关系主就已经很累了。所以这种方法是比较初级的,不灵活。

2.2 自定义 -(CGSize)intrinsicContentSize

视图的 内容 在 auto layout 中,其与约束是 同样重要的。视图有一个方法:– (CGSize)intrinsicContentSize,用来返回展示完整视力内容的最小 size

比如 UILabel 就是根据它的 textattributedTextpreferredMaxLayoutWidth 等来计算出它的内容 size

当视图内容改变时,可以调用 – (void)invalidateIntrinsicContentSize 方法来让 Auto Layout 在下次布局时重新计算。

咱们可以将间隔当作 内容 的一部分,将其计算在内:

@interface NLLabelIntervalView : UIView
 
@property (nonatomic, strong) UILabel *label;
@property (nonatomic, assign) CGSize intervalSize;
 
@end
 
@implementation NLLabelIntervalView
 
- (CGSize)intrinsicContentSize {
CGSize size = [self.label intrinsicContentSize];
if ([self.label.text length] > 0) {
size.width += self.intervalSize.width;
size.height += self.intervalSize.height;
}
 
return size;
}
 
@end

可以看到,这种方法在一定程度上可以解决间隔问题,但它有很大的不足:它将间隔 侵入 到内容中;需要 包装 目标视图,这个代价却实在有点大,虽然利用 继承 可以一部分 包装 问题,但类似于这里的 UILabel,由于它内容的绘制方法(文字垂直居中),继承 是无法做到 间隔 的。不过在自定义视图时,如果就间隔考虑进去的话,问题倒是不大。

所以,这种方案适用于自定义视图中,对系统定义的视图帮助有限。

2.3 利用对齐矩形(alignment rect)

你可能会直观的认为 Auto Layout 中,约束是使用 frame 来确定视图的大小和位置的,但实际上,它使用的是 对齐矩形(alignment rect) 这个几何元素。不过在大多数情况下,framealignment rect 是相等的,所以你这么理解也没什么不对。

系统有 frame 不用,为啥要用 alignment rect 呢?

有时候,咱们在创建复杂的视图时,可能会添加各种装饰元素,如阴影、外边框、角标等等。但考虑到开发这样的视图所需时间成本,或者为了避免离屏渲染等原因,会找设计师直接切相应的成品图给咱们。如下图:

alignment rect
(图片来源:iOS Auto Layout Demystified)

上图中,(a) 是咱们拿到的图,(c) 是这个图的 frame。显然,咱们在布局的时候,不想将阴影和角标考虑进去(视图的 center 和 底边、右边都发生了偏移),而是只考虑中间的核心部分,如图 (b) 中框出的矩形所示。

对齐矩形就是用来处理这种情况的。

UIView 提供了方法,由 frame 得到 alignment rect

// The alignment rectangle for the specified frame.
- (CGRect)alignmentRectForFrame:(CGRect)frame;

它得可逆,也就是说得能从 alignment rect 反过来得到 frame

// The frame for the specified alignment rectangle.
- (CGRect)frameForAlignmentRect:(CGRect)alignmentRect;
			

考虑到每次重写这两个方法比较烦,系统也提供了一个简便方法,由 inset 来指定 framealigment rect 的关系:

// The insets from the view’s frame that define its alignment rectangle.
- (UIEdgeInsets)alignmentRectInsets;
			

回到间隔问题。咱们可以将间隔当作上面提到的装饰,让 UILabelalignment rectframe 多个 10 point 间隔就好了:

@interface NLLabel : UILabel
@end
@implementation NLLabel
- (UIEdgeInsets)alignmentRectInsets {
return UIEdgeInsetsMake(.0, .0, -10.0, .0);
}

不过让人感觉迷惑是的,在 iOS 中,frameForAlignmentRect:alignmentRectForFrame: 重写之后,并没有起到预期的作用,OS X 中倒是正常。所以在 iOS 中,还是使用 alignmentRectInsets 的好。对于这个现象,还希望有了解的同学帮忙解释一下。

当然,每次都得要继承才能使用对齐矩形,毕竟不太方便,也许 关联对象method swizzled 组合起来是个可行方案:

#import 
@interface UIView (nl_aligmentRectInsets)
@property (nonatomic, copy) UIEdgeInsets (^nl_alignmentRectInsets)(UIEdgeInsets originInsets);
@end
...

3 总结

对齐矩形可是个好玩意呢~

参考:
1、Apple Developer
2、Advanced Auto Layout Toolbox
3、iOS Auto Layout Demystified