SwiftUI 之 HStack 和 VStack 的切換

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第7天,點選檢視活動詳情

前言

SwiftUI 的各種堆疊是許多框架中最基本的佈局工具,能夠讓我們定義組檢視,這些組檢視可以按照水平、垂直或覆蓋檢視對齊。

當涉及到水平和垂直的變體時( HStackVStack ),我們需要在這兩者之間動態的切換。舉個例子,假如我們正在構建一個 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 測量當前可用空間,並根據寬度是否大於其高度,可以選擇使用 HStackVStack 來渲染內容。

雖然可以在 LoginActionsView 中放入該邏輯,但我們希望以後能複用程式碼,因此需要重新建立一個專門的檢視,作為一個獨立的元件來實現動態堆疊的切換邏輯。

為了使程式碼可用性更高,我們不會硬編碼讓兩個堆疊變體使用對齊或間距什麼的。相反,讓我們像 SwiftUI 一樣,對這些屬性引數化,同時設定框架所使用的預設值 — 就像這樣:

```swift struct DynamicStack: 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 使用了與 HStackVStack 相同的 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: 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
    )
}

} ```

經過以上操作,LoginActionsView 將可以在常規的尺寸渲染時動態切換成水平佈局(例如在大尺寸的 iPhone 使用橫屏,或者全屏 iPad 上的任一方向),而其它所有尺寸的配置使用垂直佈局。所有這些仍然使用緊湊垂直佈局,它使用的空間不超過渲染其內容所需的空間。

使用佈局協議

雖然我們最後已經用了非常棒的解決方案,可以在所有支援 SwiftUIiOS 版本中使用,但也讓我們來探索一下在 iOS 16 中引入的一些新的佈局工具(在寫這篇文章時,它作為 Xcode 14 的一部分仍在測試階段)

其中一個工具是新的 Layout 協議,它既能讓我們建立完整的自定義佈局,直接整合到 SwiftUI 的佈局系統中,同時也提供給我們一種更絲滑更動畫的方式在各種佈局之間動態切換 。

這都是因為事實證明 Layout 不僅僅是我們第三方開發者的 APIApple 也讓 SwiftUI 自己的佈局容器使用這個新協議 。所以,與其直接使用 HStackVStack 作為容器檢視,不如將它們作為符合 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
    ))
}

} ```

以上的操作是可行的,因為當 HStackVStack 的內容型別是 EmptyView 時,它們都符合新的 Layout 協議(當內容為空時就是這種情況),讓我們來看一下SwiftUI 的 公共介面

```swift struct DynamicStack: View { ...

var body: some View {
    currentLayout(content)
}

} ```

注意:由於迴歸, Xcode 14 beta 3 中省略了以上條件的一致性,根據 SwiftUI 團隊的 Matt Ricketson 的說法,可以直接使用底層的 _HStackLayout_VStackLayout 型別作為臨時的解決方法。並希望能在未來測試版本中修復。

現在我們能通過使用新的 currentLayout 解決使用什麼佈局,現在我們來更新 body 的實現,簡單呼叫從該屬性返回的 AnyLayout ,就像函式一樣 — 像這樣:

```swift struct DynamicStack: View { ...

var body: some View {
    currentLayout(content)
}

} ```

我們之所以能像一個函式一樣呼叫佈局方法(儘管它實際上是一個結構)是因為 Layout 協議使用了 Swift ”像函式一樣呼叫“ 的特性

那麼我們之前的方案和上面基於佈局的方案有什麼區別呢?關鍵的區別在於(除了後者需要 iOS 16 )切換佈局可以保留正在渲染的底層檢視的標識,而在 HStackVStack 之間切換就不會這樣。這樣做會令動畫更流暢,例如在切換裝置方向時,我們也有可能在執行此類更改時獲得小幅的效能提升(因為 SwiftUI 總是在其檢視層次結構為靜態時儘可能表現最佳)

選擇合適的檢視

但我們還沒有結束,因為 iOS 16 也給了我們其他有趣的新的佈局工具,它有可能也能用於實現 DynamicStack — 一種全新的檢視型別,名字叫做 ViewThatFits 。就像字面意思一樣,這種新的容器將會在我們初始化時傳遞的候選列表中,基於當前上下文挑選出最優檢視。

在我們的例子中,這意味著我們能同時把 HStackVStack 傳遞給它,並且代表我們在它們中間自動切換。

```swift struct DynamicStack: View { ...

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 檢視,它可以根據當前內容在 HStackVStack 之間動態切換。

關於我們

我們是由 Swift 愛好者共同維護,我們會分享以 Swift 實戰、SwiftUI、Swift 基礎為核心的技術內容,也整理收集優秀的學習資料。