Auto Layout

Auto Layout學習指南:利用Visual Format Language和程式碼設定約束畫面佈局

Auto Layout學習指南:利用Visual Format Language和程式碼設定約束畫面佈局
Auto Layout學習指南:利用Visual Format Language和程式碼設定約束畫面佈局
In: Auto Layout

做為一個iOS開發者,你應該知道在任何專案的待辦任務清單內,都會有視圖(views)以及子視圖(subviews)之間constraints設定的問題,無庸置疑,設定constraints(約束條件)是相當棘手的議題,它常常會讓你在開發應用程式時感到痛苦,但其實一切只取決於開發者是否理解它,事實上,約束條件就是你想要應用於螢幕上任何圖形元素的佈局規則,必須考慮視圖與子視圖在畫面上的位置、大小以及視覺關係,並且當設備的方向改變時,要定義UI元件的預設表現。

約束條件可以透過兩種方式進行設定:包含圖形以及程式碼,多數人都會選擇使用圖形化介面來進行約束條件設定,只要在storyboard或XIB檔案中即可直接進行,這樣做的好處是可以讓開發者立刻就藉由畫面看到視圖之間應該要設立的約束條件,所以開發者只要看到畫面即可進行,儘管這個方法實作比較容易,卻也很可能因為給定的約束條件相互矛盾,導致畫面需要的佈局約束條件無法被同時滿足,而讓應用程式在運行時出現問題,雖然大多時候Xcode可以提供你一些協助,可透過提示指出被誤植的constraints,讓開發者可以修復並更新這些設定,直到不再出現約束條件衝突為止。

另一方面,透過程式碼則是另外一個設定約束條件的方法,它包含了兩個不同的選擇:使用UIKit提供的屬性以及方法,或透過另外一個特殊的格式語言Visual Format Language,如果你從未操作過,那本文將提供讀者首次認識它們的機會,在這篇教程的最後你將可以決定自己是否適合在程式碼設定約束條件,以及哪種方式會是你所偏好的,實際上,這些方式都是可以被組合使用的,而且它們也應該被拿來混用,特別使用Visual Format Language的開發者(雖然被公認是很方便的方法,但有時不是那麼有彈性),現在這裡還不會介紹到太多細節,但是接下來的篇幅將會致力於此,所以讀者若是對上述提及的內容還有些模糊,在你閱讀這篇文章的過程中將會一一釐清。

在結束前面這個簡短的介紹段落前,我想要在這裡先做一個小評論,雖然使用約束條件是被推薦的方法,但不一定總是要使用它去建置你的UI (user’s interface),在滿足以下兩個條件時可以不用:首先,你是使用程式碼建置應用程式的圖形元件(Label、Button…等視圖元件),代表必須初始化並且設定它們的框架(frame),並透過其他視圖和它們在代碼中的視覺行為來設定位置(當然沒有設置約束條件,而是給定精確的框架)。以個人來說,大多數時間,我會偏好透過程式碼手動生成我的視圖,所以在Storyboard裡面不會使用到view controllers,以這種方式來管理視圖元件最終的動作,特別是它們需要在某些時間點使用動畫效果呈現。第二個條件是,不管使用者如何變更裝置的方向,你的應用程式都只需要應付一個方向的設定。但是在任何其他狀況下,它將會面臨到根據不同裝置方向調整元件的位置以及框架大小的地痛苦時刻,所以constraints仍舊是最佳的選擇,不管如何,這裡我想表達的是,還是有不需要使用constraints來建置UI的方法,但是只有在特定情況下才會發生,當然我不是鼓勵你這樣做,它取決於你的UI構建方式。

這邊是官方文件供你參考,介紹如何透過編程方式建立constraints,另外,這是Visual Format Language的官方指南

Demo Project

在我們開始體驗建立約束條件的甜蜜滋味前,這邊有個名為Constraints初始專案供讀者下載,在ViewController的類別中,你可以發現一系列的函式,它們目前內部還是空的,實作這些函式將是我們接下來的工作,同時,在viewDidAppear(_:)裡面可以看到目前只有第一個函式可以被呼叫,剩下的函式目前被我們註解掉了,接下來我們會在各個段落依序解除它們被註解的狀態,最後,看一下這個類別裡面我們宣告了一些視圖(views)元件,當然它們都會是接下來會使用到的。

不用多說,是時候開始談論到最有趣的部分。

創建約束條件 – 基礎

我們將會從一個簡單的基礎範例開始,請打開這個初始專案,進入到ViewController.swift的檔案內,並且找到simpleView1()這個函式,並且添加下列的程式碼,生成一個簡單的UIView物件。

func simpleView1() {
    let myView = UIView()
    myView.backgroundColor = UIColor.black

    self.view.addSubview(myView)
}

這邊沒有太困難的地方,我們初始化一個view,並且設定它的背景顏色為黑色,我們不會指定一個frame,因為等一下它將會透過constraints形成,接著將它設定為ViewController內預設視圖中的一個子視圖。

讓我們幫上面的view建立leading(前導)的constraint,我們想要view的側距離parent view(父視圖)的左邊有50 points的距離(換句話說,view的X位置與parent view的左邊有50 points的距離)。

