SwiftUI 之 HStack 和 VStack 的切換
持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第7天,點選檢視活動詳情
前言
SwiftUI
的各種堆疊是許多框架中最基本的佈局工具,能夠讓我們定義組檢視,這些組檢視可以按照水平、垂直或覆蓋檢視對齊。
當涉及到水平和垂直的變體時( HStack
和 VStack
),我們需要在這兩者之間動態的切換。舉個例子,假如我們正在構建一個 app
其中包含 LoginActionsView
,一個讓使用者登入時在列表中選擇操作的類:
```swift 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
防止按鈕文字被截斷,這僅是在我們確信給定的內容檢視不會比檢視本身更大的情況。想了解更多資訊,可以檢視我的文章 - SwiftUI 佈局系統第三章
目前,我們的按鈕是垂直排列的,並且填滿了水平線上的可用空間(你可以用以上示例程式碼預覽按鈕的樣子),雖然這在豎向的 iPhone 上看起來很好,但假設我們現在想要在橫向模式下讓 UI
橫向排列。
GeometryReader 能實現嗎?
一種方式是用 GeometryReader
測量當前可用空間,並根據寬度是否大於其高度,可以選擇使用 HStack
或 VStack
來渲染內容。
雖然可以在 LoginActionsView
中放入該邏輯,但我們希望以後能複用程式碼,因此需要重新建立一個專門的檢視,作為一個獨立的元件來實現動態堆疊的切換邏輯。
為了使程式碼可用性更高,我們不會硬編碼讓兩個堆疊變體使用對齊或間距什麼的。相反,讓我們像 SwiftUI
一樣,對這些屬性引數化,同時設定框架所使用的預設值 — 就像這樣:
```swift
struct DynamicStack
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
,現在可以在 LoginActionsView
中直接將以前的 VStack
換成新的自定義的例項:
```swift struct LoginActionsView: View { ...
var body: some View {
DynamicStack {
Button("Login") { ... }
Button("Reset password") { ... }
Button("Create account") { ... }
}
.buttonStyle(ActionButtonStyle())
}
} ```
優秀!然而,就像上面的程式碼展示的那樣,使用 GeometeryReader
來展示動態切換有一個相當明顯的缺點,在幾何圖形閱讀器中總是會填充水平和垂直方向的所有可用空間(以便測量實際空間)。在我們的例子中,LoginActionsView
不再只是水平方向的排列,它現在也能移動到螢幕的頂部。
雖然我們也有很多方法能解決這些問題(例如使用類似在這篇 Q&A 中用來使多個檢視具有相同寬度和高度的技術),但真正的問題是當我們要動態的確定方向時,測量可用空間是否是一個好的方法。
一個使用尺寸類的例子
相反,讓我們使用 Apple
的尺寸類系統來決定 DynamicStack
應該在底層使用 HStack
還是 VStack
。這樣做的好處不僅僅是在引入 GeometeryReader
之前保留同樣緊湊的佈局,並且會使 DynamicStack
在開始的時候以一種和系統元件類似的方式在所有裝置和方向上構建。
為了觀察當前水平方向的尺寸,我們需要用到 SwiftUI 環境系統 — 通過在 DynamicStack
中宣告 @Environment
- 標記屬性(帶有 horizontalSizeClass
關鍵路徑),將會使我們在檢視內容中切換到當前 sizeClass
的值:
```swift
struct DynamicStack
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
)
}
} ```
經過以上操作,LoginActionsView
將可以在常規的尺寸渲染時動態切換成水平佈局(例如在大尺寸的 iPhone
使用橫屏,或者全屏 iPad
上的任一方向),而其它所有尺寸的配置使用垂直佈局。所有這些仍然使用緊湊垂直佈局,它使用的空間不超過渲染其內容所需的空間。
使用佈局協議
雖然我們最後已經用了非常棒的解決方案,可以在所有支援 SwiftUI
的 iOS
版本中使用,但也讓我們來探索一下在 iOS 16
中引入的一些新的佈局工具(在寫這篇文章時,它作為 Xcode 14
的一部分仍在測試階段)
其中一個工具是新的 Layout
協議,它既能讓我們建立完整的自定義佈局,直接整合到 SwiftUI
的佈局系統中,同時也提供給我們一種更絲滑更動畫的方式在各種佈局之間動態切換 。
這都是因為事實證明 Layout
不僅僅是我們第三方開發者的 API
,Apple
也讓 SwiftUI
自己的佈局容器使用這個新協議 。所以,與其直接使用 HStack
和 VStack
作為容器檢視,不如將它們作為符合 Layout
的例項,使用 AnyLayout
型別進行包裝 — 就像這樣:
```swift 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
))
}
} ```
以上的操作是可行的,因為當 HStack
和 VStack
的內容型別是 EmptyView
時,它們都符合新的 Layout
協議(當內容為空時就是這種情況),讓我們來看一下SwiftUI
的 公共介面
```swift
struct DynamicStack
var body: some View {
currentLayout(content)
}
} ```
注意:由於迴歸,
Xcode 14 beta 3
中省略了以上條件的一致性,根據SwiftUI
團隊的 Matt Ricketson 的說法,可以直接使用底層的_HStackLayout
和_VStackLayout
型別作為臨時的解決方法。並希望能在未來測試版本中修復。
現在我們能通過使用新的 currentLayout
解決使用什麼佈局,現在我們來更新 body
的實現,簡單呼叫從該屬性返回的 AnyLayout
,就像函式一樣 — 像這樣:
```swift
struct DynamicStack
var body: some View {
currentLayout(content)
}
} ```
我們之所以能像一個函式一樣呼叫佈局方法(儘管它實際上是一個結構)是因為
Layout
協議使用了Swift
”像函式一樣呼叫“ 的特性
那麼我們之前的方案和上面基於佈局的方案有什麼區別呢?關鍵的區別在於(除了後者需要 iOS 16
)切換佈局可以保留正在渲染的底層檢視的標識,而在 HStack
和 VStack
之間切換就不會這樣。這樣做會令動畫更流暢,例如在切換裝置方向時,我們也有可能在執行此類更改時獲得小幅的效能提升(因為 SwiftUI
總是在其檢視層次結構為靜態時儘可能表現最佳)
選擇合適的檢視
但我們還沒有結束,因為 iOS 16
也給了我們其他有趣的新的佈局工具,它有可能也能用於實現 DynamicStack
— 一種全新的檢視型別,名字叫做 ViewThatFits
。就像字面意思一樣,這種新的容器將會在我們初始化時傳遞的候選列表中,基於當前上下文挑選出最優檢視。
在我們的例子中,這意味著我們能同時把 HStack
和 VStack
傳遞給它,並且代表我們在它們中間自動切換。
```swift
struct DynamicStack
var body: some View {
ViewThatFits {
HStack(
alignment: verticalAlignment,
spacing: spacing,
content: content
)
VStack(
alignment: horizontalAlignment,
spacing: spacing,
content: content
)
}
}
} ```
注意:在這種情況下,我們首先放置 HStack
是很重要的,因為 VStack
可能總是合適的,即使在我們希望佈局是橫向的情況下(例如 iPad
的全屏模式)。同樣重要的是要指出,上述基於 ViewThatFits
的技術將會始終嘗試 HStack
,即使在用緊湊尺寸渲染布局時也是如此,只有在 HStack
不適合時才會選擇基於VStack
的佈局。
結語
以上就是通過四種不同的方式實現 DynamicStack
檢視,它可以根據當前內容在 HStack
和 VStack
之間動態切換。
關於我們
我們是由 Swift 愛好者共同維護,我們會分享以 Swift 實戰、SwiftUI、Swift 基礎為核心的技術內容,也整理收集優秀的學習資料。
- 在 SwiftUI 中建立一個環形 Slider
- Swift 週報 第二十五期
- Swift 週報 第二十四期
- 在 iOS 16 中用 SwiftUI Charts 建立一個折線圖
- Swift 中的 async/await ——程式碼例項詳解
- Swift AsyncSequence — 程式碼例項詳解
- Swift 週報 第十期
- SwiftUI 之 HStack 和 VStack 的切換
- 第三方庫並不是必須的
- Swift 週報 第十二期
- LeetCode - #146 LRU 快取(Top 100)
- LeetCode - #145 二叉樹的後序遍歷
- 現今 Swift 包中的二進位制目標
- LeetCode - #125 驗證迴文串
- 解決 iOS 15 上 APP 莫名其妙地退出登入
- 用 SwiftLint 保持 Swift 風格一致
- TCA - SwiftUI 的救星?(一)
- Swift 中的熱過載
- 在 Swift 中編寫指令碼:Git Hooks
- LeetCode - #124 二叉樹中的最大路徑和(Top 100)