iOS layoutSubviews 和 layoutIfNeeded

530 阅读8分钟

UIView Layout

iOS 中 UIKit 为 UIView 提供了这些方法来进行视图的更新与重绘:

//更新方法
- (void)setNeedsLayout;
- (void)layoutIfNeeded;
 
- (void)layoutSubviews;

- (CGSize)sizeThatFits:(CGSize)size

- (void)sizeToFit
 
//重绘方法
- (void)drawRect:(CGRect)rect;
 
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;

要理解视图的更新与重绘,那么就必然要知道 iOS 的绘图机制 The View Drawing Cycle。
The View Drawing Cycle: 在开始任何绘图操作之前,系统将一直等到当前RunLoop结束。这种延迟使您有机会使多个视图失效、从层次结构中添加或删除视图、隐藏视图、调整视图大小以及同时重新定位视图。然后,您所做的所有更改都会同时反映出来。

显然修改 UIView 的 frame、bounds 等属性后并不会马上重新绘制,而是用 RunLoop 把多次绘制聚集在一个 Cycle 里一起渲染,这显然是更加高效的行为。

layoutSubviews()

继承于 UIView 的子类重写,进行布局更新,刷新视图。 如果某个视图自身的bounds或者子视图的bounds发生改变,那么这个方法会在当前runloop结束的时候被调用。 为什么不是立即调用呢?因为渲染毕竟比较消耗性能,特别是视图层级复杂的时候。这种机制下任何UI控件布局上的变动不会立即生效,而是每次间隔一个周期,所有UI控件在布局上的变动统一生效并且在视图上更新。

为什么不是立即调用呢?

Update cycle(更新周期, Update cycle 负责布局并且重新渲染视图) 是当应用完成了你的所有事件处理代码后控制流回到主 RunLoop 时的那个时间点。正是在这个时间点上系统开始更新布局、显示和设置约束。如果你在处理事件的代码中请求修改了一个 view,那么系统就会把这个 view 标记为需要重画(redraw)。在接下来的 Update cycle 中,系统就会执行这些 view 上的更改。用户交互和布局更新间的延迟几乎不会被用户察觉到。iOS 应用一般以 60 fps 的速度展示动画,就是说每个更新周期只需要 1/60 秒。这个更新的过程很快,所以用户在和应用交互时感觉不到 UI 中的更新延迟。但是由于在处理事件和对应 view 重画间存在着一个间隔,RunLoop 中的某时刻的 view 更新可能不是你想要的那样。如果你的代码中的某些计算依赖于当下的 view 内容或者是布局,那么就有在过时 view 信息上操作的风险。

不应该在代码中显式调用这个方法。相反,有许多可以在 r及时 loop 的不同时间点触发 layoutSubviews 调用的机制,这些触发机制比直接调用 layoutSubviews 的资源消耗要小得多。

layoutSubviews 完成后,在 view 的所有者 view controller 上,会触发 viewDidLayoutSubviews 调用。因为 viewDidLayoutSubviewsview 布局更新后会被唯一可靠调用的方法,所以你应该把所有依赖于布局或者大小的代码放在 viewDidLayoutSubviews 中,而不是放在 viewDidLoad 或者 viewDidAppear 中。这是避免使用过时的布局或者位置变量的唯一方法。

layoutSubviews 调用的主要流程步骤:
  1. 创建 UIView 子类
  2. 重写 layoutSubviews
  3. 调用 setNeedsLayout
  4. 系统调用 layoutSubviews
  5. 执行自定义布局代码
layoutSubviews 的调用时机(会触发自动刷新触发器)
  1. init初始化不会触发layoutSubviews,使用initWithFrame 进行初始化时,当rect的值不为CGRectZero时,也会触发。
  2. addSubview会触发layoutSubviews。
  3. 滚动一个UIScrollView会触发layoutSubviews(layoutSubviews 会在 UIScrollView 和它的父 view 上被调用)。
  4. 旋转Screen会触发父UIView上的layoutSubviews事件。
  5. 改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件。
  6. 视图第一次加载: 当视图第一次加载到屏幕上时,系统会调用layoutSubviews来确保视图的子视图正确布局。
  7. 手动调用setNeedsLayout: 如果我们手动调用了视图的setNeedsLayout方法,系统将会在下一个绘制周期调用layoutSubviews。
  8. 更新视图的 constraints。
  9. 设置view的Frame会触发layoutSubviews(frame的值变化了)
如何使用 layoutSubviews

要使用 layoutSubviews,我们需要在自定义的UIView子类中重写这个方法。