let leading = NSLayoutConstraint(item: myView,
                                         attribute: .leading,
                                         relatedBy: .equal,
                                         toItem: self.view,
                                         attribute: .leading,
                                         multiplier: 1.0,
                                         constant: 50.0)

來談論一下NSLayoutConstraint(...)方法裡面的各個參數(parameters)。

  • item是我們要設定constraint的圖形元素,在我們這個範例中,就是myView這個視圖,但是它也可以是其他的圖形元素(button、label、image view等)。
  • attribute是透過簡單的文字去描述我們設定的constraint類型(例如:leading、trailing、top…等),它是NSLayoutAttribute的value,當你開始輸入這個value,Xcode將會自動提示所有可用的值。
  • relatedBy表示視圖attribute屬性設定的constraint與其他視圖attribute屬性的關係,它是一個NSLayoutRelation值,簡單來說,它提供一個選項,讓第一個attribute參數值可選擇等於、大於或是小於下一個參數值,同樣的,Xcode會自動提示所有可用的值。
  • toItem是另一個視圖,用來做為我們原本視圖constraint設定的參考點,在某些時候它可以為nil,在我們的範例中,self.view就是設為參考點之用。
  • attribute參數(第二個)是參考視圖(在這裡為self.view)的constraint type,它用來表達與目標視圖的attribute之間約束關係,就如同先前提到的,relatedBy參數值用來指定對應關係。
  • multiplier參數將第二個attribute參數的值乘以另一個做為參數給定的值。通常設置的默認值為1.0,接下來將看到一些範例,我們會設置預設之外的值,以便讀者了解其如何運作。
  • 實際上,constant的值會被加到第二個attribute參數,因而讓item內所設定的視圖生成預期的結果。

簡而言之,在上面的代碼中,我們替myView視圖的建立leading constraint,讓它對齊父視圖(parent view)的leading,它們左側之間的距離為50.0 points。

緊接著,按照之前步驟,添加trailing(尾部)的約束條件:

let trailing = NSLayoutConstraint(item: myView,
                                  attribute: .trailing,
                                  relatedBy: .equal,
                                  toItem: self.view,
                                  attribute: .trailing,
                                  multiplier: 1.0,
                                  constant: -50.0)

除了constant的數值外,其他都跟先前做的步驟一樣,在這個案例中,需要用負數來表示我們視圖的右邊在父視圖右側前面。使用正數會讓myView視圖的寬延伸至self.view右側之後50.0 points的距離。

上面的兩個約束條件以及給定的設定,讓我們完成兩件重要的事情:

  1. 首先,視圖將以水平置中顯示,並且與螢幕兩側距離相同。
  2. myView視圖的寬度會在運行時會被自動指定計算。更準確地說,寬度將等於:self.view.frame.width - 2 * 50.0

下一步則是要垂直設定我們視圖的位置,所以我們將指定top的約束條件:

let top = NSLayoutConstraint(item: myView,
                             attribute: .top,
                             relatedBy: .equal,
                             toItem: self.view,
                             attribute: .top,
                             multiplier: 1.0,
                             constant: 100.0)

根據給定的constant(常數)值,我們可以告訴這個視圖Y軸原點位置,它將會與父視圖上方保持100.0 points的距離。

目前我們已經透過leading與top的約束條件,指定了myView視圖的原點位置,以及它的寬度,這裡要做最後一件事,就是告訴應用程序高度應該設為多少。讓我們再添加一個約束條件:

let height = NSLayoutConstraint(item: myView,
                                attribute: .height,
                                relatedBy: .equal,
                                toItem: nil,
                                attribute: .notAnAttribute,
                                multiplier: 1.0,
                                constant: 120.0)

在上面的程式碼中有一些新的設定,首先,toItem參數值被設為nil,因為我們不再需要透過其他視圖的相對位置來設定高度值,因此,我們也不需要設定第二個attribute參數,所以將它設為notAnAttribute,這樣一來,當視圖要在螢幕上佈局時,這個參數會自動被忽略。另外,視圖的高度設置為120.0 points(由constant參數中給定)。

如果我們現在運行這個應用程式,將不會有任何東西顯示在螢幕上,這是因為雖然我們已經建立上面這些約束條件,系統還不知道這些設定條件,所以接下來請看我們如何更改它,這裡有三種使用constraint物件的方式,本文將會依序介紹,第一個就是直接將約束條件添加到myView的父視圖:

self.view.addConstraint(leading)
self.view.addConstraint(trailing)
self.view.addConstraint(top)
self.view.addConstraint(height)

<請特別注意,這些約束條件是被加進該視圖的superview,這裡請記住一個通例;當有一個簡單視圖(如這裡)與其他客製化視圖沒有對應關係,那麼你為其創建的約束條件應添加到它的父視圖。然而,當有更多的視圖通過屬性(leading,trailing等)相依在一起時,則它們的約束條件要被添加到它們共同的superview。稍後我們將用一個更複雜的例子來說明。現在,先專注於約束條件被添加到父視圖的情境,如上面程式碼所示。>

如果你不喜歡上面的方法,也可以使用下列的作法,activate(啟用)你的約束條件:

NSLayoutConstraint.activate([leading, trailing, top, height])

這是一個比較快速的方法,你只需要提供一個array,將約束條件塞入array之中,並且把它當作NSLayoutConstraint類別的activate(...)類別函式參數。

