前言
SwiftUI 的各種堆棧是許多框架中最基本的布局工具,能夠讓我們定義組視圖,這些組視圖可以按照水平、垂直或覆蓋視圖對(duì)齊。
當(dāng)涉及到水平和垂直的變體時(shí)( HStack 和 VStack ),我們需要在這兩者之間動(dòng)態(tài)的切換。舉個(gè)例子,假如我們正在構(gòu)建一個(gè) app 其中包含 LoginActionsView ,一個(gè)讓用戶登錄時(shí)在列表中選擇操作的類:
struct LoginActionsView: View {
...
var body: some View {
VStack {
Button("Login") { ... }
Button("Reset password") { ... }
Button("Create account") { ... }
}
.buttonStyle(ActionButtonStyle())
}
}
struct ActionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.fixedSize()
.frame(maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(10)
}
}
以上代碼中,我們用到了
fixedSize防止按鈕文本被截?cái)?,這僅是在我們確信給定的內(nèi)容視圖不會(huì)比視圖本身更大的情況。想了解更多信息,可以查看我的文章 - SwiftUI 布局系統(tǒng)第三章
目前,我們的按鈕是垂直排列的,并且填滿了水平線上的可用空間(你可以用以上示例代碼預(yù)覽按鈕的樣子),雖然這在豎向的 iPhone 上看起來很好,但假設(shè)我們現(xiàn)在想要在橫向模式下讓 UI 橫向排列。
GeometryReader 能實(shí)現(xiàn)嗎?
一種方式是用 GeometryReader 測量當(dāng)前可用空間,并根據(jù)寬度是否大于其高度,可以選擇使用 HStack 或 VStack 來渲染內(nèi)容。
雖然可以在 LoginActionsView 中放入該邏輯,但我們希望以后能復(fù)用代碼,因此需要重新創(chuàng)建一個(gè)專門的視圖,作為一個(gè)獨(dú)立的組件來實(shí)現(xiàn)動(dòng)態(tài)堆棧的切換邏輯。
為了使代碼可用性更高,我們不會(huì)硬編碼讓兩個(gè)堆棧變體使用對(duì)齊或間距什么的。相反,讓我們像 SwiftUI 一樣,對(duì)這些屬性參數(shù)化,同時(shí)設(shè)定框架所使用的默認(rèn)值 — 就像這樣:
struct DynamicStack<Content: View>: View {
var horizontalAlignment = HorizontalAlignment.center
var verticalAlignment = VerticalAlignment.center
var spacing: CGFloat?
@ViewBuilder var content: () -> Content
var body: some View {
GeometryReader { proxy in
Group {
if proxy.size.width > proxy.size.height {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
} else {
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
}
}
}
由于我們使新的 DynamicStack 使用了與 HStack 和 VStack 相同的 API ,現(xiàn)在可以在 LoginActionsView 中直接將以前的 VStack 換成新的自定義的實(shí)例:
struct LoginActionsView: View {
...
var body: some View {
DynamicStack {
Button("Login") { ... }
Button("Reset password") { ... }
Button("Create account") { ... }
}
.buttonStyle(ActionButtonStyle())
}
}
優(yōu)秀!然而,就像上面的代碼展示的那樣,使用 GeometeryReader 來展示動(dòng)態(tài)切換有一個(gè)相當(dāng)明顯的缺點(diǎn),在幾何圖形閱讀器中總是會(huì)填充水平和垂直方向的所有可用空間(以便測量實(shí)際空間)。在我們的例子中,LoginActionsView 不再只是水平方向的排列,它現(xiàn)在也能移動(dòng)到屏幕的頂部。
雖然我們也有很多方法能解決這些問題(例如使用類似在這篇 Q&A 中用來使多個(gè)視圖具有相同寬度和高度的技術(shù)),但真正的問題是當(dāng)我們要?jiǎng)討B(tài)的確定方向時(shí),測量可用空間是否是一個(gè)好的方法。
一個(gè)使用尺寸類的例子
相反,讓我們使用 Apple 的尺寸類系統(tǒng)來決定 DynamicStack 應(yīng)該在底層使用 HStack 還是 VStack 。這樣做的好處不僅僅是在引入 GeometeryReader 之前保留同樣緊湊的布局,并且會(huì)使 DynamicStack 在開始的時(shí)候以一種和系統(tǒng)組件類似的方式在所有設(shè)備和方向上構(gòu)建。
為了觀察當(dāng)前水平方向的尺寸,我們需要用到 SwiftUI 環(huán)境系統(tǒng) — 通過在 DynamicStack 中聲明 @Environment - 標(biāo)記屬性(帶有 horizontalSizeClass 關(guān)鍵路徑),將會(huì)使我們?cè)谝晥D內(nèi)容中切換到當(dāng)前 sizeClass 的值:
struct DynamicStack<Content: View>: View {
...
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
switch sizeClass {
case .regular:
hStack
case .compact, .none:
vStack
@unknown default:
vStack
}
}
}
private extension DynamicStack {
var hStack: some View {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
}
var vStack: some View {
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
經(jīng)過以上操作,LoginActionsView 將可以在常規(guī)的尺寸渲染時(shí)動(dòng)態(tài)切換成水平布局(例如在大尺寸的 iPhone 使用橫屏,或者全屏 iPad 上的任一方向),而其它所有尺寸的配置使用垂直布局。所有這些仍然使用緊湊垂直布局,它使用的空間不超過渲染其內(nèi)容所需的空間。
使用布局協(xié)議
雖然我們最后已經(jīng)用了非常棒的解決方案,可以在所有支持 SwiftUI 的 iOS 版本中使用,但也讓我們來探索一下在 iOS 16 中引入的一些新的布局工具(在寫這篇文章時(shí),它作為 Xcode 14 的一部分仍在測試階段)
其中一個(gè)工具是新的 Layout 協(xié)議,它既能讓我們創(chuàng)建完整的自定義布局,直接集成到 SwiftUI 的布局系統(tǒng)中,同時(shí)也提供給我們一種更絲滑更動(dòng)畫的方式在各種布局之間動(dòng)態(tài)切換 。
這都是因?yàn)槭聦?shí)證明 Layout 不僅僅是我們第三方開發(fā)者的 API ,Apple 也讓 SwiftUI 自己的布局容器使用這個(gè)新協(xié)議 。所以,與其直接使用 HStack 和 VStack 作為容器視圖,不如將它們作為符合 Layout 的實(shí)例,使用 AnyLayout 類型進(jìn)行包裝 — 就像這樣:
private extension DynamicStack {
var currentLayout: AnyLayout {
switch sizeClass {
case .regular, .none:
return horizontalLayout
case .compact:
return verticalLayout
@unknown default:
return verticalLayout
}
}
var horizontalLayout: AnyLayout {
AnyLayout(HStack(
alignment: verticalAlignment,
spacing: spacing
))
}
var verticalLayout: AnyLayout {
AnyLayout(VStack(
alignment: horizontalAlignment,
spacing: spacing
))
}
}
以上的操作是可行的,因?yàn)楫?dāng) HStack 和 VStack 的內(nèi)容類型是 EmptyView 時(shí),它們都符合新的 Layout 協(xié)議(當(dāng)內(nèi)容為空時(shí)就是這種情況),讓我們來看一下SwiftUI 的 公共接口
struct DynamicStack<Content: View>: View {
...
var body: some View {
currentLayout(content)
}
}
注意:由于回歸,
Xcode 14 beta 3中省略了以上條件的一致性,根據(jù)SwiftUI團(tuán)隊(duì)的 Matt Ricketson 的說法,可以直接使用底層的_HStackLayout和_VStackLayout類型作為臨時(shí)的解決方法。并希望能在未來測試版本中修復(fù)。
現(xiàn)在我們能通過使用新的 currentLayout 解決使用什么布局,現(xiàn)在我們來更新 body 的實(shí)現(xiàn),簡單調(diào)用從該屬性返回的 AnyLayout ,就像函數(shù)一樣 — 像這樣:
struct DynamicStack<Content: View>: View {
...
var body: some View {
currentLayout(content)
}
}
我們之所以能像一個(gè)函數(shù)一樣調(diào)用布局方法(盡管它實(shí)際上是一個(gè)結(jié)構(gòu))是因?yàn)?
Layout協(xié)議使用了Swift”像函數(shù)一樣調(diào)用“ 的特性
那么我們之前的方案和上面基于布局的方案有什么區(qū)別呢?關(guān)鍵的區(qū)別在于(除了后者需要 iOS 16 )切換布局可以保留正在渲染的底層視圖的標(biāo)識(shí),而在 HStack 和 VStack 之間切換就不會(huì)這樣。這樣做會(huì)令動(dòng)畫更流暢,例如在切換設(shè)備方向時(shí),我們也有可能在執(zhí)行此類更改時(shí)獲得小幅的性能提升(因?yàn)?SwiftUI 總是在其視圖層次結(jié)構(gòu)為靜態(tài)時(shí)盡可能表現(xiàn)最佳)
選擇合適的視圖
但我們還沒有結(jié)束,因?yàn)?iOS 16 也給了我們其他有趣的新的布局工具,它有可能也能用于實(shí)現(xiàn) DynamicStack — 一種全新的視圖類型,名字叫做 ViewThatFits 。就像字面意思一樣,這種新的容器將會(huì)在我們初始化時(shí)傳遞的候選列表中,基于當(dāng)前上下文挑選出最優(yōu)視圖。
在我們的例子中,這意味著我們能同時(shí)把 HStack 和 VStack 傳遞給它,并且代表我們?cè)谒鼈冎虚g自動(dòng)切換。
struct DynamicStack<Content: View>: View {
...
var body: some View {
ViewThatFits {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
}
注意:在這種情況下,我們首先放置 HStack 是很重要的,因?yàn)?VStack 可能總是合適的,即使在我們希望布局是橫向的情況下(例如 iPad 的全屏模式)。同樣重要的是要指出,上述基于 ViewThatFits 的技術(shù)將會(huì)始終嘗試 HStack ,即使在用緊湊尺寸渲染布局時(shí)也是如此,只有在 HStack 不適合時(shí)才會(huì)選擇基于VStack 的布局。
結(jié)語
以上就是通過四種不同的方式實(shí)現(xiàn) DynamicStack 視圖,它可以根據(jù)當(dāng)前內(nèi)容在 HStack 和 VStack 之間動(dòng)態(tài)切換。
關(guān)于我們
我們是由 Swift 愛好者共同維護(hù),我們會(huì)分享以 Swift 實(shí)戰(zhàn)、SwiftUI、Swift 基礎(chǔ)為核心的技術(shù)內(nèi)容,也整理收集優(yōu)秀的學(xué)習(xí)資料。
后續(xù)還會(huì)翻譯大量資料到我們公眾號(hào),有感興趣的朋友,可以加入我們。