前两篇:
第一篇:有趣的Autolayout示例-Masonry实现
第二篇:有趣的Autolayout示例2-Masonry实现
Github地址:
https://github.com/zekunyan/AutolayoutExampleWithMasonry
Case 1: Parallax Header
Parallax翻译过来就是“视差”,我个人觉得就是一种“联动”的效果,在许多应用里面都能见到。当前这个例子,就是最简单的一种。
原理
原理其实就是根据UITableView当前下拉的位移值,同步改变Parallax Header的高度,即NSLayoutConstraint
的constant
属性,对应到Masonry里面就是重新让约束equalTo()
一次。
主要步骤
主要的步骤如下:
- 设置UITableView背景透明。
- 在UITableView正下方放置一个UIImageView,作为我们的Parallax Header,设置
contentMode
为UIViewContentModeScaleAspectFill
,并加上上左右的固定约束,使其与UITableView对其,然后加上一个固定高度的约束,并在代码里面保存。 - 给UITableView设置一个透明的,跟Parallax Header等高的UIView,使UITableView的头部“撑开”,让后面的Parallax Header露出来。
- 在代码里面监听UITableView的
contentOffset
属性,当y小于0时,增加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)); }]; |
监听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"]; } |
小节
NSLayoutConstraint
的constant
属性非常有用,既可以做动画,也可以方便的调整现有布局,大家多多挖掘哈~
Case 2: 动态变高度的UITableViewCell
嗯,又是UITableViewCell=。=
只不过这次的是“动态改变高度”,就是类似于微信朋友圈里面的“全文”那种效果。
单纯的不定高UITableViewCell不是本例子的重点,详细请看有趣的Autolayout示例2-Masonry实现里面的Case1。
先说一点我觉得在代码设计上比较重要的地方:Cell只负责显示内容,不应该保存具体的状态信息。
我们都知道,UITableViewCell是会被重用的,也就是说,不能保证UITableView里面的哪一行一定由哪一个UITableViewCell实例展示。
动态展开、收回Cell的时候,我们需要一个BOOL变量,用于保存当前Cell的展开、收回的状态。这个BOOL变量就是所谓的“状态”,这个状态应该保存在当前Cell的数据里面,如Entity、ViewModel里面。对Cell填充数据的时候,再根据这个“状态”,修改对应的约束。
主要步骤
先看看大致的步骤:
- 通过点击的Cell找到对应的数据Entity。
- 改变这一行数据Entity用于保存状态的BOOL变量的值。
- 让UITableView刷新这一行。
- 刷新的时候,Cell根据这个BOOL变量重新调整约束、填充数据,得到新的高度。
- 最后就是Cell的高度变化。(此时的这一行的Cell实例并不一定是之前的那个实例)
布局
为了尽量简单,例子里面的Cell只有三个子控件,第一个UILabel是标题等调试信息,第二个UILabel用来显示多行文本,最后一个UIButton用来切换展开、收回的状态。大致的布局如下:
布局的代码
标题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需要根据具体的数据、状态更新约束。
这里可以使用install
和uninstall
来控制正文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加上两两宽度相等的约束。
代码如下:
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坐标值不就等于其宽度吗~
所以,可以按照如下方式加约束:
对应的代码也会异常简单:
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能大大简化开发~