记一次 UIScrollView + Autolayout 掉坑之旅

上周一直在掉坑,好在都爬了出来。自认为有点眉目了,就想写篇文章总结一下,没想到坑越掉越大,特别是第二部分,想一窥 Autolayout 的生命周期,结果不同控件结果都不一样,基本想到哪写到哪,写着都要 ಥ_ಥ 了,这部分最后也没分析出个结果,想看看坑长啥样就看看,不想看也没啥关系。最后一部分的问题修正很简单,不过通过这次的躺坑之旅倒是搞清楚了 Autolayout 的 debug 方法,也算是不枉此坑。(本人水平有限,估计文中会有很多问题,欢迎指正)

一、问题

事情是这样子,周一开始抽空在看 Ray 家关于 UIScrollView 的系列视频,看完 AutoLayout + ScrollView 改用 SnapKit 写了一个 Demo,后来打算又用 AutoLayout 来试试,由于约束添加总是报错,就去 Google 了一下,找到解决方案:一篇中文 blog 和 一个 stackflow 答案 可以先去看一下,了解一下。但注意:那篇中文 blog 的 demo 恰好在那种情况下是有问题的,底下的留言也有提到,滚动一屏底下的 cell 都是不可选中的。那 stackoverflow 的答案呢,二者其实差不多。都解决了约束不会报错的问题,但写出同样的 demo,cell 也不能选中。我自己按照两种方式写了个简化版的 demo start1demo start2 可以自行尝试一下:

好了,看完 demo,你或许会说这二者没什么区别嘛,连代码都是一样的,只不过 demo 2 多了一层 view。恩,目前是没什么区别,不过先别急,后面在解决 cell 选中问题上会掉个小坑。

图 1

好吧,其实用 Reveal 这样的工具很容易看出问题来,就是在滚动过程中,tableView 超出了 Container 的边界,即不在整个 UIWindow 的 Hierarchies 中了,来看下 Demo 1 的滚动后:

图 2

黄色的区域是 ContainerView ,注意从 cell 10 开始之后的 cell 都超出了 ContainerView 的边界,都没在 UIWindow 的 Hierarchies 中,因此都不能点击(Demo 2 类似)

解决办法也很简单,让黄色的 Container 变的和他的 subViews 一样长不就行了。考虑到之前我们加的 AutoLayout 约束: Container.height == MainView.height,那修改 MainView 的 height 不就好了:

在计算完 scrollView.contentSize 后加一句:

view.frame.size = scrollView.contentSize  

运行,这次 ScrollView 又滚不动了,我们再拿 Reveal 看一下:

图 3

黄色的区域是 Container View,如我们所愿随着 MainView(绿色)的增高而变长了,但红色的 ScrollView 部分也被拉长了。这是因为 scrollView 与 MainView 之间有约束,修改 MainView frame size 的同时会改变 scrollView 的尺寸,好吧,那我们把 scrollView 的尺寸变回去不就行了,来修正一下:

  override func viewWillAppear(animated: Bool) {
    scrollViewFrameSize = view.frame.size
  }

  override func viewDidAppear(animated: Bool) {
    scrollView.frame.size = scrollViewFrameSize!
  }

再次运行,OK 可以滚动了,cell 点击也没问题了,顺利出坑。下面我们来仿照此法来修正 Demo 2,既然我们 Container.height == MiddleView.height,那我们直接来拉长 MiddleView 好了,同样的办法,先增加 MiddleView 的高度,然后在削减 ScrollView 的长度,运行~ 咦这次怎么 cell 还不能点击,Reveal 继续来看一下:

图 4

这次蓝色的 MiddleView 被拉长了,但 scrollView(红色)和 Container View(黄色)都是保持原样的,我们期望的是 scrollView 保持原样,但 Container View 被拉伸,那么什么地方出问题了呢?我们先把修改 scrollView.frame 的代码去掉:

/*
  override func viewWillAppear(animated: Bool) {
    scrollViewFrameSize = view.frame.size
  }

  override func viewDidAppear(animated: Bool) {
    scrollView.frame.size = scrollViewFrameSize!
  }
*/

