使用 Auto Layout 的典型痛點和技巧

官方文檔:Auto Layout Guide 加上去年WWDC上的 Mysteries of Auto Layout 這兩個 Session,以及星光社的戴銘的這篇總結深入剖析 Auto Layout,分析 iOS 各版本新增特性可以當做小抄使用,涵蓋了 Auto Layout 的所有方面。再寫東西只能寫點不同的了,本文將搜集一些使用 Auto Layout 的痛點和技巧供參考。

Auto Layout 與 Frame

很多人盛贊 Auto Layout 是比 frame 更優雅的布局方案,我基本認同,不過,首先,Auto Layout 寫起來一點都不優雅,一行 frame 代碼使用 Auto Layout 需要四行代碼,足以讓很多人望而卻步。所以官方不斷在提升添加約束的體驗,第三方庫也不斷地優化添加約束的語法來減輕開發者的痛苦。iOS 9 新推出的 Anchor 在語法上大幅簡化了約束的編寫,不足之處在于缺乏對multiplier參數的設置,但不管如何優化,frame 一行代碼 Auto Layout 還是四行代碼。就我個人來講,非常不喜歡 VFL,代碼最啰嗦,寫起來簡直要命,不過在 Debug 要讀懂 Log 就必須了解 VFL 的語法。老實說,使用 frame 時一行代碼四個數字就可以確定視圖的位置和大小比起有多種可能方案的 Auto Layout 布局實際上要舒心得多,雖然后者可讀性更好,不過 frame 往往也是需要計算的,從成本上講有時候兩者挺接近的;Auto Layout 的真正優勢在于自動化,我們只需要給系統一堆布局方程式,剩下的事情就不用我們操心了,這在分辨率適配、多視圖互相約束協作等方面顯得極其高效(優雅),這也是我能忍受寫一堆約束的原因。

剛接觸 Auto Layout 時一直想搞清楚這兩者的關系,簡單來講,Auto Layout 將約束條件轉化為視圖的 frame。原來的布局過程直接使用 frame 指定視圖的位置和大小,AutoLayout 參與布局后通過約束計算出 frame,再應用到視圖上。具體過程可參考 Mysteries of Auto Layout, Part 2 的開頭部分,深入剖析Auto Layout,分析iOS各版本新增特性也總結了視頻中的這部分內容。

如何妥善處理這兩者的關系?

Frame 自動轉化為約束

既想享受 frame 的便捷,又想得到 Auto Layout 的好處,魚與熊掌能兼得么?

UIView 的translatesAutoresizingMaskIntoConstraints屬性在這里派上用場,該屬性為true時,設置 frame 會自動轉化為約束,修改 frame 時也會自動調整約束。這時候就不要再手動添加約束了,你再添加約束往往會造成沖突,注意是往往,因為此時視圖上的約束已經是唯一可解的了,你添加的往往是優先級最高的約束,必然造成沖突,在控制臺能看到NSAutoresizingMaskLayoutConstraint這種類型的約束與你添加的約束無法同時滿足,這里甚至有溫馨提示你查看translatesAutoresizingMaskIntoConstraints屬性的文檔,想必蘋果也知道大家經常忘記把這個屬性關閉。或許你可以添加可選約束,不過這樣一來就沒什么意義了。那么可以修改這種自動添加的NSAutoresizingMaskLayoutConstraint約束嗎?實際上無法找到這樣的約束的,它被系統隱藏了,你只能在發生沖突時才能在控制臺看見它們。

這個屬性與原來的 auto resize mask 結合后能產生很好的效果,如下所示:添加了寬度和高度方向的 mask 后,當 containerView 的尺寸發生變化后,subView 也會隨之變化,享受了 Auto Layout 的好處,還不用寫約束。

subView.translatesAutoresizingMaskIntoConstraints = true
subView.autoresizingMask = [UIViewAutoresizing.FlexibleWidth, UIViewAutoresizing.FlexibleHeight]
containerView.addSubview(subView)
subView.frame = containerView.bounds

translatesAutoresizingMaskIntoConstraints屬性將兩種的布局機制的優點結合起來,你可以在某個子視圖上使用 frame,其他的子視圖使用約束來布局,互不干擾。這種混合機制就好比 Objective-C 與 Swift 在同一個工程中使用,但你不能在 Objective-C 文件中使用 Swift 語言,或者在 Swift 文件中使用 Objective-C 語言,起初我還真就這么認為的。