我們幾乎準備好了,但是我們在進入這部分的尾聲前,先來嘗試運行這個應用程式,啟動之後你會發現仍舊沒有東西顯示在畫面上,並且會看到console內有提示訊息,告訴我們全部的約束條件並未被同時滿足:

auto layout - constraints

這是因為iOS正試圖自動創建約束條件,而我們必須明確告訴它不要這樣做,為此,我們需要使用UIView類別中的translatesAutoresizingMaskIntoConstraints屬性,並將它設定為false,如下圖所示:

myView.translatesAutoresizingMaskIntoConstraints = false

現在再次運行這個應用程式,這次可以看到這個view出現在螢幕中,而且你可以旋轉這個裝置,來驗證我們約束條件的實際運作表現:

sample1-portrait-landscape-1

如果你想要更進一步,嘗試添加以下的約束條件:

let bottom = NSLayoutConstraint(item: myView,
                                attribute: .bottom,
                                relatedBy: .equal,
                                toItem: self.view,
                                attribute: .bottomMargin,
                                multiplier: 1.0,
                                constant: -250.0)

這個新的約束條件設定myView視圖的底邊距離螢幕的底邊有250.0 pixels的距離(與trailing的約束條件類似,也要注意這裡的負值)。當然,我們不要忘記將它添加到約束條件的啟動集合中:

NSLayoutConstraint.activate([leading, trailing, top, height, bottom])

再次運行這個app,你將會在console看到關於約束條件衝突的新訊息,這是因為底邊與高度的約束條件無法同時被啟用,為什麼呢?height的constraint設定一個特定的值當做view的高度,但是另外一方面,透過topbottom的約束條件於運行時設定另外一個高度(就如同先前我們透過leading與trailing約束條件來設立它的寬度),最後,myView視圖將會拿到兩種高度,但系統無法同時接受兩個值,為了展示這個範例,我們只刪除高度約束參數,並再次運行查看發生了什麼:

NSLayoutConstraint.activate([leading, trailing, top, bottom])

sample1-portrait-landscape-2

新的約束條件完全被接受,視圖底部和屏幕之間依然保持250.0 points距離。

置中對齊視圖

我們已介紹如何以編程方式設置約束,接下來透過一些案例來展示它的實用性,也藉此提供一些使用指南,我們使用Interface Builder以圖形方式設置約束時,常用的一件事是使視圖水平和垂直置中對齊,這在Storyboard中可以很容易做到,但是如何透過程式碼去實現呢?

你將會很快發現這不是那麼困難,事實上,它其實相當容易,只要你已經理解了上一節中討論的基礎觀念,這次我們將會在simpleView2()函式中作業,所以請確實將viewDidAppear(_:)裡面的simpleView1()註解起來,並且將simpleView2()的註解去除,接著移動到這個函式的body中。

開始的方式與之前相同,我們將初始化一個新的視圖對象:

func simpleView2() {
    let myView = UIView()
    myView.backgroundColor = UIColor.black
    self.view.addSubview(myView)

    myView.translatesAutoresizingMaskIntoConstraints = false
}

讓我們開始建立約束條件,設定與視圖水平置中對齊:

let centerHorizontally = NSLayoutConstraint(item: myView,
                                            attribute: .centerX,
                                            relatedBy: .equal,
                                            toItem: self.view,
                                            attribute: .centerX,
                                            multiplier: 1.0,
                                            constant: 0.0)

這邊有一個用來當做attribute參數值(centerX)的新元素,它會與constant提供的屬性值結合使用,這裡可以很明顯的看到,兩個視圖(myViewself.view)的中心X點將具有相同的值,所以我們的視圖將會水平置中,儘管如此,請確認constant參數的值總是0.0,若為負數代表我們視圖的水平中心會向左移動指定值,而正數則意味著水平中心向右移動指定值。

現在我們來對垂直置中的設定做同樣動作:

let centerVertically = NSLayoutConstraint(item: myView,
                                                  attribute: .centerY,
                                                  relatedBy: .equal,
                                                  toItem: self.view,
                                                  attribute: .centerY,
                                                  multiplier: 1.0,
                                                  constant: 0.0)

這裡不再多做討論,直接啟用這兩個約束條件:

NSLayoutConstraint.activate([centerHorizontally, centerVertically])

讓我們運行這個應用程式:

t58_6_sample2_run1

有趣的是,沒有任何東西顯示在畫面上,為什麼呢?

是啊!我們沒有指定視圖的寬度和高度,所以在預設情況下都會被設置為0。是時候替它們創建兩個新的約束。然而,這次我們將在初始化時啟動每個約束,這也是啟動約束的第三個也是最後一個方法:

NSLayoutConstraint(item: myView,
                   attribute: .width,
                   relatedBy: .equal,
                   toItem: nil,
                   attribute: .notAnAttribute,
                   multiplier: 1.0,
                   constant: 120.0).isActive = true

請注意上面右括號旁的isActive屬性。它是一個bool值,透過將其設為true來啟動約束條件。此外,我們將視圖的寬度設置為120.0 points。

同樣的,也來指定它的高度:

