構建 app 時使用的自動佈局技術,其實就是建立檢視與檢視之間關係。而約束是建立檢視間關係的紐帶,幫助我們的 app 可以適應各種尺寸的螢幕,在應對花樣百出的佈局需求時遊刃有餘。
本文已收錄至 iOS 成長之路3期·WWDC17內參
前言
如果你以前從未使用過Autolayout
,現在網上已經有很多很優秀的教程,包括往屆 WWDC 中 sessions 視訊資源都可供檢視學習。在本文中將不再重複基本的使用方法,更多的去介紹一些更加複雜的場景中的應用,本文中技術結合例項使你更容易理解吸收。讓我們一起來看看與Autolayout
相關的六種技術與應用,這些內容都非常實用,在日常開發中一定會經常使用到,相信本文一定不會讓你失望。
1. 執行時變換佈局(Changing layout at runtime)
通常我們不僅可以在 app 中使用約束來對檢視進行簡單的定位,也可以組合使用以達到更復雜的效果。我們今天要講的第一種技術點,便在執行時改變佈局。如下圖,在我們介面的頂部,有一個滑塊區域。現在我們需要一個將滑塊檢視上移並且最終隱藏的功能。
1.1 利用高度約束隱藏檢視
通常我們希望約束在設定好之後不需要再次調整,儘量讓結構清晰簡單。現在我們來思考一下,從佈局的角度使用最簡單的方式實現這個功能,一般情況下,我們把這個區域檢視高度縮短至0即可。但是如果我們真的新增上一個高度約束,並且設定為0。我們將在 Interface Builder 中發現一些警告。
1.2 避免衝突
在圖片中可以看到佈局中的這些紅線,這意味著我們設定的約束存在著一些衝突。之所以出現衝突,是因為我們設定的這些約束讓佈局引擎去做了一些不能同時並存的事情。而這個衝突出現是因為我們設定了高度為0的同時,無法保持足夠的高度以滿足該控制元件內部的內容顯示。
為了解決這個問題,我們將 slider 和 label 所在的檢視放進一個warppingView
中,如圖中橙色方框。在我們縮短warppingView
的高度時,我們也要保證warppingView
內部子檢視的高度,並且滿足子檢視相關的約束,在啟用 clips ToBounds 屬性後,超出warppingView
內部座標系範圍的內部控制元件在顯示時將被裁剪掉。這樣就達到了隱藏檢視元素的效果,如下圖效果,灰色區域將被裁剪不顯示。
讓我們來看看在 Xcode 中是如何做到的,我們需要在執行時控制warppingView
的高度,所以我們將為warppingView
手動建立一個高度約束zeroHeightConstraint
,在執行時設定zeroHeightConstraint
為0,並且在使用者點選 Edit 按鈕時,啟用該約束。這樣我們仍然會和之前一樣出現衝突的情況,我們需要將滑塊區域檢視底部到warppingView
底部邊緣的約束禁用,避免了約束衝突,這樣warppingView
就可以正常縮短高度了。
1.3 實現程式碼
接下來看看完整程式碼,在我們控制器的子類中,我們持有3個屬性:
warppingView
:外部容器檢視edgeConstraint
:底部邊緣的約束zeroHeightConstraint
:一個儲存0高度約束的屬性
@IBOutlet var warppingView: UIView!
@IBOutlet var edgeConstraint: NSLayoutConstraint!
var zeroHeightConstraint : NSLayoutConstraint!
複製程式碼
我們建立了按鈕點選事件,在響應按鈕事件函式中,我們首先要保證zeroHeightConstraint
已被建立。接著我們還希望這一個事件讓檢視可以在顯示和隱藏間切換,所以我們要對一些約束做禁用和啟用操作,做完這些就會得到我們想要的切換效果。
@IBAction func toggleDistanceControls(_ sender: Any) {
if zeroHeightConstraint == nil {
zeroHeightConstraint = warppingView.heightAnchor.constraint(equalToConstant: 0)
}
let shouldShow = !edgeConstraint.isActive
if shouldShow {
zeroHeightConstraint.isActive = false
edgeConstraint.isActive = true
}else{
edgeConstraint.isActive = false
zeroHeightConstraint.isActive = true
}
}
複製程式碼
需要特別注意的是,在啟用一個約束前務必先禁用另外一個約束。在這些簡單的切換禁用和啟用程式碼,遵守這一點讓我們避免了衝突,如果約束中一旦存在衝突,控制檯就會提醒我們:嘿,我檢測到這些約束是互相沖突的?。例如,我們啟用了zeroHeightConstraint
約束,而底部約束edgeConstraint
還未被禁用,這個時候我們就會看到控制檯列印出衝突資訊。
1.4 加入動畫
加入這些程式碼重新執行後你會發現我們的介面正確顯示和隱藏了,但是我還想為這個過程加上動畫,讓使用者可以看到檢視切換過程能夠提高使用者體驗。在這裡我們使用UIView animation block
來實現動畫,UIView animation
將捕捉並且動畫化整個過程。
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
複製程式碼
這裡得到的動畫效果,也並不是我想要的最終效果,我們還需要做最後一點調整,但是這不需要修改我們的程式碼,我們只需要將底部邊緣的約束:edgeConstraint
屬性更換成連線到頂部邊緣的約束,改成一個底部對齊的效果。整個動畫效果發生改變,我確認這就是我需要的最終效果。
具體效果可以檢視我們的Demo(非蘋果官方),通過上面這些內容我們可以知道,怎樣通過執行時改變約束來動態調整我們 app 中的佈局。
2. 跟蹤觸控手勢(Tracking touch)
現在我們來看看改變佈局的另一種方法,我保證它既簡單又炫酷。我們將用它來跟蹤觸控手勢。我們在我們下圖的 app 的中央區域有一張卡片,我們希望卡片能隨著觸控手勢移動,隨著靠近邊緣的時候,會有一些旋轉,一旦你的手離開螢幕,卡片就會彈回螢幕中間。
2.1 frame 飲水知源
通常一個控制元件在螢幕上的位置由它的 frame 決定,而 frame 又源起何處呢?
- Layout engine owns frame。當我們使用
Autolayout
並使用約束控制此檢視時,佈局引擎將會持有此檢視的 frame 。- Value derived from constraints。frame 的值是從這些約束中計算出來的。
- transform property offsets from frame。還有另一個屬性會影響檢視在螢幕上的位置,那就是 transform ,在 transform 屬性源起於 frame 。
- CGAffineTransform = translation + rotation + scale。通過
CGAffineTransform
,它可以幫助我們為檢視加入平移 ,旋轉和縮放等變換,在從約束中計算出 frame 之後,將其應用在 transform 中。
2.2 加入監聽手勢
再回到需求上,如果我們想要中間的卡片隨著我的手勢移動,那我們就要加入一個手勢識別器,並且拖線連線到程式碼中,新增監聽手勢的方法,在該方法中我們可以訪問手勢識別器的各種屬性。此外還將我們要移動的卡片也通過拖線建立了屬性。
@IBOutlet weak var cardView: UIImageView!
@IBAction func panCard(_ sender: UIPanGestureRecognizer) {}
複製程式碼
2.3 加入位移和旋轉
接下來我們要通過手勢識別器監聽使用者手勢移動,得到位移結果後轉換成 transform 應用在cardView
上。在這裡有transform
函式幫我們進行了位移和輕微的旋轉,這個時候,卡片將會隨著你的手指移動伴隨著輕微的旋轉。
func transform(for translation: CGPoint) -> CGAffineTransform {
let moveBy = CGAffineTransform(translationX:translation.x, y: translation.y)
let rotation = -sin(translation.x/(cardView.frame.width * 4.0))
return moveBy.rotated(by: rotation)
}
複製程式碼
2.4 位置還原
但是當我放開手指時,卡片停留在原位,沒有回到螢幕中央,因為我們並沒有去重置卡片的 transform 屬性。當我再次觸控並移動,我們會看到它會回到原來的位置,這是因為我們開始了一個新的位移,新的位移關聯的原來的 frame 。總之這不是我想要的效果,我希望在使用者手指離開螢幕後,卡片能夠立即回到螢幕中間的位置。我們可以通過手勢識別器的狀態來做到這一點。我們在state
為end
的時候,將重置 transform 並且加入彈簧動畫。加入這部分程式碼執行 app ,在我鬆開卡片後它會彈回中間的位置。
@IBAction func panCard(_ sender: UIPanGestureRecognizer) {
switch sender.state {
case .changed:
let translation = sender.translation(in: view)
cardView.transform = transform(for: translation)
case .ended:
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4,
initialSpringVelocity: 1.0, options: [], animations: {
self.cardView.transform = .identity
}, completion: nil)
default:
break;
}
}
複製程式碼
簡單的幾行程式碼,實現了一個很有意思的互動效果,在這些內容裡面,我們可以看到 frame 它不僅是通過約束來計算出,也會受到 transform 的影響,檢視的 frame 中蘊含多種屬性的組合效果。
3. 動態字型(Dynamic type)
Dynamic type
是 iOS 中提供了一組文字樣式,文字樣式包含了標題、副標題、正文等樣式,而且使用者可以控制這些樣式字型大小的技術。在 iPhone 的簡訊訊息中,如果使用者喜歡大一點的字型,通過在設定中進行設定後,我們將看到訊息介面會有所變化,字型變大了,訊息氣泡和輸入文字也變大了。日曆和其他一些地方也具備類似的功能。
相信這個時候你一定會好奇,如何才能在我們自己的 app 實現這個功能呢?另外在調整字型大小時,如果不相應地調整我們的佈局,容易造成檢視重疊,對使用者體驗來說是非常不好的。幸運的是,Autolayout
可以很輕鬆地幫我們搞定這個問題。
3.1 支援 Dynamic Type
所以趕緊讓我們來看看是怎麼實現的吧,開啟 IB 介面,選中你要支援 Dynamic Type 的 label ,檢視 label 的屬性,勾選automatically adjust font
,如果你眼睛夠敏銳的話,你能看到上面出現了一個警告,原因是因為automatically adjust font
屬性生效,該屬性要求 label 設定指定的文字樣式。這裡要將系統預設字型更換為caption one
,該樣式和預設字型12號大小相對應。
3.2 通過 Accessibility Inspector 改變字型大小
設定好這些再重新執行,你會發現和之前並沒有什麼不同,這是因為我們還沒有改變文字樣式的大小,我們可以在設定中調整字型大小,但是這種方式需要來回切換不夠直觀,所有我們用另外一種方法,點選頂部導航條Xcode
->Open Develop Tool
->Accessibility Inspector
->target
切換至模擬器->選擇設定標籤,就可以看到修改字型大小的滑塊了。這個時候滑動滑塊就能看到我們的 label 字型在實時地改變。如果我們連線了 iPhone ,我們也可以將target
切換至我們的 iPhone 。
3.3 根據字型大小動態調整佈局
在下圖中可以看到,如果我們把字型調整到非常大的時候,我們的 label 就會發生重疊,接下來我們要解決這個問題。
首先我們建立了一個文字區域,這個文字區域會隨著字型變大而增高,所以我們只要將底部 label 被限制在底部,頂部 label 被限制在頂部,再在兩者之間新增了一個垂直間距約束,使兩個標籤始終保持足夠垂直間距,避免使用固定高度約束,這樣 label 的高度會隨著字型變大而增高,接著 label 又會將文字區域給撐高。這個時候可以開啟Accessibility Inspector
來測試改變我們 app 的字型大小,你會發現我們的文字區域會隨著字型的變化而改變高度。
在這中間我們並不需要做很多處理,就能實現這樣一個非常實用的功能,特別是當你有一些需要讀者閱讀文字的的需求 ,相信 Dynamic Type 能夠幫助你,讓你的 app 更加強大。
4. 安全區(Safe area)
接下來要介紹的內容在之後你可能會頻繁使用到,所以一定要搬好小板凳認真看。當你新建了一個控制器,控制器有一個導航條和一個底部標籤欄,如何保證你的內容主體不被導航條和標籤欄遮擋?可能你已經聽說過在 iOS 11 上有了新的 layout guide ,稱之為Safe Area Layout Guide
。
4.1 Safe Area Layout Guide 更易使用
這是UIView
的新特性,它適用於自動佈局,它是夾在導航條和標籤欄之間的一個矩形,在這個矩形區域中你可以放心地為你的檢視新增約束。在這之前,你可能不得不使用UIViewController
的Top Layer Guide
和Bottom Layer Guide
,現在在Safe Area
中這些都已經通通被丟棄了。
Safe Area Layout Guide
使用起來更加簡單,也更容易理解,如同字面意思,可以安全的讓你的檢視安全地呆在導航條和標籤欄中間,不被遮擋,不管是尺寸的變化和螢幕旋轉,它都會自動做相應地調整。
4.2 如何使用 Safe Area Layout Guide
Safe Area
也適用在 tvOS 上。如果要將你的 app 和內容放到 tvOS 上,你可能會遇到各種各樣的尺寸的螢幕,在某些情況如下圖,我們頂部的標題太靠近頂部邊緣,可能因此被遮擋掉一部分。
這個時候我們要調整我們的內容,讓它處於Safe Area
之中。Safe Area
代表storyboard
中的這塊淺綠色的區域,你只要將你檢視中的約束設定到Safe Area
中,那它就安全了。
然後用一張的美麗背景影像填充剩餘檢視空間,媽媽在也不用擔心我們的內容被導航條和標籤欄擋住了。如下圖,它們只會聽話地呆在深色矩形方框內。
4.3 開啟 Safe Area Layout Guide
開啟Safe Area Layout Guide
也十分簡單,開啟我們的storyboard
,進入 file inspector 標籤頁,然後找到Use Safe Area Layout Guides
並且勾選上。你會發現每個控制器中都會出現一個Safe Area
檢視,然後你就可以像其他檢視一樣,將約束連向它。
Safe Area Layout Guide
是 UIView 的新特性,在之前的版本中頂部和底部的Layout Guides
之間的矩形區域將與新的Safe Area
相匹配,他們可以互相轉換,如果在 iOS 11 的故事板中啟用Safe Area
,在你選中afe Area Layout Guides
勾選框時 Xcode 將會自動升級你的約束。總而言之,在 Xcode 9 的故事板中使用Safe Area Layout Guide
,將向下相容 iOS 老版本的。
5. 比例定位(Proportional positioning)
接下來我們要談一談,關於如何將一個檢視定位在其 superview 的佈局技術,我們將其稱之為 Proportional positioning ,即按 比例定位 。在安卓的佈局技術中也有類似的功能,它的應用面廣泛且實用,相信未來的開發中一定會頻繁使用到。
5.1 比例佈局
假設現在我有一個需求,要將我們 app 中的卡片高度定位在其 superview 高度的70%。也許你會有幾種方式可以實現上述需求,但是現在我要用一個最直接的方式來實現它,便是我現在要介紹得的使用 spacerview 的方法。
從物件庫拖出一個檢視,只是一個普通的UIView
。為它新增約束後設定隱藏,這樣就不會渲染它,讓它做一個安靜的美男子,這樣它就成為你需要定位的檢視的參照物。而且這種技術也可以組合使用,靈活搭配,這裡有另一個例子,我有一個場景,要遵守1/5,2/5和2/5的比例,然後他們以這些比例填充滿整個螢幕。下面讓我們來看看如何做到的。
5.2 構建 SpacerView
如下圖中我們已經有一個基本的佈局。我有一個 label 和一個 image ,已經新增了基本約束。 當我選中它們,如果你仔細看,你會注意到它左邊和右邊的約束是藍色的,但頂部和底部是紅色的。這意味著我們還需要新增一些約束來定位。無論何時在 Interface Builder 畫布中看到紅色,那隻可能是兩種情況,要麼你的約束太少,位置是不確定的,或者設定了太多的約束,其中一部分是衝突的。
我知道是因為我沒有對垂直方向位置進行固定。所以接下來我們要通過建立 spacerview 來實現。拖一個UIView
出來。首先我們將其隱藏,這樣不會浪費效能進行繪製,我們為其新增好上方、左側以及寬度的約束後,我們還沒有設定其高度約束,我們要為其設定等高約束,使其高度與 superview 高度相,如下圖效果。
接著讓我們檢視等高約束的屬性,修改比例為70%,設定成功後你會發現spacerview
的高度已經縮減了,下一步設定 Second Item 即比例參考物件檢視為 Safe Area
,這樣我們的spacerview
就已經設定好了。
5.3 對齊到 Baseline
現在我們要將我們卡片檢視底部與spacerview
底部對齊,所以我們新增了底部對齊的約束,如果我想要是spacerview
與我們的卡片文案的baseline
對齊怎麼辦?選中約束後轉到屬性檢查器,選擇 FirstItem 選項,選中First Baseline
即可。
重新執行後得到了我想要的效果。
所以當你需要在 Interface Builder 中使用這個比例定位技術時,使用spacerview
能夠幫助你達到期望。但是一定要將這些檢視標記為隱藏,使它們不會被渲染,但又能協助你進行佈局,使你能夠定位你的內容。如果你是用程式設計方式進行佈局,可以使用UILayout Guide
來完成,你可以將其用作等效於spacerviews
。
6. Stack view 自適應佈局(Stack view adaptive layout)
讓我們一起來看看最後一種我們要佈局的檢視,如下圖,你能看到 app 中展示了一個自適應佈局的頁面,上方是一個4x4的網格排列,底部有一個 label 。
當我旋轉手機的時候,出現了一些不一樣的東西。它仍然會顯示一個4x4的網格,但它有一個文字檢視出現在右邊的位置。
6.1 豎屏佈局
這一切是怎麼做到的呢?讓我們來看看如何對 Interface Builder 中的 stackview 進行自適應佈局。首先看下圖中最外層是一個垂直的 stackview ,從上到下分成三行,第一行和第二行都是包含兩張圖片的水平 stackview ,第三行是一個 label 。就如你看到的,他們高度是相等的,我們可以通過 Alignment,Distribution,和 Spacing 等屬性來進行調整,以達到你想要的佈局。 stackview 有一個非常讚的地方,就是它能幫你管理被包含的檢視的約束,這樣你只要新增很少的約束。
接下來讓我們選中所有的 stackview ,在Distribution
選項中選擇fill equally
實現平均分佈,在Spacing
選項中我們可以手動輸入我們想要的間距,另外系統也提供了標準間距選項給我們,點選輸入框右邊的倒三角就會出現一個Use Standard Value
選項,直接選中即可。
下一步我要確保這些影像是正方形的,我們直接選中第一張圖片,為其新增一個寬高比為1:1的約束,新增後你會發現出現一些衝突,這是因為在滿足填充滿整個螢幕和三行平均分佈的同時,無法保證圖片比例達到1:1。
所以我們要做一些改變,我們將 stackview 到底部的固定約束脩改成大於等於,這樣出現的衝突就解決了,也達到了我想要的效果。
6.2 橫屏佈局
當我們將裝置旋轉到橫屏狀態,我們預期的效果是在右邊有一個 textview ,而底部並沒有 label ,為了更接近我們預期效果我們需要把底部的 label 先隱藏。我們要如何才能做到在豎屏中顯示,在橫屏中隱藏呢?
在全新的 Xcode9 中的隱藏屬性,可以為不同size class
分別設定顯示或隱藏。轉到 label 的hidden
屬性,你會發現勾選按鈕左邊有一個加號,它讓這一切變得輕鬆簡單。點選後在彈出的介面中Width
選擇any
,在橫屏的時候,Height
選擇compact
,因為在橫屏的時候它的高度是緊湊的。
做完這些點選add variation
,並且在hidden
屬性下面找到剛剛設定的隱藏屬性並且勾選中它,你會發現 label 被隱藏了,如果你切換成豎屏,又會顯示出來。
接下來繼續新增一個 textview ,為了做到這點,我們要在最外層套一個水平排列的 stackview ,然後將 textview 加入 stackview 中,並且為新建的 stackview 新增約束,其中底部約束就如同之前設定為大於等於。這樣就得到了我們橫屏中需要的效果。
當我們切換到豎屏時 textview 仍然顯示了,我們要在豎屏時隱藏它,就像之前一樣轉到隱藏選單,並新增一個變數,Width
選擇any
,Height
選擇Regular
,然後將其標記為隱藏,這樣在豎屏時, textview 就不再顯示了,就此達到了我們期望的效果。
在使用 stackview 的時候,我們可以使用Alignment
,Distribution
,Spacing
這些屬性幫助我們佈局。還有巢狀使用,只需要加入很少的約束,我們僅僅需要在你對寬高比例有要求的時候,通過寬高比例約束來獲得我們想要的比例。令人驚喜的是,Xcode 9 中的隱藏屬性是可分級的,它非常適合與 stackview 搭配使用,而且隨著size class
的變化的隱藏屬性是向下相容的。
總結
到這裡我們已經看完了Autolayout
相關的的六種技術,在你構建 app 的時候有了更多的佈局手段,這些技術能夠使你的介面看起來非常美觀,結構清晰,並且自適應佈局,在日常開發中會經常使用到。我已經迫不及待地想看到更多人使用這些技術了。
Demo
GitHub:FindMyDates