在 storyboard 里,這個屬性是默認關閉的,在代碼里生成的視圖的該屬性默認是開啟的。如果你不想用約束,又希望 AutoLayout 能幫你打理,就開啟這個屬性。其他情況下,應該關閉這個屬性。

各自為政

如果嫌棄寫約束太麻煩,也可以不使用 Auto Layout,使用 Auto Layout 的視圖去折騰約束,剩下的視圖就由你負責手動處理 frame 了。覺得 iOS 7 看起來好丑,我還是用我的 iOS 6 吧,沒問題,只是很多新特性無法享受罷了。

直接的混血模式,危險!

正常情況下,兩者的合作方式應該只有上面兩種,但經常還是有人將 Auto Layout 與 frame 在一個視圖上混合使用,因為看上去好像都能正常工作,但可能會遇到各種疑難雜癥,原因在于兩者更新布局的機制差異。

Auto Layout 對視圖進行布局的唯一依據是視圖的約束,約束發生變化后會觸發約束機制重新計算視圖的 frame 并更新,這種情況包括:約束的修改和優先級的變化,添加或移除約束,添加或移除添加了約束的視圖。當然還有其他事件會觸發約束變化,文檔 Understanding Auto Layout 中列舉了這些情況。在代碼中我們造成這樣的約束變化后 Auto Layout 會自動更新視圖的布局,但有時候你不能期待布局會立即更新,因為 Auto Layout 要搜集約束變化,計算新的布局,然后遍歷受影響的視圖重新布局,在性能上比直接設置 frame 要慢一點。你可以在擁有變化的約束的視圖上調用layoutIfNeeded()強制立刻更新布局。

直接設置 frame 并不會修改視圖相關的約束(除了開啟translatesAutoresizingMaskIntoConstraints),而約束的一切變化會轉化為新的 frame,兩者之間的影響是單方向的。同時修改視圖的 frame 和約束,最終結果還是以約束轉化的 frame 為準。前后腳修改 frame 和約束,這種瞬間的布局變化兩者混合使用不會出錯,因為最終都修改了 frame;但如果用這種方式進行動畫會出現偏差,因為兩者之間的影響是單方向的,必然會造成狀態的不連續,這是可以預見的;在這種情況下,如果希望動畫完全符合你的預期,必須保證約束與 frame 是匹配的,但這樣一來,修改 frame 后還得修改約束,實在沒必要,使用 Auto Layout 就老老實實地使用約束吧。

來看看例子:

@IBOutlet weak var testView: UIView!
@IBOutlet weak var centerXConstraint: NSLayoutConstraint!

var center = testView.center //假設此時 center.x 的值為160
center.x += 10
testView.center = center //現在 testView 的 center.x 為 170

centerXConstraint.constant += 10 //現在 testView 的 center.x 依然為 170,因為直接修改 frame 并不影響約束,約束只參照約束
centerXConstraint.constant += 10 //現在 testView 的 center.x 為 180
testView.center.x += 10 // 現在 testView 的 center.x 為 190,但從 Auto Layout 的角度看依然是 180

//動畫從 190 變化到 200
UIView.animateWithDuration(0.5, animations: {
        testView.center.x += 10
})
//盡管從 Auto Layout 的角度看 center.x 是 180,修改約束后該值為 190,這個動畫應該是從 180 變化到 190,但實際動畫是從當前的 200 變化到 190。
UIView.animateWithDuration(0.5, animations: {
    self.centerXConstraint.constant += 10
    self.testView.superView.layoutIfNeeded()
})

順便提一下使用 Auto Layout 做動畫的方法。UIView 中更新布局的相關方法:

Laying out SubViews
- layoutSubviews() //不要直接調用,如果需要強制更新布局,調用下面的 setNeedsLayout()
- setNeedsLayout() //標記布局需要在下一個周期更新
- layoutIfNeeded()  //立刻更新布局
Triggering Auto Layout
- setNeedsUpdateConstraints() //標記約束需要在稍后更新,系統會調用下面的 updateConstraints()方法,修改多個約束后調用該方法批量更新有助于提升性能
- updateConstraints() //更新調用該方法的視圖的約束
- updateConstraintsIfNeeded() //更新調用該方法的視圖以及其子視圖的約束