NSLayoutConstraint(item: myView,
                   attribute: .height,
                   relatedBy: .equal,
                   toItem: nil,
                   attribute: .notAnAttribute,
                   multiplier: 1.0,
                   constant: 70.0).isActive = true

現在請再次運行它,並且看裝置在兩個不同方向顯示的結果:

sample2-portrait-landscape-1

視圖中設置Subviews約束條件

Okay,只處理一個視圖的約束條件似乎是很容易,但事情在現實世界中不是那麼簡單,有包含子視圖的視圖,且這些子視圖還可以包含其他子視圖,而且它們的大小和位置都應該要被正確的設置,在大多數情況下,它們應該同時支援縱向和橫向展示,因此,讓我們多看看幾個例子,學習如何處理子視圖,但不會使用極端的範例,在這部分中,將看到如何創建以下視圖,即使它相當簡單,但確實能讓我們學習到一些東西:

t58_9_sample3_portrait

請在viewDidLoad(_:)裡面將我們前一個調用函式的程式碼註解起來,並且去拿掉complexView1()的註解,接著,請移動到這個函式的body中,準備將程式碼添加進去。

第一件事情我們要先建立黑色的容器視圖(container view),首先,請將它初始化:

func complexView1() {
    containerView1 = UIView()
    containerView1.backgroundColor = UIColor.black
    self.view.addSubview(containerView1)
    containerView1.translatesAutoresizingMaskIntoConstraints = false

}

請注意,containerView1, containerView2, containerView3, button1, button2, label1, label2ViewController類別中分別被宣告為views、buttons、labels等屬性,在下一部分會需要引用這些物件。

讓我們依據目前為止已經學習到的知識,繼續設定containerView1的約束條件,根以下是它們的配置:

NSLayoutConstraint(item: containerView1, attribute: .centerX, relatedBy: .equal, toItem: self.view, attribute: .centerX, multiplier: 1.0, constant: 0).isActive = true
NSLayoutConstraint(item: containerView1, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1.0, constant: 80.0).isActive = true
NSLayoutConstraint(item: containerView1, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 150.0).isActive = true
NSLayoutConstraint(item: containerView1, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 100.0).isActive = true

如你所見,在建立的同時也啟用了它們,所以讓我們要寫的程式碼實際上減少了。

接下來,讓我們初始化前一個螢幕截圖中顯示的第一個按鈕button1。以下程式碼沒有包含任何新的東西,只是一個標準的UIButton初始化,因此這裡不去特別談論它:

button1 = UIButton(type: .custom)
button1.backgroundColor = UIColor.orange
button1.setTitle("@1x", for: .normal)
button1.setTitleColor(.white, for: .normal)
button1.translatesAutoresizingMaskIntoConstraints = false

我猜你現在已經在想如何設置約束條件了,因為這將是我們的下一步,對吧?但其實不然,不要急,因為你會發現自己陷入一個設置約束條件的麻煩中,這裡提供一個重要的建議,請記住:在創建和啟動任何類型的視圖約束條件之前,請先確定該視圖已被添加到其父視圖。在這種情況下,意味著我們必須執行以下操作:

containerView1.addSubview(button1)

這動作相當簡單,但我們卻很容易忘記在適當的時間點添加這一行程式碼!

是時候來設定button的約束條件了,首先,如下所示,我們將它的中心設置在水平軸上:

let centerHorizontally = NSLayoutConstraint(item: button1, attribute: .centerX, relatedBy: .equal, toItem: containerView1, attribute: .centerX, multiplier: 1.0, constant: 0.0)

請注意,在此刻的參考視圖為containerView1,並非self.view,我們第一次使用另一個子視圖作為參考,所以我們可以正確設置centerX屬性,它可以是任何其他屬性,這裡我們是以一般的情況為例。

在垂直軸上,讓我們的按鈕與這個container view的頂部保持10.0 points的距離:

let top = NSLayoutConstraint(item: button1, attribute: .top, relatedBy: .equal, toItem: containerView1, attribute: .top, multiplier: 1.0, constant: 10.0)

不要忘記設置寬與高,因為它們不會從前面的約束條件中自動計算。 所以,請參考下列程式碼:

let width = NSLayoutConstraint(item: button1, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 45.0)
let height = NSLayoutConstraint(item: button1, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 22.0)

為了重新燃起先前的記憶,我們手動將上述約束條件添加至container view:

containerView1.addConstraint(centerHorizontally)
containerView1.addConstraint(top)
containerView1.addConstraint(width)
containerView1.addConstraint(height)

最後,我們來初始化button2這個按鈕:

button2 = UIButton(type: .custom)
button2.backgroundColor = UIColor.orange
button2.setTitle("@2x", for: .normal)
button2.setTitleColor(.white, for: .normal)
button2.translatesAutoresizingMaskIntoConstraints = false
containerView1.addSubview(button2)

現在,是時候替它加上約束條件了,第一件事就是設定讓button2按鈕在水平軸上置中,我們讓兩個按鈕的X中心點位置相同。

NSLayoutConstraint(item: button2, attribute: .centerX, relatedBy: .equal, toItem: button1, attribute: .centerX, multiplier: 1.0, constant: 0.0).isActive = true

同時,讓我們的按鈕與button1的底邊保持10 points的距離。

NSLayoutConstraint(item: button2, attribute: .top, relatedBy: .equal, toItem: button1, attribute: .bottom, multiplier: 1.0, constant: 10.0).isActive = true