- (void)layoutSubviews {
    // 调用确保任何父类的布局逻辑都不会被遗漏。
    [super layoutSubviews];
    
    // 布局操作 -- 更新布局代码
    
}
常见的布局操作
  • 调整子视图的位置和大小: 通过修改子视图的frame属性,我们可以调整它们的位置和大小,确保它们适应父视图的变化。

  • 更新约束: 如果我们在项目中使用Auto Layout,那么在layoutSubviews中更新约束将是一个不错的选择,以确保布局的稳定性。

  • 其他自定义布局操作: 根据具体的需求,我们还可以进行其他自定义的布局操作,以满足特定的UI设计要求。

layoutSubviews的实际应用
  • 处理动态数据: 当我们的视图需要根据动态数据进行布局调整时,layoutSubviews是一个理想的位置。例如,一个聊天界面中的消息气泡高度可能会根据消息内容的不同而变化,这时我们可以在layoutSubviews中更新气泡的高度。

  • 自定义动画效果: layoutSubviews的调用时机使得它非常适合用于实现一些自定义的动画效果。通过在布局调整中加入动画,我们可以创建出更加生动和流畅的用户体验。

setNeedsLayout()

标记为需要重新布局,异步调用layoutIfNeeded刷新布局,不立即刷新,在下一轮runloop结束前刷新,对于这一轮runloop之内的所有布局和UI上的更新只会刷新一次,layoutSubviews一定会被调用。

layoutIfNeeded()

如果有需要刷新的标记,立即调用layoutSubviews进行布局(如果没有标记,不会调用layoutSubviews)。

在当前runloop中立即刷新

layoutIfNeeded不一定会调用layoutSubviews方法。 setNeedsLayout一定会调用layoutSubviews方法(有延迟,在下一轮runloop结束前)。 如果想在当前runloop中立即刷新,调用顺序应该是:

[self setNeedsLayout];
[self layoutIfNeeded];

drawRect()

这个方法是用来重绘的。

  • drawRect在以下情况下会被调用:
    1、如果在UIView初始化时没有设置rect大小,将直接导致drawRect不被自动调用。drawRect调用是在Controller->loadView, Controller->viewDidLoad 两方法之后掉用的.所以不用担心在控制器中,这些View的drawRect就开始画了.这样可以在控制器中设置一些值给View(如果这些View draw的时候需要用到某些变量值).
    2、该方法在调用sizeToFit后被调用,所以可以先调用sizeToFit计算出size。然后系统自动调用drawRect:方法。
    3、通过设置contentMode属性值为UIViewContentModeRedraw。那么将在每次设置或更改frame的时候自动调用drawRect:。
    4、直接调用setNeedsDisplay,或者setNeedsDisplayInRect:触发drawRect:,但是有个前提条件是rect不能为0。
    以上1,2推荐;而3,4不提倡

drawRect方法使用注意点: 1、若使用UIView绘图,只能在drawRect:方法中获取相应的contextRef并绘图。如果在其他方法中获取将获取到一个invalidate的ref并且不能用于画图。drawRect:方法不能手动显示调用,必须通过调用setNeedsDisplay 或者 setNeedsDisplayInRect,让系统自动调该方法。
2、若使用calayer绘图,只能在drawInContext: 中(类似于drawRect)绘制,或者在delegate中的相应方法绘制。同样也是调用setNeedDisplay等间接调用以上方法。
3、若要实时画图,不能使用gestureRecognizer,只能使用touchbegan等方法来调用setNeedsDisplay实时刷新屏幕。
4、和 layoutSubviews 一样,你不应该直接调用 drawRect 方法,而应该通过调用触发方法,让系统在 run loop 中的不同结点自动调用。

sizeToFit()

  • sizeToFit会自动调用sizeThatFits方法;
  • sizeToFit不应该在子类中被重写,应该重写sizeThatFits
  • sizeThatFits传入的参数是receiver当前的size,返回一个适合的size
  • sizeToFit可以被手动直接调用sizeToFit和sizeThatFits方法都没有递归,对subviews也不负责,只负责自己

setNeedsDisplay()

这个方法类似于布局中的 setNeedsLayout 。它会给有内容更新的视图设置一个内部的标记,但在视图重绘之前就会返回。然后在下一个 update cycle 中,系统会遍历所有已标标记的视图,并调用它们的 drawRect 方法。如果你只想在下次更新时重绘部分视图,你可以调用 setNeedsDisplay(),并把需要重绘的矩形部分传进去(setNeedsDisplayInRect in OC)。大部分时候,在视图中更新任何 UI 组件都会把相应的视图标记为“dirty”,通过设置视图“内部更新标记”,在下一次 update cycle 中就会重绘,而不需要显式的 setNeedsDisplay 调用。然而如果你有一个属性没有绑定到 UI 组件,但需要在每次更新时重绘视图,你可以定义他的 didSet 属性,并且调用 setNeedsDisplay 来触发视图合适的更新。