使用 Auto Layout 時提交動畫與普通的動畫沒有什么區別,在 Block 里修改相關屬性,只不過最后需要調用layoutIfNeeded()立即更新布局,其他方法無效。在網上的一些例子里,也有將修改的步驟放在 Block 之外,起初我搜索到的就是這樣的方式,為了強制保持一致的代碼風格,我現在回到了下面的風格。

UIView.animateWithDuration(0.5, animations: {
    ....../*修改 view 的約束*/
    view.superView.layoutIfNeeded() //立刻更新其下的子視圖的布局
})

很多時候我們喜歡在viewDidLoad()做一些設置,但此時還沒有開始布局視圖,如果你希望在此修改約束,記得調用layoutIfNeeded()方法立刻更新布局使得修改生效。

約束(contraint)的擁有者

我一直覺得使用 AutoLayout 的另外一個重大障礙是找出需要的約束。在 IB 中添加的約束可以在實現文件里用 IBOutlet 來引用,但可能不會引用每一條約束,或者你是在代碼里添加的呢,所以第一個問題是,怎么找出要修改的約束?約束往往涉及兩個視圖,是否在這兩個視圖上都保存了一份呢,要修改是否需要修改兩份?否。約束保存在兩個視圖最近的父類視圖中或者兩者中層級比較高的那個視圖,事實上你如果將約束添加到兩者中層級較低的那個視圖會出現錯誤。這是由自動布局的機制決定的,布局更新的順序是從上到下,從外到內,在更新布局時需要根據視圖上的約束對其下的子視圖進行布局,添加到子視圖上顯然不利于布局的計算。從另外一個角度講,視圖只會保存它的子視圖相關的約束,以及參與對象中較高層次是自身的約束,比如設定self.height = self.width * 0.5這種約束。

知道了地方還需要找到指定的約束,從下面方法的參數可以看到,如果希望直接對比視圖來查找往往需要一番轉換才行,而且還需要對比兩個參數 view1 和 view2,這實在是很不方便。

init(item view1: AnyObject, attribute attr1: NSLayoutAttribute, relatedBy relation: NSLayoutRelation, toItem view2: AnyObject?, attribute attr2: NSLayoutAttribute, multiplier multiplier: CGFloat, constant c: CGFloat)

約束還有一個屬性var identifier: String?用于標記,這在 Debug 時面對超長 Log 時非常省心,這里設定該值用于查找能夠省點力氣。相比使用 frame 時可以一步修改時的便捷,尋找約束的過程可能就耗盡了你對 AutoLayout 的向往。

另外,從 iOS 8 開始添加約束不必再用view.addConstraint(constraint)這種方法了,如前面所說,這種方法必須將約束添加到最近的父視圖或是參與約束中層級較高的那個視圖中,在 iOS 8 里,NSLayoutConstraint類添加的var active: Bool屬性可以自動調用相關視圖的addConstraint:removeConstraint:方法,不必需要我們操心約束的正確擁有者了。新生成的約束該屬性為false

另外 iOS 8 還添加了以下兩個批量處理的方法:

class func activateConstraints(_ constraints: [NSLayoutConstraint])
class func deactivateConstraints(_ constraints: [NSLayoutConstraint])

優先級 Priority

我剛開始接觸 AutoLayout 的時候知道約束可以是不等式,只要所有的約束有解且唯一就可以保證布局正常,但不知約束的優先級有何用處。約束的優先級值的范圍為半開區間(0,1000]之間的 Float,優先級值為1000時表示該約束必須滿足,其他的值表示該約束是可選的。在 storyboard 里可以看到優先級約定了這么幾個常量:

Required   1000
Hight      750
Low        250

優先級值為1000時該值不可再變化,可選約束的優先級值可以在(0,1000)之間隨意變化,不能為1000,必須滿足的約束和可選的約束一旦生成了就不能轉化到對方的陣營里去。如果所有的 Required 約束不能唯一確定布局,就會從優先級次高的約束中補充,依然不能唯一確定布局的話,再從次級的約束中補充。所以不妨換一種思維方式,不需要所有的約束都是 Required 級別的,只要保證優先級靠前的約束能唯一確定布局就可以了。