請注意,在這裡我們將button2top屬性與button1bottom屬性相配對,這兩個按鈕是按照正確的順序(從上到下)排列,所以在運行時不會有任何問題。

針對按鈕的寬度,我們希望它是button1寬度的兩倍,就如同下列程式碼所示:

NSLayoutConstraint(item: button2, attribute: .width, relatedBy: .equal, toItem: button1, attribute: .width, multiplier: 2.0, constant: 0.0).isActive = true

在我們的範例中,首次使用參考視圖計算另一個視圖的寬度,但不只是這樣;請注意multiplier值,它被設定為2.0,表示把參考視圖的屬性值(就是button1的寬度)加倍後,可得到我們想要的結果,透過上面代碼很清楚的看到,它可以很簡單的藉由其他參考視圖的屬性,快速完成約束條件的設置工作!

同樣替我們的按鈕設置它的高度:

NSLayoutConstraint(item: button2, attribute: .height, relatedBy: .equal, toItem: button1, attribute: .height, multiplier: 1.5, constant: 0.0).isActive = true

button2高度是button1的1.5倍。一般來說,如果你改變第一個按鈕的大小或位置(button1),那麼button2也會受到影響。

現在你可以運行這個app並且看它的顯示結果。

sample3-portrait-landscape-1

為了更深入了解我們在這裡做了什麼,請試著更改約束條件,並觀察這些動作如何影響視圖。

操作更多視圖吧!

在這裡我們將向ViewController視圖控制器添加更多視圖,來對上個部分的內容做更多擴展。在完成我們的工作後,將會看到這個結果:

t58_11_sample4_portrait

首先移動至viewDidAppear(_:)裡面,將調用complexView2()函式的程式碼取消註釋(uncomment),然而,不要註釋掉對complexView1()的調用,因為container view和內部按鈕接下來將被當成設置約束條件的參考視圖,做完上述工作後,前進到complexView2()函式的body裡面。

首先,我們初始化第二個container view:

func complexView2() {
    containerView2 = UIView()
    containerView2.backgroundColor = UIColor.black
    containerView2.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(containerView2)

}

為了展示範例,第一個containerView2約束條件,我們將兩個容器視圖的側對齊,如下所示:

NSLayoutConstraint(item: containerView2, attribute: .left, relatedBy: .equal, toItem: containerView1, attribute: .left, multiplier: 1.0, constant: 0).isActive = true

換句話說,containerView1containerView2將具有相同的X原點。對於Y原點,我們將它設為低於containerView1底部25.0 points:

NSLayoutConstraint(item: containerView2, attribute: .top, relatedBy: .equal, toItem: containerView1, attribute: .bottom, multiplier: 1.0, constant: 25.0).isActive = true

至於寬度和高度,我們將它設為大於containerView1寬度和高度的1.5倍:

NSLayoutConstraint(item: containerView2, attribute: .width, relatedBy: .equal, toItem: containerView1, attribute: .width, multiplier: 1.5, constant: 0).isActive = true
NSLayoutConstraint(item: containerView2, attribute: .height, relatedBy: .equal, toItem: containerView1, attribute: .height, multiplier: 1.5, constant: 0).isActive = true

藉由設置上面的程式碼,第二個黑色視圖已正確配置在螢幕上,如上一個螢幕截圖所示。

下一步是在containerView2(螢幕截圖中的橙色)中添加另一個視圖,晚一點它也將做為我們添加label的容器。

containerView3 = UIView()
containerView3.backgroundColor = UIColor.orange
containerView3.translatesAutoresizingMaskIntoConstraints = false
containerView2.addSubview(containerView3)

我們想讓這個視圖(containerView3)與containerView2視圖有相同的中心點,所以將使用centerX以及centerY的屬性:

NSLayoutConstraint(item: containerView3, attribute: .centerX, relatedBy: .equal, toItem: containerView2, attribute: .centerX, multiplier: 1.0, constant: 0).isActive = true
NSLayoutConstraint(item: containerView3, attribute: .centerY, relatedBy: .equal, toItem: containerView2, attribute: .centerY, multiplier: 1.0, constant: 0).isActive = true

如你所見,為兩個視圖設置相同的中心點是如此簡單!但這些條件還無法讓螢幕顯示橙色視圖; 我們需要指定寬度和高度,這些動作相當簡單,其實都已經在前面的例子中看過了:

NSLayoutConstraint(item: containerView3, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 180).isActive = true
NSLayoutConstraint(item: containerView3, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: 100.0).isActive = true

第三個(也是最後一個)容器視圖已經設置完成,你現在可以在運行app時看到它了:

現在來創建label並定位它的位置,看一下這個小節開頭出現的螢幕截圖,一開始先初始化在畫面左邊的label:

label1 = UILabel()
label1.backgroundColor = UIColor.blue
label1.text = "L1"
label1.textColor = UIColor.white
label1.textAlignment = .center
label1.translatesAutoresizingMaskIntoConstraints = false
containerView3.addSubview(label1)

這個label的原點預計被設置在(20.0, 20.0),代表我們必須創建一個約束條件,將label正確地放置在水平軸上,以及另外一個約束條件將它定位到垂直軸。首先,我們將使用leading屬性,我們將其常數值設置為20.0:

let leading = NSLayoutConstraint(item: label1, attribute: .leading, relatedBy: .equal, toItem: containerView3, attribute: .leading, multiplier: 1.0, constant: 20.0)

請注意,我們不像先前那樣直接啟用constraint。下列的約束條件也一樣如此。這是有它的意義的,你很快就會明白。

在下一個約束條件中,我們將使用top屬性垂直定位label:

let top1 = NSLayoutConstraint(item: label1, attribute: .top, relatedBy: .equal, toItem: containerView3, attribute: .top, multiplier: 1.0, constant: 20.0)

這裡沒有新的東西,所以不在此多做討論。

我們將使用前面創建的另一個視圖,button1按鈕被用來指定我們label的寬度。實際上,我們想讓兩者寬度相等:

let width1 = NSLayoutConstraint(item: label1, attribute: .width, relatedBy: .equal, toItem: button1, attribute: .width, multiplier: 1.0, constant: 0)

關於高度,我們將它設定為比寬度更大1.2倍:

let height1 = NSLayoutConstraint(item: label1, attribute: .height, relatedBy: .equal, toItem: label1, attribute: .width, multiplier: 1.2, constant: 0)

很顯然的,如果修改button1的寬度,label1的大小也會被改變。

真正有趣的部分來了,我們必須添加這些約束條件到視圖上。但是哪一個視圖呢? 如果你還記得,本文先前已經提到,在初始化約束條件時,約束條件必須添加到引用視圖的第一個共同的頂層視圖(共同的superview)。透過我們逐一檢查上述約束條件,答案將更明確:

leading約束條件是在下列兩個視圖的初始化作業所設置:label1containerView3。這兩個人的共同的superview是哪一個呢?

這答案很簡單,它就是containerView2,這是我們應該添加約束條件的地方:

containerView2.addConstraint(leading)

同樣的,我們將top約束條件添加到containerView2中:

containerView2.addConstraint(top1)

我們現在來看看width1。在初始化這個約束條件時,它的引用視圖是label1和button1。然而,它們沒有相同的superview,如果在向上層結構追朔,我們將發現共同的superview是什麼?其實就是self.view

self.view.addConstraint(width1)

如果你想看上述邏輯的重要性,嘗試使用containerView2取代上面的self.view。將會看到應用崩潰,你可以去讀取返回的log,將獲得有用的訊息。

最後,讓我們看看height1的約束。在其初始化中,僅引用label1,因此,可以安全的將其添加到containerView3視圖中:

containerView3.addConstraint(height1)

以上真正有價值的是,了解約束條件如何添加到views和superviews。當然,如果你使用我們先前談到的其他兩種方法來啟用約束條件,你就不需要陷入這種麻煩,但無論如何,如果你以編程方式來設置約束條件,那麼這絕對是你應該要學習的能力。
還有一個label要添加到視圖(label2),所以讓我們在看約束條件之前先初始化它:

label2 = UILabel()
label2.backgroundColor = UIColor.blue
label2.text = "L2"
label2.textColor = UIColor.white
label2.textAlignment = .center
label2.translatesAutoresizingMaskIntoConstraints = false
containerView3.addSubview(label2)

這裡沒有什麼新的東西,所以直接來看約束條件:第一個會看到trailing constraint;它將讓label的右側和superview的右側之間保持20.0 points的距離:

let trailing = NSLayoutConstraint(item: label2, attribute: .trailing, relatedBy: .equal, toItem: containerView3, attribute: .trailing, multiplier: 1.0, constant: -20.0)

再一次提醒,注意上面約束條件中負的常數值。

根據這個部分開頭的截圖畫面,我們希望兩個label具有相同的Y原點,因此可以讓他們的top屬性相等:

let top2 = NSLayoutConstraint(item: label2, attribute: .top, relatedBy: .equal, toItem: label1, attribute: .top, multiplier: 1.0, constant: 0.0)

同樣的,我們也將label之間的寬度設為相等:

let width2 = NSLayoutConstraint(item: label2, attribute: .width, relatedBy: .equal, toItem: label1, attribute: .width, multiplier: 1.0, constant: 0.0)

關於它的高度,我們將讓label2button2一樣高。

let height2 = NSLayoutConstraint(item: label2, attribute: .height, relatedBy: .equal, toItem: button2, attribute: .height, multiplier: 1.0, constant: 0.0)

最後,我們將使用NSLayoutConstraint類的activate(...)方法,而不是手動將約束添加到正確的視圖中:

NSLayoutConstraint.activate([trailing, top2, width2, height2])

這樣一來,我們就不必在初始化約束條件時尋找視圖的共同superview。

如果你運行這個app,你可以看到裝置在兩個不同方向顯示的結果如下:

sample4-portrait-landscape-1

The Visual Format Language

Visual Format Language(VFL;可視化語言)並不真的是一個編程語言,但它是一種在視圖之間創建約束的描述性方法,以及關於螢幕上所有視覺元素的佈局規則。VFL似乎沒有我們先前看到的工具靈活/強大; 但是,開發者可以透過一個的constraint描述句,以單個字符串設置距離和大小,等於和不等式。它通常會與先前所學的方法組合使用,藉此實現最終希望的結果。我們將在這裡看到基礎知識,你練習之後將會更容易操作它。

