有趣的Autolayout示例3-Masonry实现

更新

  • 2016-06-02: 感谢我不是蜡笔小新但是我有小白,发现了Case 2的最后两个cell展开动画问题,原来是tableView的estimatedRowHeight会跟展开动画冲突的缘故。
  • 2015-12-23: 感谢XVXVXXX的PR,Case 1不再需要Fake Header View,直接用contentInset就好~
  • 2015-12-17:Case3的等间距,用UIStackView实现最优雅,但是无奈只有iOS9以上支持。兼容方案如OAStackView也可以,但是在UITableViewCell里面用,或者需要频繁的增减内部View的数量时,性能损耗有点厉害,会卡。所以,还是要看需求=。=

前言

第三篇来了。
依然是3个小例子,主要部分用Masonry手写代码实现,其它的约束在storyboard里面直接拖拽搭建。
至于为啥不用VFL,主要是因为它的“描述性”的写法很容易出错,没有补全、不好调试,写起来没有“代码”的感觉=。=。当然,这个仁者见仁智者见智~
三个例子分别是:Parallax Header动态变高度的UITableViewCell,以及两种方式实现等间距。原理其实都很简单,例子也都是平时积累起来的。

前两篇:

Github地址:
https://github.com/zekunyan/AutolayoutExampleWithMasonry

Gif示例

Case 1: Parallax Header

Parallax翻译过来就是“视差”,我个人觉得就是一种“联动”的效果,在许多应用里面都能见到。当前这个例子,就是最简单的一种。

原理

原理其实就是根据UITableView当前下拉的位移值,同步改变Parallax Header的高度,即NSLayoutConstraintconstant属性,对应到Masonry里面就是重新让约束equalTo()一次。

主要步骤

主要的步骤如下:

  1. 设置UITableView背景透明。
  2. 在UITableView正下方放置一个UIImageView,作为我们的Parallax Header,设置contentModeUIViewContentModeScaleAspectFill,并加上上左右的固定约束,使其与UITableView对其,然后加上一个固定高度的约束,并在代码里面保存。
  3. 设置UITableView的contentInset跟Parallax Header等高,使UITableView的头部“撑开”,让后面的Parallax Header露出来。
  4. 在代码里面监听UITableView的contentOffset属性,当y小于0时,增加Parallax Header高度,使其产生联动效果。

约束示意图如下:
Parallax Header

代码

创建Parallax Header的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_parallaxHeaderView = [UIImageView new];
// 把Parallax Header放在UITableView的下面
[self.view insertSubview:_parallaxHeaderView belowSubview:_tableView];

// 设置contentMode
_parallaxHeaderView.contentMode = UIViewContentModeScaleAspectFill;
_parallaxHeaderView.image = [UIImage imageNamed:@"parallax_header_back"];

// 添加约束
[_parallaxHeaderView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.and.right.equalTo(self.view);
make.top.equalTo(self.mas_topLayoutGuideBottom);
// 保存高度约束
_parallaxHeaderHeightConstraint = make.height.equalTo(@(ParallaxHeaderHeight));
}];

设置UITableView的contentInset

1
_tableView.contentInset = UIEdgeInsetsMake(ParallaxHeaderHeight, 0, 0, 0);

监听contentOffset属性的两种方法

方法1:直接实现scrollViewDidScroll:

这种方法应该是最直接的:

1
2
3
4
5
6
7
8
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (scrollView.contentOffset.y < 0) {
// 增加Parallax Header对应的高度,y是负数,所以减去
_parallaxHeaderHeightConstraint.equalTo(@(ParallaxHeaderHeight - scrollView.contentOffset.y));
} else {
_parallaxHeaderHeightConstraint.equalTo(@(ParallaxHeaderHeight));
}
}

方法2:KVO监听contentOffset变化

用KVO的好处就是不用要求当前类实现UITableView的delegate,对于代码的拆分有好处。

增加KVO:

1
[_tableView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil];