由于優先級在1000以下的約束都是可選的,可以針對不同的布局需求添加多個可選約束而且不會引起約束沖突,通過調整這些可選約束的優先級可以實現不同的布局。

優先級約束動畫

上面的例子來自這篇文章 Animating Autolayout Constraints,這篇文章的最后一次修改中通過分別修改了兩個約束的 constant 和優先級來實現這個動畫。為了讓這個示例更典型一點,這里也稍微修改一下原文的實現,僅僅修改其中一個約束的優先級就可以實現這個動畫(黃藍視圖的間距會稍有不同)。實現方法:添加如下的的約束條件,只保證兩個視圖之間的距離約束是必須實現的,相對于尾部的距離約束都是可選的,哪個優先級高就滿足哪個。這里的兩個可選約束優先級一樣,是無法確定唯一布局的,必須打破這個局面。

yellowView.Trailing = 1.0 * superView.TrailingMargin (priority = 750)
blueView.Trailing = 1.0 * superView.TrailingMargin (priority = 750)
blueView.Leading = 1.0 * yellowView.Trailing + 18(priority = 1000)//視圖間的標準間距為8,這里為了將 blueView 擠出屏幕,多加了10個單位

另外還有個前提約束:yellowView.width = 1.0 * blueView.width (priority = 1000)

在 Switch 的響應方法中更改其中一個尾部約束的優先級值,此時滿足上面三個約束中優先級最高的兩個約束就可以唯一確定布局,這樣當兩個可選布局的優先值的排位不一樣時就形成了兩種布局:

func updateConstraintsForMode {
    if (self.modeSwitch.isOn) {
        self.blueViewTrailingConstraint.priority = UILayoutPriorityDefaultHigh + 1
    } else {
        self.blueViewTrailingConstraint.priority = UILayoutPriorityDefaultHigh - 1
    }
}

如果通過更改約束本身的構成條件來完成上面的效果,最簡單的辦法是去掉 yellowView 與父視圖的尾部距離約束,然后修改blueViewTrailingConstraint的常量值。這種方法可以說是非常的 frame 風格,也是很容易想到的。孰優孰劣不好說,這個例子就算是給你提供另外一種可能吧。

優先級在一些擁有固有尺寸(intrinsicContentSize)的視圖上運用得比較多,像 UILabel,UIButton,UITextField 這類為了優先保證內容顯示完整的控件,在 storyboard 里添加約束時僅僅需要添加兩個位置相關的約束就可以了。 在這里可以看到相關的討論:Priority: Content hugging vs Content compression resistance

約束系數 Multiplier

約束的組成:

約束圖解

前面說過 AutoLayout 的真正優勢是自動化。常見的場景是,比如多個子視圖的 centerX 相對于父視圖的 centerX 保持一定的距離,該距離與子視圖在隊列中的位置有關,其中一個視圖在該方向的位置發生變化時,后面的視圖會自動更新位置,使用 frame 是難以如此便捷地做到的。但有時候考慮到某個子視圖可能會移除,這樣需要重新配置約束,覺得麻煩,怎么辦,回到 frame 的方法,每個子視圖單獨與父視圖配置約束,這樣不影響其他子視圖。當屏幕旋轉時如果父視圖的寬度發生了變化,如何自動維持這個規律?我剛開始使用 AutoLayout 時還不適應這種布局方式,僅僅只是將 frame 翻譯為對應的約束,但采用的手法卻是將對應的距離轉化為 constant,比如下面這種:

subView.centerX = 1.0 X containerView.centerX + (i * containerView.frame.width)

然而當父視圖寬度變化時卻發現子視圖并沒有自動更新位置。問題在哪?搞錯了這個約束方程式的變量,constant 設定后它就不會變了,應該在父視圖的 centerX 這個變量上做文章。我默默地將multiplier設置為1后的約束盡管也發生了變化(很微量往往察覺不到),但沒有達到預期,應該這么做:

subView.centerX = (2 * i + 1) X containerView.centerX + 0

這樣每一個子視圖都會隨著父視圖的 centerX 的變化自動更新自己的位置。具體例子可以參考這里

當你需要設定一些特別的比例時,比如 3/7, 7/13 之類,在代碼中可以很方便地就這樣原封不動地交給算式表達式來計算,在 storyboar 里怎么弄呢,3:7, 7:13 這樣就可以了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容