讓我們開始吧,請移動到viewDidLoad(_:)裡面,將我們過去已經使用過的方法註解起來,並且將vflExample()調用函式的註解拿掉,接著進入到這個函式的body中,

我們要做的第一件事情,就是把接下來要使用的視圖進行初始化動作,該視圖為containerView1,它已經在這個class中被宣告過了:

func vflExample() {
    containerView1 = UIView()
    containerView1.backgroundColor = UIColor.black
    containerView1.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(containerView1)

}

現在我們第一次見到VFL,並指定視圖的寬度:

let descHorizontal = "H:[containerView1(200)]"

上述的字串被使用時,它將會設定container view的寬度為200 points,但是在語法中有一些規則,我們將開始用字符串“H:”</ strong>開頭的子串來談論它們,當它們出現時,代表約束條件僅考慮水平軸。 對於縱軸,可以使用“V:”開頭的前綴字(參見下一頁)。請注意,使用”V:”是強制添加的,而”H:”則可以省略; 系統將自動”理解”它是參考你的水平軸。

下一步是descHorizontal字符串中的[containerView1(200)]部分。一般來說,當使用語法[XXX]時,你要指定目標視圖,在我們的範例中則是containerView1。 這邊提醒一些重要事項:上面顯示的”containerView1″只是一個字符串值,所以它可以是任何東西,不一定要是範例上所使用的,目前不指向真正的視圖對象。例如,我可以設置如下,結果並不會有區別:

let descHorizontal = "H:[iLoveConstraints(200)]"

上面給的視圖名稱和實際視圖之間等會將以具體的方式進行連接。

最後,視圖名稱旁邊括號內的數字宣告width

接著重複以上動作,設定試圖的高度。

let descVertical = "V:[containerView1(150)]"

因為我們替設置束條件的字串添加“V:”前綴字,系統會收到設定150高度的指令。>

如你所見,我們沒有指定我們視圖的原點,但沒有關係,系統會自動將視圖定位到(0,0)點。有了上面兩個簡單的宣告設定,是時候嘗試讓它們運行。下一步,是讓上面給定視圖的描述性名稱和實際視圖對象之間的進行配對。因此,我們將創建一個Dictionary,如下所示:

let viewsDict = ["containerView1": containerView1]

在許多視圖的情況下,dictionary也將有多個entries。如果您忘記替某個視圖與dictionary中的key值配對,或者誤植了任何字,該應用程序就會閃退。

下一步是使用上面準備的物件,創建實際的約束條件。我們將從水平軸開始:

let horizontalConstraints = NSLayoutConstraint.constraints(withVisualFormat: descHorizontal,
                                                           options: NSLayoutFormatOptions(rawValue: 0),
                                                           metrics: nil,
                                                           views: viewsDict)

上面的方法返回一個包含約束條件的array,而非只是單一約束。這邊不需要去特別設定 optionsmetrics參數,只要關心descHorizontal字符串和viewsDict參數。我們可以避免將字符串和dictionary創建為不同的對象,就是直接提供實際數值當做參數。

現在,設定縱軸上的約束條件:

let verticalConstraints = NSLayoutConstraint.constraints(withVisualFormat: descVertical,
                                                         options: NSLayoutFormatOptions(rawValue: 0),
                                                         metrics: nil,
                                                         views: viewsDict)

當然,我們不應該忘記將它們添加到我們視圖的superview中:

self.view.addConstraints(horizontalConstraints)
self.view.addConstraints(verticalConstraints)

此刻你可以嘗試運行這個app,會看到一個黑色視圖出現在螢幕的左上方,它的大小是我們先前所指定的。

auto layout - visual format language

More On The VFL

現在我們已經學習到可視化格式語言(Visual Format Language)的基本規則,但畢竟創建位於左上角的視圖並不是那麼實用,接下來看一些更有用的東西。繼續使用相同的代碼,透過修改兩個字符串,如下所示:

let descHorizontal = "H:|-[containerView1(200)]"
let descVertical = "V:|-100-[containerView1(150)]"

<第一行中的新元素是”|-“子字符串。”|” 符號表示描述視圖的superview,當它在視圖的左側時,也代表對應到superview的左側(也可以說,我們以這種方式描述leading約束)。如果我們想要指明右側(描述trailing約束),那麼我們應該以類似於以下方式將它放在引用視圖之後:>

let descHorizontal = "H:[containerView1(200)]-|"

“-“符號通常表示兩個參考視圖之間的距離。當它如上所示使用時,系統將在我們視圖的左側和其superview的左側(leading約束的預設值)之間自動設置default距離。然而,當使用類似於descVertical字符串中的形式”-X-“時,給定的值描述兩個視圖之間的距離。 因此,換句話說,"H:|-[containerView1(200)]"意味著containerView1的X原點將由系統自動設置default間距值,而"V:|-100-[containerView1(150)]"意味著視圖的Y原點距離頂端100.0 points。運行這個app,結果如下圖所示:

visual format language

讓我們來看看另外一個範例:

let descHorizontal = "H:|-40-[containerView1]-100-|"
let descVertical = "V:[containerView1(40)]-120-|"