实现监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"contentOffset"]) {
// 取出contentOffset值
CGPoint contentOffset = ((NSValue *)change[NSKeyValueChangeNewKey]).CGPointValue;

// 改变高度
if (contentOffset.y < 0) {
_parallaxHeaderHeightConstraint.equalTo(@(ParallaxHeaderHeight - contentOffset.y));
} else {
_parallaxHeaderHeightConstraint.equalTo(@(ParallaxHeaderHeight));
}
}
}

最后别忘了取消KVO:

1
2
3
- (void)dealloc {
[_tableView removeObserver:self forKeyPath:@"contentOffset"];
}

小节

NSLayoutConstraintconstant属性非常有用,既可以做动画,也可以方便的调整现有布局,大家多多挖掘哈~

Case 2: 动态变高度的UITableViewCell

嗯,又是UITableViewCell=。=
只不过这次的是“动态改变高度”,就是类似于微信朋友圈里面的“全文”那种效果。

单纯的不定高UITableViewCell不是本例子的重点,详细请看有趣的Autolayout示例2-Masonry实现里面的Case1。

先说一点我觉得在代码设计上比较重要的地方:Cell只负责显示内容,不应该保存具体的状态信息

我们都知道,UITableViewCell是会被重用的,也就是说,不能保证UITableView里面的哪一行一定由哪一个UITableViewCell实例展示。
动态展开、收回Cell的时候,我们需要一个BOOL变量,用于保存当前Cell的展开、收回的状态。这个BOOL变量就是所谓的“状态”,这个状态应该保存在当前Cell的数据里面,如Entity、ViewModel里面。对Cell填充数据的时候,再根据这个“状态”,修改对应的约束。

主要步骤

先看看大致的步骤:

  1. 通过点击的Cell找到对应的数据Entity。
  2. 改变这一行数据Entity用于保存状态的BOOL变量的值。
  3. 让UITableView刷新这一行。
  4. 刷新的时候,Cell根据这个BOOL变量重新调整约束、填充数据,得到新的高度。
  5. 最后就是Cell的高度变化。(此时的这一行的Cell实例并不一定是之前的那个实例)

布局

为了尽量简单,例子里面的Cell只有三个子控件,第一个UILabel是标题等调试信息,第二个UILabel用来显示多行文本,最后一个UIButton用来切换展开、收回的状态。大致的布局如下:

Case2 UITableViewCell布局示意

布局的代码

标题UIlabel

1
2
3
4
5
6
7
_titleLabel = [UILabel new];
[self.contentView addSubview:_titleLabel];

[_titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.height.equalTo(@21);
make.left.and.right.and.top.equalTo(self.contentView).with.insets(UIEdgeInsetsMake(4, 8, 4, 8));
}];

底部“More”按钮

1
2
3
4
5
6
7
8
9
_moreButton = [UIButton buttonWithType:UIButtonTypeSystem];
[_moreButton setTitle:@"More" forState:UIControlStateNormal];
[_moreButton addTarget:self action:@selector(switchExpandedState:) forControlEvents:UIControlEventTouchUpInside];
[self.contentView addSubview:_moreButton];

[_moreButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.height.equalTo(@32);
make.left.and.right.and.bottom.equalTo(self.contentView);
}];

正文UIlabel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CGFloat preferredMaxWidth = [UIScreen mainScreen].bounds.size.width - 16;

// Content - 多行
_contentLabel = [UILabel new];
_contentLabel.numberOfLines = 0;
_contentLabel.lineBreakMode = NSLineBreakByCharWrapping;
_contentLabel.clipsToBounds = YES;
_contentLabel.preferredMaxLayoutWidth = preferredMaxWidth; // 多行时必须设置
[self.contentView addSubview:_contentLabel];

[_contentLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.and.right.equalTo(self.contentView).with.insets(UIEdgeInsetsMake(4, 8, 4, 8));
make.top.equalTo(_titleLabel.mas_bottom).with.offset(4);
make.bottom.equalTo(_moreButton.mas_top).with.offset(-4);
// 先加上高度的限制
_contentHeightConstraint = make.height.equalTo(@64).with.priorityHigh(); // 优先级只设置成High,比正常的高度约束低一些,防止冲突
}];

为什么要加正文UIlabel高度约束

