SwiftUI 之 HStack 和 VStack 的切換

前言

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

當(dāng)涉及到水平和垂直的變體時(shí)( HStackVStack ),我們需要在這兩者之間動(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ù)寬度是否大于其高度,可以選擇使用 HStackVStack 來渲染內(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 使用了與 HStackVStack 相同的 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)用了非常棒的解決方案,可以在所有支持 SwiftUIiOS 版本中使用,但也讓我們來探索一下在 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ā)者的 APIApple 也讓 SwiftUI 自己的布局容器使用這個(gè)新協(xié)議 。所以,與其直接使用 HStackVStack 作為容器視圖,不如將它們作為符合 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) HStackVStack 的內(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í),而在 HStackVStack 之間切換就不會(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í)把 HStackVStack 傳遞給它,并且代表我們?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)容在 HStackVStack 之間動(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),有感興趣的朋友,可以加入我們。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容