运行,用 Reveal 查看,会发现结果和上图一摸一样,至此我们可以得出结论,修改了 MiddleView 的 frame ,Autolayout 并没有让 scrollView 和 container view 的 frame 产生变化。那究竟是哪里出了问题呢,下面来调试一下,关于 AutoLayout 的调试可以看一下这篇文章 Debugging iOS AutoLayout Issues

打一个 UIViewAlertForUnsatisfiableConstraints 断点,先来关注下 MiddleView,在 viewDidAppear 里加如下一句(至于为什要在这里加这句话,后面细说):

middleView.setTranslatesAutoresizingMaskIntoConstraints(true)  

运行,程序会停在断点处:

此时 console 还没有任何输出,接下来如果点击 Continue console 会立即输出有问题的 Layout Constraint,但我们还想停留在 lldb 模式下 debug,好在汇编代码还不难,我们找到 symbol sstub for:NSLog 打个断点

然后 continue,接着 step over,这个时候 console 就输出结果了:

注意 attempt to recover by breaking constraint 这句话,就是 debug 告诉我们问题所在,我们可以用 lldb 的 recursiveDescription 命令 用私有 API: recursiveDescription 看一下这个 UIView:0x7f8d004c28a0 的结构:

根据打印出来的 View 结构,也可以确定就是我们的 MiddleView,再次把注意力放回到 NSLayoutConstraint:0x7f8d00754680 上,continue 把程序跑起来,到 Reveal 里去找一下这个 NSLayoutConstraint:

很容易就找到了嘛,继续来看一下:

用 Reveal 可以清楚的看到该约束的 First item 和 Second item 分别是 MainView 和 MiddleView,也就是他们之间的 bottom 约束被打破了。既然 MainView 和 MiddleView 之间的约束被无效化了(break),那么其他 Views 之间的约束呢?

继续添加如下:

override func viewDidAppear(animated: Bool) {

    middleView.setTranslatesAutoresizingMaskIntoConstraints(true)
    scrollView.setTranslatesAutoresizingMaskIntoConstraints(true)
    containerView.setTranslatesAutoresizingMaskIntoConstraints(true)

  }

运行,输出四条将被 breaking 掉的 constraint:

Will attempt to recover by breaking constraint  
<NSLayoutConstraint:0x7f80da4839a0 V:[UIView:0x7f80da4804b0]-(0)-|   (Names: '|':UIView:0x7f80da482690 )>

Will attempt to recover by breaking constraint  
<NSLayoutConstraint:0x7f80da482440 V:[UIScrollView:0x7f80da4805e0]-(0)-|   (Names: '|':UIView:0x7f80da4804b0 )>

Will attempt to recover by breaking constraint  
<NSLayoutConstraint:0x7f80da480ed0 V:|-(0)-[UIView:0x7f80da6c83d0]   (Names: '|':UIScrollView:0x7f80da4805e0 )>

Will attempt to recover by breaking constraint  
<NSLayoutConstraint:0x7f80da482350 UIView:0x7f80da6c83d0.height == UIView:0x7f80da4804b0.height>  

分别对应着:

  • MainView 与 MiddleView 之间的 bottom
  • MiddleView 与 ScrollView 之间的 bottom 约束
  • ScrollView 与 ContainerView 之间的 top 约束
  • MiddleView 与 ContainerView 之间的 Equal height 约束

OK 到此为止,我们已经知道了这些约束都无效了,也就基本上搞清楚了为什么会是 图 4 那个样子,现在再回到我们之前添加的 setTranslatesAutoresizingMaskIntoConstraints(true) 方法上来:

override func viewDidAppear(animated: Bool) {

    middleView.setTranslatesAutoresizingMaskIntoConstraints(true)
    scrollView.setTranslatesAutoresizingMaskIntoConstraints(true)
    containerView.setTranslatesAutoresizingMaskIntoConstraints(true)

  }

思考一下为什么我们要在 viewDidAppear 里添加这些方法?给你一刻钟的时间 :)在给出答案之前,先来熟悉一下 AutoLayout 的生命周期。

二、AutoLayout 生命周期漫谈(这部分是坑可跳过)

我把 Ray 家一个 Demo 改造了下:

根 View 是 RootView,灰色大方块是 ContainerView,中间的绿色方块是 InnerView,InnerView 是 ContainerView 的 subView,整个工程先不要开启现 Autolayout。下面的滑块可以调整上面 views 的 frame.origin 和 bounds.origin 。在工程中分别创建 ContainerView 和 InnerView Class,在 Views 和主 VC 中添加如下方法:

// Class InnerView
class InnerView: UIView {

  override func layoutSubviews() {
    println("InnerView layoutSubviews")
  }
}

// Class ContainerView
class ContainerView: UIView {

  override func layoutSubviews() {
    println("ContainerView layoutSubviews")
  }
}

// ViewController life time
  override func viewWillAppear(animated: Bool) {
    println("ContainerView willAppear")
  }

  override func viewDidAppear(animated: Bool) {
    println("ContainerView didAppear")
  }

  override func viewWillLayoutSubviews() {
    println("ContainerView viewWillLayoutSubviews")
  }

  override func viewDidLayoutSubviews() {
    println("ContainerView viewDidLayoutSubviews")
  }

先 Run 起来看下调用顺序:

// 结果
ViewDidLoad  
View willAppear  
View viewWillLayoutSubviews  
RootView layoutSubviews  
View viewDidLayoutSubviews  
ContainerView layoutSubviews  
InnerView layoutSubviews  
View didAppear

接下来分别移动滑块改变 views 的 origin,来观察一下 layoutSubviews 的调用:

  1. 移动 containerView frame.origin 并不会产生任何 layoutSubview 动作(没有输出)

  2. 移动 containerView bounds.origin 只会调用 ContainerView 自身和 Superview(RootView)的 layoutSubViews

  3. 移动 innerView frame.origin 和第一个一样,并不会产生任何的 layoutSubview 动作(没有输出)

  4. 移动 innerView bounds.origin 同样是会先调用 Superview(ContainerView) 的 layoutSubViews,然后再调用自己的

记住 origin 变化产生的影响,下面我们修改 views 的 size,观察下 layoutSubviews 的调用。我们都知道:当一个 View 旋转的时候,frame.origin 和 frame.size 都会发生变化,而 bounds.origin 和 bounds.size 都不会变。利用这个原理在上面 Demo 基础上修改下:

  1. 旋转 containerView 会使 frame.origin 与 frame.size 同时发生变化,但是我们之前检测过了,修改 frame.origin 并不会产生任何 layoutSubview , 不过这次我们观察到了根视图(RootView)的 layoutSubview 被调用,就说明修改 frame.size 会调用 superView 的 layoutSubView 方法,且并不会调用自身的 layoutSubView。

  2. 同样的道理,旋转 innerView,改变了 frame,也只会调用 superView(containerView)的 layoutSubview 方法,而 innerView 自身的 layoutSubView 也不会被调用

  3. 修改 bounds.size(同时也修改了 frame.size) 保持 frame.origin 和 bounds.origin 不变,这个就不贴图了,直接上结论:同样会先调用 superView 的,然后调用自己的

看到这里你也许会问,为什么没有保持 frame.size 不变,单独修改 bounds.size 的,原因是如下:如果当 bounds.size 变化时保持 view.frame 不变(origin 和 size)不变,这是可以做到的。但是要同时保持 bounds.origin 也不变,却是做不到的。(如有反例请指出)观察下面这张图

红色矩形和灰色矩形 frame 完全一样,但 bounds 不同。保持 frame 不变,改变 bounds.size 的同时,bounds.origin 也跟着变了

看到这里,我们就没有继续试验的必要了,因为要保持 frame 不变,改变 bounds.size 必会导致 bounds.origin 变化,origin 变化产生的结果在第一个 demo 中已经见识到了:会先调用 Superview 的 layoutSubViews,然后再调用自己的 layoutSubViews

综合这两个 Demo 直接上结论(在不开启 autolayout 的情况下,View 皆为 UIView 基类的时候):

  1. 改变一个 View 的 frame.origin,并不会导致任何 View 的 layoutSubviews 被调用
  2. 改变一个 View 的 frame.size,会导致 superView 的 layoutSubviews 被调用
  3. 改变一个 view 的 bounds(origin or size)会导致 Superview 和 View 的 layoutSubViews 分别被调用

注意,这里所指的是 frame 和 bounds 单独变化的情况(即一个变,另一个保持不变),通常 frame 和 bounds 的变化是相互影响的。

至于 layoutSubviews 什么时候会被调用更详细的信息看这个讨论 这里还有个中文的