有趣的Autolayout示例2-Masonry实现里面的Case1也讲过,获取Cell的高度的方法是systemLayoutSizeFittingSize:,如果不对正文UILabel加上高度约束,获取的高度就是根据正文的内容计算出来的,这与之前的例子里面一致。

为了使高度固定,就需要加上一个高度约束,使得systemLayoutSizeFittingSize:计算时按照这个约束去计算。

为什么正文UILabel的高度约束的优先级要调整为High

在UITableView刷新时,会先计算高度,即先调用tableView: heightForRowAtIndexPath:方法,如果高度约束为默认的1000最高的话,会产生冲突。

因为在计算的时候,我们的高度是由一个“template cell”填充内容后计算得来,这个时候的高度已经是展开以后的高度,当前的Cell还来不及调整约束(甚至不会调整,如果只用beginUpdates和endUpdates更新的话,Cell不会reload),所以降低这个高度约束的优先级,去掉冲突。

使用install和uninstall控制约束

为了能得正确高度,Cell需要根据具体的数据、状态更新约束。
这里可以使用installuninstall来控制正文UILabel高度约束是否生效。在填充Cell的数据时,就可以根据状态BOOL值来选择调用:

1
2
3
4
5
6
7
8
9
10
- (void)setEntity:(Case8DataEntity *)entity indexPath:(NSIndexPath *)indexPath {
// 设置数据...

// 改变约束
if (_entity.expanded) {
[_contentHeightConstraint uninstall];
} else {
[_contentHeightConstraint install];
}
}

创建Delegate,使得Cell的事件得以回传到ViewController

在点击Cell的“More”按钮时,需要改变当前的展开收回状态BOOL值,还需要让UITableView刷新。
直接在Cell里面修改Entity数据,或者持有UITableView实例都是不恰当的,这个时候可以用Delegate模式实现。

Delegate如下:

1
2
3
@protocol Case8CellDelegate <NSObject>
- (void)case8Cell:(Case8Cell *)cell switchExpandedStateWithIndexPath:(NSIndexPath *)index;
@end

然后ViewController实现这个Protocol

1
2
3
4
5
6
7
8
9
- (void)case8Cell:(Case8Cell *)cell switchExpandedStateWithIndexPath:(NSIndexPath *)index {
// 取出对应数据
Case8DataEntity *case8DataEntity = _data[(NSUInteger) index.row];
// 修改状态
case8DataEntity.expanded = !case8DataEntity.expanded; // 切换展开还是收回
case8DataEntity.cellHeight = 0; // 重置高度缓存

// 刷新UITableView
}

Cell保存ViewController这个delegate,然后在按钮点击时回调

1
2
3
4
5
6
7
// Cellb保存delegate,注意weak
@property (weak, nonatomic) id <Case8CellDelegate> delegate;

// Cell的“More”按钮点击
- (void)switchExpandedState:(UIButton *)button {
[_delegate case8Cell:self switchExpandedStateWithIndexPath:_indexPath];
}

刷新的方式

UITableView的刷新可以用以下几种方法:

1. reloadData

reloadData刷新,其实就是把所有Cell都刷新了一次,代价有点大,不推荐。

2. reloadRowsAtIndexPaths:withRowAnimation:

这个方法的好处就是可以指定要刷新的哪几行,而且可以指定刷新时的动画形式,一般来说用UITableViewRowAnimationFade就不错。
刷新的时候,tableView:cellForRowAtIndexPath:会被调用,原来的Cell实例会被替换。

3. beginUpdates和endUpdates

这两个方法一般都是成对使用的,在中间可以执行插入、删除等调整Cell的操作,改变Cell的高度也可以用它。
不过要注意的是,这两个方法并不会重新加载Cell,只是单纯的改变了高度,所以如果Cell原来的约束里面有高度约束这种,而又保持默认的优先级,就会产生约束冲突。

从效果上来讲,我个人觉得用reloadRowsAtIndexPaths:withRowAnimation:会更好一些~

小节

想用好Autolayout不容易啊,要仔细研究UITableView的机制=。=

Case 3: 两种方式实现等间距

