AutoLayout Tips

小顧Bruce發表於2020-03-29

AutoLayout 的三個階段

每個啟用自動佈局的UIView在初始化後經過三個步驟:約束更新、佈局和渲染。

約束更新 Update Constraints

這一步做的事情是基於約束計算 frame,系統自頂向下遍歷檢視層級,即從父檢視到子檢視,呼叫每個檢視的updateConstraints()方法。

setNeedsUpdateConstraints會使約束失效,安排下一個 runloop 內更新約束。如果約束已經失效(被標記為需要更新),updateConstraintsIfNeeded會在合適的時候觸發updateConstraints

Apple 建議不要重寫updateConstraints,除非發現更改現有的約束太慢,此時需要在updateConstraints中批量更新約束,同時要保證實現儘可能高效。

佈局 Layout

在此步驟中,每個檢視的 frame 都將使用 Update 階段中計算的值進行更新。系統自底向上遍歷檢視,即從子檢視到父檢視,依次呼叫layoutSubviews

當發生這兩種情況時,需要重寫layoutSubviews

  • 約束不足以描述檢視的佈局。
  • 需要手動寫程式碼計算 frame

呼叫setNeedsLayout會使佈局失效,向系統表示檢視的佈局需要重新計算。如果佈局已經失效,layoutIfNeeded會觸發layoutSubviews。它們的關係同 setNeedsUpdateConstraints 以及 updateConstraintsIfNeeded 方法的工作機制類似。

重寫layoutSubviews時需要注意:

  • super.layoutSubviews().
  • 不要呼叫setNeedsLayoutsetNeedsUpdateConstraints,不然會死迴圈。
  • 不要修改當前層次結構之外的檢視約束。
  • 要小心更改當前層次結構的檢視的約束。它將觸發一個 Update 步驟,然後是另一個 Layout 步驟,可能會建立一個死迴圈。

渲染 Display

此步驟負責將畫素顯示到螢幕上。預設情況下,UIView將所有工作傳遞給一個它的CALayer,它包含當前檢視狀態的畫素點陣圖。此步驟與是否用自動佈局無關。

這裡關鍵的方法是drawRect。大多數情況下,我們可以組合使用系統已有的 view 和 layer 來構建UI,除非你使用OpenGL ES, Core Graphics 或者 UIKit 做自定義繪製,不然不需要重寫這方法。

所有諸如背景顏色、新增子檢視等這些操作都是自動繪製的。

假如重寫了drawRect,切記呼叫setNeedsDisplay(_:)傳入需要重繪的部分,不要直接呼叫drawRect,就同setNeedsLayout一樣。

UIViewController相關

步驟一和步驟二在 UIViewController 中有對應的部分:

  • Update: updateViewConstraints
  • Layout: viewWillLayoutSubviews / viewDidLayoutSubviews.

viewDidLayoutSubviews是其中最重要的。它用來通知檢視控制器它的檢視已經完成了佈局步驟(即它的bounds已經改變)。當layoutSubviews完成後,在 view 的所有者檢視控制器上,會觸發 viewDidLayoutSubviews 呼叫。這裡檢視已經佈局完它的子檢視,並且它在螢幕上還不可見,所以我們應該把所有依賴於佈局或者大小的程式碼放在 viewDidLayoutSubviews 中,而不是放在 viewDidLoad 或者 viewDidAppear 中。這是避免使用過時的佈局位置資料的唯一方法。

技巧細節

Intrinsic Content Size

Intrinsic content size是基於檢視內容的固有大小。例如,一個UIImageViewIntrinsic content size就是它的影像大小。

這裡有兩個技巧可以幫助簡化佈局和減少約束的數量:

  • 為自定義檢視重寫intrinsicContentSize方法,根據內容返回合適的尺寸。
  • 如果一個檢視只有一個維度的固有大小,你仍然應該覆蓋intrinsicContentSize併為未知維度返回UIViewNoIntrinsicMetric

Alignment Rectangle

AutoLayout 使用對齊矩形來定位檢視。需要注意的是,intrinsicContentSize 指的是對齊矩形,而不是 frame。

預設情況下,檢視的對齊矩形等於用alignmentRectInsets修改過的 frame。為了更好地控制對齊矩形,也可以重寫alignmentRect(forFrame:)frame(forAlignmentRect:)

我們來看看對齊矩形是如何影響檢視定位的。

這裡有一個帶著30 points陰影的 image view。綠色和黑色陰影都屬於同一張 image。

001@2x

我已經疊加了紅線來顯示父檢視的水平和垂直中心線。imageview 約束在父檢視的中心,但是檢視內容綠色方框,顯然沒有居中。

除錯 Alignment Rectangles

Xcode10.2開始, Interface Builder 可以顯示我們自定義的對齊矩形。通過(Editor > Canvas > Layout Rectangles)這個步驟可以在 Interface Builder 畫布中顯示對齊矩形。

也可以在執行時顯示檢視的對齊矩形。開啟 scheme 編輯器,加一個啟動引數-UIViewShowAlignmentRects YES

003

此時執行起來,檢視的對齊矩形會被黃色框高亮。

Alignment rects shown in yellow

可以看到自動佈局將檢視中的黃色對齊矩形居中。它不知道我們需要綠色方框居中。為了忽略掉陰影,我們需要一個新的對齊矩形,從底部和右側去掉30點:

005@2x

如果把圖片放到了 Asset Catalog 裡,我們可以直接修改對齊矩形。在 attributes inspector 欄下,如圖所示部分修改:

006

如果使用多個倍數的影像(1x, 2x, 3x),那麼需要為每個影像指定邊距值。這裡需要為1x增加30個畫素,為2x增加60個畫素,為3x增加90個畫素。

那麼如何通過程式碼修改呢?我們給UIImageView加個擴充套件:

extension UIImageView { 
  convenience init?(named name: String, top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) {
    guard let image = UIImage(named: name) else {
        return nil
    }
    let insets = UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
    let insetImage = image.withAlignmentRectInsets(insets)
    self.init(image: insetImage)
  }
}
複製程式碼

在控制器中使用時:

override func viewDidLoad() {
  super.viewDidLoad()
  setupImageView()
}

private func setupImageView() {
 guard let imageView = UIImageView(named: "Shadow", top: 0, left: 0, bottom: 30, right: 30) else {
      fatalError("Can't create image")
  }
  view.addSubview(imageView)
}
複製程式碼

再次執行,我們就將看到綠色居中:

007@2x

上面的例子中陰影是屬於圖片的一部分,我們還可以通過 UIKit 加陰影,這種方式不會影響對齊矩形。