下面回到第一个 Demo,在 Xcode 里启用 Autolayout,重新 Run 一下,什么都先别做,看下输出:

ViewDidLoad  
View willAppear  
View viewWillLayoutSubviews  
RootView layoutSubviews  
View viewDidLayoutSubviews  
ContainerView layoutSubviews  
InnerView layoutSubviews  
// 多了三条
View viewWillLayoutSubviews  
RootView layoutSubviews  
View viewDidLayoutSubviews  
// end
View didAppear  

和前面不加 AutoLayout 对比多了三条语句,说明渲染完所有的 Views 之后又回到根视图调用了(RootView)layoutSubviews,最后这次调用正是 Autolayout 所做的事情。到这里我又掉了一个坑:

接下来移动一下下面四个滑块,你会发现所有的输出都多出了这三句,看到这里我们可以大胆猜测一下,Autolayout 的机制就是:任何位于 View Hierarchies 中的 Subview 发生改动,不管该 Subview 位于 View Hierarchies 哪一层,都会立即触发 RootView 的 layoutSubviews,并层层触发自己 Subview 的 layoutSubviews,最终使整个 View Hierarchies 重新达到一个新的平衡状态,而这些 layoutSubview 调整 Views 的法则就是附加的约束(constraint)

多出的这三条语句其实是 UISlider 这个控件搞的鬼:这个控件在开启 Autolayout 的时候会调用

那是不是所有的控件都是这样子呢,试一下 ScrollView

这两个控件在 关闭/开启 Autolayout 时,console 的输出刚好相反 😓 看到这里我内心是崩溃的 😭

随后我又试验了其他控件,各不相同 😥。好吧就此打住,本来还想总结一下,blog 要写不完了 😂。

如果是正常的纯 UView 嵌套的话,开启/关闭 Autolayout,console 输出结果是完全一样的

这里最后注意一点就是 Autolayout 第一次生效是在 View Hierarchies 中所有 Views 都被渲染完成后,如果在 viewWillAppear 中修改了加了约束的 view.frame,你会发现随后 Autolayout 分分钟钟教你做人,在 viewDidAppear 中又根据约束把 frame 给改了回来。


打住不写了


三、修正 Demo

现在回到文章一开始那个带 ScrollView 的 demo 1,这个例子中,我们启用了 AutoLayout,并添加了诸多约束,运行~ 除了 cell 不能点击外,一切 OK 。

看一下 console:

viewWillAppear  
MainView layoutSubviews  
ScrollView layoutSubviews  
ContainerView layoutSubviews  
ScrollView layoutSubviews  
viewDidAppear  

注意倒数第二条 ScrollView layoutSubviews 这是我们修改了 scrollView.contentSize 之后,触发了 Autolayout 的某种机制

为了修正 cell 的选中问题,我们拉长了 MainView 的 frame,输出 console:

viewWillAppear  
MainView layoutSubviews  
ScrollView layoutSubviews  
ContainerView layoutSubviews  
MainView layoutSubviews  
ScrollView layoutSubviews  
viewDidAppear  

这一次对 MainView frame 的修改,使 autolayout 触发了 MainView 的 layoutsubViews 方法。

下来添加下面两条命令到 viewDidAppear 方法中

scrollView.setTranslatesAutoresizingMaskIntoConstraints(true)  
containerView.setTranslatesAutoresizingMaskIntoConstraints(true)  

再次运行,console 没有任何变化。现在来解释下为什么要在这里加这两句话:

先看下文档解释:

If this property’s value is YES, the system creates a set of constraints that duplicate the behavior specified by the view’s autoresizing mask. Note that the autoresizing mask constraints fully specify the view’s size and position; therefore, you cannot add additional constraints to modify this size or position without introducing conflicts.

其实很简单,就是设置在约束布局系统中是否把自动布局转换为约束布局,我们先用 Autolayout 处理,然后在 viewDidAppear 中将自动布局转换为约束布局(在 Autolayout 的生命周期中 执行到 viewDidAppear 这步他的任务已经完成了),他还要求你提供的约束不能有冲突。如果有console 会抱怨,即(Will attempt to recover by breaking constraint)

这里我们先让 autolayout 处理一遍我们的布局,然后转换成约束布局,如果 autolayout 处理过程中没有问题,那么转换之后的约束布局也没问题,如果有问题,转换后的也有问题。