透過第一行程式碼,containerView1將距離superview左側40.0 points,以及右側100.0 points位置。請注意,在這種情況下,我們不用指定寬度,否則Xcode會顯示約束條件不被滿足的訊息,就像我們試圖同時設置leading, trailing和width的約束條件。在垂直軸上,我們將視圖的高度設置為40.0 points,並且使其距離螢幕底部120.0 points:

sample5-portrait-landscape-1

假設現在你不想替視圖的左右邊距離給定特定的值,而是要先計算完以後再來使用它們。為了讓程式碼簡單一些,我們不進行任何計算,而是為屬性分配幾個值:

let leftDist: CGFloat = 150.0
let rightDist: CGFloat = 25.0

接下來修改descHorizontal字符串:

let descHorizontal = "H:|-distanceLeft-[containerView1]-distanceRight-|"

類似於先前將視圖名稱與實際對象配對的工作,我們將創建一個新的dictionary來將distanceLeftdistanceRight與實際值配對:

let metrics = ["distanceLeft": leftDist, "distanceRight": rightDist]

現在讓我們更新水平約束條件,將metricsdictionary做為參數傳遞給metrics參數:

let horizontalConstraints = NSLayoutConstraint.constraints(withVisualFormat: descHorizontal,
                                                           options: NSLayoutFormatOptions(rawValue: 0),
                                                           metrics: metrics,
                                                           views: viewsDict)

上面清楚了解metrics參數是什麼,以及如何使用它。

該是時候添加另外一個視圖,請添加下列的程式碼進行初始化:

containerView2 = UIView()
containerView2.backgroundColor = UIColor.orange
containerView2.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(containerView2)

接著,更改descHorizontal約束條件的描述字串,它也包括containerView2視圖:

let descHorizontal = "H:|-40-[containerView1(150)]-35-[containerView2]-|"

上面程式碼顯示containerView2的X原點應該在containerView1右方距離35.0 points的位子。記住,沒有必要只用一個描述字符串中設定視圖的約束條件,它可以有多個。例如,對於containerView1視圖的垂直約束如下:

let descVertical = "V:[containerView1(85)]-200-|"

上面代碼將視圖高度設置為85.0 points,並將它放在距離superview底部200.0 points的位置。現在,我們將替containerView2創建另一個字符串來指定垂直軸上的約束條件:

let descVertical2 = "V:[containerView2(==containerView1)]"

請注意上方有出現新的元素:我們將高度設為等於containerView1視圖的高度。這意味著這個視圖將取決於第一個視圖的高度,如果那個視圖改變,這邊也將會連動改變。一般來說,你可以使用以下符號來定義有關視圖大小和位置這兩個值之間的關係: ==(equal),>=(大於),<=(小於)

在查看上面程式碼的運行成果前,還有兩件事要做:首先,更新viewsDict的dictionary,使其包含containerView2視圖:

let viewsDict = ["containerView1": containerView1, "containerView2": containerView2]

其次,根據descVertical2描述創建一個新的約束集合:

let verticalConstraints2 = NSLayoutConstraint.constraints(withVisualFormat: descVertical2,
                                                          options: NSLayoutFormatOptions(rawValue: 0),
                                                          metrics: nil,
                                                          views: viewsDict)

不要忘記向superview添加約束條件:

self.view.addConstraints(verticalConstraints2)

以下是運行應用程序的結果:

到目前為止,我們沒有使用constraints(...)方法的options參數值。它用來設定描述字符串上引用視圖間設置對齊的方式,有幾個選項可以供開發者使用。但請注意,根據你所做的約束配置,並非所有對齊選項實際上都能運作。回到我們的範例中,讓我們再次更新程式碼,嘗試在horizontalConstraints初始化過程中對齊視圖的bottom:

let horizontalConstraints = NSLayoutConstraint.constraints(withVisualFormat: descHorizontal,
                                                           options: .alignAllBottom,
                                                           metrics: nil,
                                                           views: viewsDict)

所有對齊選項都是NSLayoutFormatOptions結構的常量,訪問這個連結即可查看所有對齊選項。

再次運行這個app,查看底部對齊方式如何影響視圖的呈現:

總結

如果你也是偏好透過程式碼建置使用者介面的開發者,那知道如何處理代碼中的約束條件是必須具備的能力,在我看來,使用本篇教程的第一部分中學習到的編程方法來創建和設置約束條件,將會成為開發者手中擁有的最強大的工具,Visual Format Language雖然是個很好的選擇,但是如果你在很複雜的UI介面使用它,將會意識到無法只透過它滿足所有約束條件的設置需求,在多數的案例中,當我們使用VFL,通常會需要同時配合編程方法才能滿足最終需求,但是在本文中並未示範這類型的案例,讀者有興趣可以練習實作看看,在任何情況下,選擇哪個方法來設置項目的約束條件最終取決於開發者個人偏好以及專案的需求。本篇教程在此已經到了尾聲,希望你有足夠的動力,開始處理程式碼中的約束條件。Have fun!

你可以在GitHub下載完整的專案

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校ALPHA Camp畢業後,積極投入iOS程式開發,目前任職於國內電商公司。聯絡方式:電郵[email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文Working with Auto Layout Visual Format Language and Programmatically Creating Constraints

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
更多來自 AppCoda 中文版
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。