UIKit 的 UIView 是一个非常重要的类,几乎每个尝试 iOS 开发的程序员都会用到它。UIView 本身实现了 Composite Pattern,所以一个应用的界面最终可以由一群树状组合的 UIView 来组合而成——在这棵 UIView 树的最顶部,是继承于 UIView 的 UIWindow 实例,然后是由 UIWindow 实例保有的 rootViewController 的根 UIView 实例,然后是在该 UIView 实例上的各种各样的子节点 UIView。

父 UIView 可以拥有自己的子 UIView,自然而然的,父 UIView 就会面对用怎样的策略来布局、排列这些子 UIView 的问题。在 UIView 中,UIKit 的开发者专门提供了 layoutSubviews 方法来解决这个问题。

官方文档的描述

官方文档对于该方法有如下的描述:

(This method) Lays out subviews.

Subclasses can override this method as needed to perform more precise layout of their subviews. You should override this method only if the autoresizing and constraint-based behaviors of the subviews DO NOT offer the behavior you want. You can use your implementation to set the frame rectangles of your subviews directly.

以上节选自 UIView Class Reference

Whenever the size of a view changes, UIKit applies the autoresizing behaviors of that view’s subviews and THEN calls the layoutSubviews method of the view to let it make manual changes. You can implement the layoutSubviews method in custom views when the autoresizing behaviors by themselves DO NOT yield the results you want. Your implementation of this method can do any of the following:

  1. Adjust the size and position of any immediate subviews.
  2. Add or remove subviews or Core Animation layers.
  3. Force a subview to be redrawn by calling its setNeedsDisplay or setNeedsDisplayInRect: method.

One place where applications often lay out subviews manually is when implementing a large scrollable area. Because it is impractical to have a single large view for its scrollable content, applications often implement a root view that contains a number of smaller tile views. Each tile represents a portion of the scrollable content. When a scroll event happens, the root view calls its setNeedsLayout method to initiate a layout change. Its layoutSubviews method then repositions the tile views based on the amount of scrolling that occurred. As tiles scroll out of the view’s visible area, the layoutSubviews method moves the tiles to the incoming edge, replacing their contents in the process.

以上节选自 View Programming Guide for iOS

从文档的描述可以看到,layoutSubviews 的主要功能就是让程序员自己实现子 UIViews 的布局算法,从而在需要重新布局的时候,父 UIView 会按照这个流程重新布局自己的子 UIViews。而且,layoutSubviews 方法只能被系统触发调用,程序员不能手动直接调用该方法。要引起该方法的调用,可以调用 UIView 的 setNeedsLayout 方法来标记一个 UIView。这样一来,在 UI 线程的下次绘制循环中,系统便会调用该 UIView 的 layoutSubviews 方法。

使用 layoutSubviews 的实例

一个比较典型的例子来自于 RDVTabBarController 项目,它的目标是实现一个提供高定制性的 TabBarController。在这个项目中,作者使用了 layoutSubviews 来控制 TabBar 的子 UIView——TabBarItem 的重新布局,从而达到 TabBar 发生变化时 TabBarItem 的绘制与布局不会出错的目的:

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
- (void)layoutSubviews {
//在调用 UIView 的 layoutSubviews 方法之前,UIView 的 frame 已经更新了,下面的 frameSize 就是最新的 frame 的 size 属性
CGSize frameSize = self.frame.size;
CGFloat minimumContentHeight = [self minimumContentHeight];

[[self backgroundView] setFrame:CGRectMake(0, frameSize.height - minimumContentHeight,
frameSize.width, frameSize.height)];

//根据新的 TabBar 的 frameSize 来计算每个 TabBarItem 的新宽度,进而保证在绘制后不会TabBarItem 的宽度超出 TabBar 等问题
[self setItemWidth:roundf((frameSize.width - [self contentEdgeInsets].left -
[self contentEdgeInsets].right) / [[self items] count])];

NSInteger index = 0;

// Layout items

for (RDVTabBarItem *item in [self items]) {
CGFloat itemHeight = [item itemHeight];

if (!itemHeight) {
itemHeight = frameSize.height;
}

//根据最新计算出来的宽、高来设置 TabBarItem 的 frame 属性。
//设置 frame 属性后,如果新设置的 frame 不同于设置之前的 frame,系统会自动调用该 UIView 的 layoutSubviews 方法来重新布局
[item setFrame:CGRectMake(self.contentEdgeInsets.left + (index * self.itemWidth),
roundf(frameSize.height - itemHeight) - self.contentEdgeInsets.top,
self.itemWidth, itemHeight - self.contentEdgeInsets.bottom)];
//设置新的 frame 不会引起 UIView 的重绘,所以需要手工强制其重绘
[item setNeedsDisplay];

index++;
}
}

我在代码中的注释解释了这段代码都做了什么。如果你要实现自己的 layoutSubviews 方法的话,可以参考这个例子的流程。

何时被调用

一个曾经让我比较疑惑的问题是,既然我不能手动直接调用该方法,那在什么时候、何种条件下这个方法会被调用呢?

Stackoverflow 上已经有相关的讨论了(作者在他的博客上有更详细的描述),并且有一位朋友给出了很不错的解答:

  1. init does not cause layoutSubviews to be called (duh)
  2. addSubview causes layoutSubviews to be called on the view being added, the view it’s being added to (target view), and all the subviews of the target
  3. view setFrame intelligently calls layoutSubviews on the view having its frame set only if the size parameter of the frame is different
  4. scrolling a UIScrollView causes layoutSubviews to be called on the scrollView, and its superview
  5. rotating a device only calls layoutSubview on the parent view (the responding viewControllers primary view)
  6. Resizing a view will call layoutSubviews on its superview

也就是说,layoutSubviews 方法会在这些情况下,在这些 UIView 实例上被调用:

  1. addSubview 被调用时:target view(一定会),以及被添加的 view(第一次调用会)
  2. 更改 UIView 的 frame 时:被更改 frame 的 view(frame 与之前不同时)
  3. 对于 UIScrollView 而言,滚动式:scroll view
  4. 设备的 orientation 改变时:涉及改变的 UIViewController 的 root view
  5. 使用 CGAffineTransformScale 改变 view 的 transform 属性时,view 的 superview:被改变的 view

然而,根据我自己的实验,上面的描述并不是很完善的。我的两点补充如下:

  1. 第一次调用 addSubview 的时候,target view 和被添加到 target view 的 view 的 layoutSubviews 方法会被调用。在已经添加完毕后,若 target view 已经拥有该被添加 view,则只有 target view 的 layoutSubviews 方法会被调用。“and all the subviews of the target”这句话是错误的。
  2. 只有 UIView 处于 key window 的 UIView 树中时,该 UIView 的 layoutSubviews 方法才有可能被调用。不在树中的不会被调用。这也是为什么 Stackoverflow 上的讨论中这个答案的第二点会被提出。

小结

使用 layoutSubviews 可以让应用界面的适应能力更强。如果 UIKit 默认提供的自动布局机制 Auto Layout 不能提供给你想要的 UIView 布局行为,你可以自己定制该方法来决定布局行为。