等间距,也就是View之间的X或Y轴上的坐标等差,在这里我只举出水平方向上的等间距,垂直方向上一个道理。

方法1:利用透明等宽的占位View填充空白处,实现等间距

步骤很简单,就是循环创建真正要展示的View和占位View,布局如下:

利用占位View实现等间距

说明一下:

  • 占位View的宽度不能定死,这样外部的父级View宽度变化时,内部的View仍然可以保持等间距。
  • 既然占位View宽度不定,总得有个宽度的参照,这个参照就是其它的占位View,也就是说,要给占位View加上两两宽度相等的约束。

代码如下:

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
// 先创建第一个占位View
UIView *lastSpaceView = [UIView new];
lastSpaceView.backgroundColor = [UIColor greenColor]; // 用绿色标出
[_containerView1 addSubview:lastSpaceView];

// 添加上左下三个约束
[lastSpaceView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.and.top.and.bottom.equalTo(_containerView1);
}];

// 循环创建
for (NSUInteger i = 0; i < ITEM_COUNT; i++) {
// 创建ItemView,即真正显示内容的View
UIView *itemView = [self getItemViewWithIndex:i];
[_containerView1 addSubview:itemView];

// 固定宽高,左边、垂直方向中心与上一个占位View对齐。
[itemView mas_makeConstraints:^(MASConstraintMaker *make) {
make.height.and.width.equalTo(@(ITEM_SIZE));
make.left.equalTo(lastSpaceView.mas_right);
make.centerY.equalTo(_containerView1.mas_centerY);
}];

// 创建下一个占位View
UIView *spaceView = [UIView new];
spaceView.backgroundColor = [UIColor greenColor];
[_containerView1 addSubview:spaceView];

// 左边与当前ItemView对齐,上下与边界对齐,宽度与上一个占位View相等!
// 但是右边的约束不能加,因为要留给下一次循环与下一个ItemView的左边界添加
[spaceView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(itemView.mas_right).with.priorityHigh(); // 降低优先级,防止宽度不够出现约束冲突
make.top.and.bottom.equalTo(_containerView1);
make.width.equalTo(lastSpaceView.mas_width);
}];

// 更新
lastSpaceView = spaceView;
}

// 为最后一个占位View添加右边约束
[lastSpaceView mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(_containerView1.mas_right);
}];

像这种重复添加View和约束,合理使用变量保存“上一次循环”创建的占位View,就可以大大简化代码,而且可以任意调整数量~

方法2:直接按比例设置multiplier

等间距,其实就是按比例,再进一步就是x坐标是按比例的。
延伸到View上,可以理解为centerX的值与父级View的宽度按比例增减。

但是,读者可以尝试一下,直接设置一个View的边界、位置属性,如centerX,等于其父级View的宽度是会报错的。

难道就没有办法了?当然不是。

始终在坐标系上考虑约束

在上一篇有趣的Autolayout示例2-Masonry实现的开头我也提到过,Autolayout最终都是体现在坐标系上,一切都会按照viewA-attribute = viewB-attribute * multiplier + constant这种公式去计算,既然centerX不能跟父级的Width宽度一起加约束,那就换一个,如父级的右边界,父级View的右边界在父级本身的参照系下的Y坐标值不就等于其宽度吗~

所以,可以按照如下方式加约束:

按比例设置multiplier实现等间距

对应的代码也会异常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 循环创建
for (NSUInteger i = 0; i < ITEM_COUNT; i++) {
UIView *itemView = [self getItemViewWithIndex:i];
[_containerView2 addSubview:itemView];

[itemView mas_makeConstraints:^(MASConstraintMaker *make) {
// 宽高一定
make.width.and.height.equalTo(@(ITEM_SIZE));
// 确定Y坐标
make.centerY.equalTo(_containerView2.mas_centerY);
// 确定X坐标,注意分子分母都要加1
make.centerX.equalTo(_containerView2.mas_right).multipliedBy(((CGFloat)i + 1) / ((CGFloat)ITEM_COUNT + 1));
}];
}

小节

很多时候,灵活的使用multiplier能大大简化开发~

总结

三个例子说完了,你有啥收获呢~?:-D

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器