最后我们来修改回 scrollView 的 frame 为原始尺寸,来修正 scrollView 不能滚动的问题,运行,观察下 console:

viewWillAppear  
MainView layoutSubviews  
ScrollView layoutSubviews  
ContainerView layoutSubviews  
MainView layoutSubviews  
ScrollView layoutSubviews  
viewDidAppear  
MainView layoutSubviews

2015-09-15 17:58:15.857 ScrollViewAutoLayout[12012:417673] Unable to simultaneously satisfy constraints.  
    Probably at least one of the constraints in the following list is one you don't want. Try this:
(1) look at each constraint and try to figure out which you don't expect; 
(2) find the code that added the unwanted constraint or constraints and fix it. 
(Note: If you're seeing NSAutoresizingMaskLayoutConstraints 
that you don't understand, refer to the documentation  
for the UIView property translatesAutoresizingMaskIntoConstraints)  
(
    "<NSLayoutConstraint:0x7f9f9aee1ca0 V:|-(0)-[ScrollViewAutoLayout.ScrollView:0x7f9f9aeded60]   (Names: '|':ScrollViewAutoLayout.MainView:0x7f9f9aede510 )>",
    "<NSLayoutConstraint:0x7f9f9aee1cf0 ScrollViewAutoLayout.ScrollView:0x7f9f9aeded60.bottom == _UILayoutGuide:0x7f9f9aee15b0.top>",
    "<_UILayoutSupportConstraint:0x7f9f9aec71e0 V:[_UILayoutGuide:0x7f9f9aee15b0(0)]>",
    "<_UILayoutSupportConstraint:0x7f9f9aec6fc0 _UILayoutGuide:0x7f9f9aee15b0.bottom == ScrollViewAutoLayout.MainView:0x7f9f9aede510.bottom>",
    "<NSLayoutConstraint:0x7f9f9ad43200 'UIView-Encapsulated-Layout-Height' V:[ScrollViewAutoLayout.MainView:0x7f9f9aede510(1120)]>",
    "<NSAutoresizingMaskLayoutConstraint:0x7f9f9ad96070 h=-&- v=-&- 'UIView-Encapsulated-Layout-Top' V:|-(0)-[ScrollViewAutoLayout.MainView:0x7f9f9aede510]   (Names: '|':UIWindow:0x7f9f9ada66c0 )>",
    "<NSAutoresizingMaskLayoutConstraint:0x7f9f9af689e0 h=--& v=--& ScrollViewAutoLayout.ScrollView:0x7f9f9aeded60.midY == + 333.5>"
)

Will attempt to recover by breaking constraint  
<_UILayoutSupportConstraint:0x7f9f9aec6fc0 _UILayoutGuide:0x7f9f9aee15b0.bottom == ScrollViewAutoLayout.MainView:0x7f9f9aede510.bottom>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.  
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKit/UIView.h> may also be helpful.  
ScrollView layoutSubviews  

console log: "viewDidAppear" 后面多了一句:"MainView layoutSubviews",还记得那个试验么,在开启 Autolayout 的情况下:修改 ScrollView 的 frame.size 会只会调用父类的 layoutSubviews。这都不是重点,注意观察这句话:

Will attempt to recover by breaking constraint  
<_UILayoutSupportConstraint:0x7f9f9aec6fc0 _UILayoutGuide:0x7f9f9aee15b0.bottom == ScrollViewAutoLayout.MainView:0x7f9f9aede510.bottom>  

即警告我们 MainView 和 ScrollView 的底部约束将要被无效化(因为 scrollView 被恢复到原始尺寸了)虽然我们已经修正了 cell 点击问题,但其实整个 Demo 还是有问题的。

如果不加 setTranslatesAutoresizingMaskIntoConstraints Autolayout 在这种情况下不会在 console 里抱怨,仅仅是让这些约束失效罢了。

好吧,正确的做法是在 Autolayout 管理下不要直接修改 View 的 Position 与 Size,而应该直接修改 Constraints,我把修正后的 Demo1 传到 Github 上了,这次你们可以用 setTranslatesAutoresizingMaskIntoConstraints 大法看一下,没有问题了。另一种加了 DummyView 的 Demo 2 也是用同样的方法来修正,你们可以自己练习一下,就不贴了。


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!