SwiftUI 佈局 —— 尺寸( 上 )

語言: CN / TW / HK

highlight: a11y-dark

在 SwiftUI 中,尺寸這一佈局中極為重要的概念,似乎變得有些神祕。無論是設定尺寸還是獲取尺寸都不是那麼地符合直覺。本文將從佈局的角度入手,為你揭開蓋在 SwiftUI 尺寸概念上面紗,瞭解並掌握 SwiftUI 中眾多尺寸的含義與用法;並通過建立符合 Layout 協議的 frame 和 fixedSize 檢視修飾器的複製品,讓你對 SwiftUI 的佈局機制有更加深入地理解。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

尺寸 —— 一個刻意被淡化的概念

SwiftUI 是一個宣告式框架,提供了強大的自動佈局能力。開發者幾乎可以在不涉及尺寸( 或很少涉及 )這一概念的情況下創建出漂亮、精美、準確的佈局效果。

但由於 SwiftUI 的檢視並沒有提供尺寸這一屬性,因此即使在 SwiftUI 誕生了數年後的今天,如何獲取檢視的尺寸仍然是網路上的熱門問題。同時對於不少的開發者來說,使用 frame 修飾器為檢視設定尺寸產生的結果也經常與他們的預期有所不同。

這並非意味著尺寸在 SwiftUI 中不重要,事實恰恰相反,正是由於在 SwiftUI 中尺寸是一個十分複雜的概念,蘋果將絕大多數有關尺寸的配置和表述都隱藏到了引擎蓋之下,刻意對其進行了包裝與淡化。

淡化尺寸概念的初衷或許是出於以下兩點:

  • 引導開發者轉型到宣告式程式設計邏輯,轉變使用精準尺寸的習慣
  • 掩蓋 SwiftUI 中複雜的尺寸概念,減少初學者的困擾

但無論如何淡化或掩蓋,當涉及更加高階、複雜、精準的佈局時,尺寸是一個始終無法繞開的環節。隨著你對 SwiftUI 認識的提高,瞭解並掌握 SwiftUI 中的眾多尺寸含義也勢在必行。

SwiftUI 佈局過程速覽

SwiftUI 的佈局就是佈局系統通過為檢視樹上的節點提供必要的資訊,最終計算出每個檢視( 矩形 )所需的尺寸以及擺放位置的行為。

swift struct ContentView: View { var body: some View { ZStack { Text("Hello world") } } } // ContentView // | // |———————— ZStack // | // |—————————— Text

以上面的程式碼為例( ContentView 為應用的根檢視 ),我們簡述一下 SwiftUI 的佈局過程( 當前裝置為 iPhone 13 Pro ):

  1. SwiftUI 的佈局系統為 ZStack 提供一個建議尺寸( 390 x 763 該尺寸為裝置螢幕尺寸去掉安全區域的大小 ),並詢問 ZStack 的需求尺寸

  2. ZStack 為 Text 提供建議尺寸( 390 x 763 ),並詢問 Text 的需求尺寸

  3. Text 根據 ZStack 提供的建議尺寸,返回了自己的需求尺寸( 85.33 x 20.33 ,因為 ZStack 提供建議尺寸大於 Text 的實際需求,因此 Text 的需求尺寸為對文字不折行,不省略的完整顯示尺寸)

  4. ZStack 向 SwiftUI 的佈局系統返回了自己的需求尺寸( 85.33 x 20.33,因為 ZStack 中僅有 Text 一個子檢視,因此 Text 的需求尺寸便是 ZStack 的需求尺寸 )

  5. SwiftUI 的佈局系統將 ZStack 放置在了 152.33, 418.33 處,併為其提供了渲染尺寸( 85.33 x 20.33 )

  6. ZStack 將 Text 放置在了 152.33, 418.33 處,併為其提供了渲染尺寸( 85.33 x 20.33 )

佈局過程基本上分為兩個階段:

  • 第一階段 —— 討價還價

在這個階段,父檢視為子檢視提供建議尺寸,子檢視為父檢視返回需求尺寸( 上方的 1-4 )。在 Layout 協議中,對應的是 sizeThatFits 方法。經過該階段的協商,SwiftUI 將確定檢視所在螢幕上的位置和尺寸。

  • 第二階段 —— 安置子民

在該階段,父檢視將根據 SwiftUI 佈局系統提供的螢幕區域( 由第一階段計算得出 )為子檢視設定渲染的位置和尺寸( 上方的 5-6 )。在 Layout 協議中,對應的是 placeSubviews 方法。此時,檢視樹上的每個檢視都將與螢幕上的具體位置聯絡起來。

討價還價的次數與檢視結構的複雜度成正比,整個的協商過程可能會反覆出現多次甚至推倒重來的情況。

容器與檢視

在閱讀 SwiftUI 佈局系列文章時,大家可能會對其中某些稱謂產生困惑。一會兒父檢視、一會兒佈局容器,到底它們之間是什麼關係,是不是同一個東西?

在 SwiftUI 中,只有符合 View 協議的 component 才能被 ViewBuilder 所處理。因此任何一種佈局容器,最終都會被包裝並以 View 的形式出現在程式碼中。

例如,下面是 VStack 的建構函式,content 被傳遞給了真正的佈局容器 _VStackLayout 進行佈局:

swift public struct VStack<Content>: SwiftUI.View where Content: View { internal var _tree: _VariadicView.Tree<_VStackLayout, Content> public init(alignment: SwiftUI.HorizontalAlignment = .center, spacing: CoreFoundation.CGFloat? = nil, @SwiftUI.ViewBuilder content: () -> Content) { _tree = .init( root: _VStackLayout(alignment: alignment, spacing: spacing), content: content() ) } public typealias Body = Swift.Never }

除了我們熟悉的 VStack、ZStack、List 等佈局檢視外,在 SwiftUI 中,大量的佈局容器是以檢視修飾器的形式存在的。例如,下面是 frame 在 SwiftUI 中的定義:

```swift public extension SwiftUI.View { func frame(width: CoreFoundation.CGFloat? = nil, height: CoreFoundation.CGFloat? = nil, alignment: SwiftUI.Alignment = .center) -> some SwiftUI.View { return modifier( _FrameLayout(width: width, height: height, alignment: alignment)) } }

public struct _FrameLayout { let width: CoreFoundation.CGFloat? let height: CoreFoundation.CGFloat? init(width: CoreFoundation.CGFloat?, height: CoreFoundation.CGFloat?, alignment: SwiftUI.Alignment) public typealias Body = Swift.Never } ```

_FrameLayout 被包裝成 viewModifier ,作用於給定的檢視。

```swift Text("Hi") .frame(width: 100,height: 100)

// 可以被視為

_FrameLayou(width: 100,height: 100,alignment: .center) { Text("Hi") } ```

此時 _FrameLayout 即是 Text 的父檢視,也是佈局容器。

對於不包含子檢視的檢視來說( 例如 Text 這類的元檢視 ),它們同樣會提供介面供父檢視來呼叫以向其傳遞建議尺寸並獲取其需求尺寸。雖然當前 SwiftUI 中絕大多數的檢視並不遵循 Layout 協議,但從 SwiftUI 誕生之始,其佈局系統便是按照 Layout 協議提供的流程進行佈局操作的,Layout 協議僅是將內部的實現過程包裝成開發者可以呼叫的介面,以方便我們進行自定義佈局容器的開發。

因此,為了簡化文字,我們在文章中會將父檢視與具備佈局能力的容器等同起來。

不過需要注意的是,在 SwiftUI 中,有一類檢視是會在檢視樹上顯示為父檢視,但並不具備佈局能力。其中的代表有 Group、ForEach 等。這類檢視的主要作用有:

  • 突破 ViewBuilder Block 的數量限制
  • 方便為一組檢視統一設定 view modifier
  • 有利於程式碼管理
  • 其他特殊應用,如 ForEach 可支援動態數量的子檢視等

例如在本文最初的例子中,SwfitUI 會將 ContentView 視作類似 Group 的存在。這類檢視本身並不會參與佈局,SwiftUI 的佈局系統會在佈局時自動將它們忽略,讓其子檢視與具備佈局能力的祖先檢視直接聯絡起來。

SwiftUI 中的尺寸

如上文中所示,在 SwiftUI 的佈局過程中,在不同的階段、出於不同的用途,尺寸這一概念是在不斷地變化的。本節將結合 SwiftUI 4.0 中的 Layout 協議對佈局過程涉及的尺寸做更詳細的介紹。

即使你對 Layout 協議不瞭解或短時間無法使用 SwiftUI 4.0 ,並不會影響你對下文的閱讀和理解。儘管 Layout 協議的主要用途是讓開發者建立自定義佈局容器,且在 SwiftUI 中僅有少數的檢視符合該協議,但從 SwiftUI 1.0 開始,SwiftUI 檢視的佈局機制便基本與 Layout 協議所實現的流程一致。可以說 Layout 協議是一個用來觀察和驗證 SwiftUI 佈局運作原理的優秀工具。

建議尺寸

SwiftUI 的佈局是從外向內進行的。佈局過程的第一個步驟便是由父檢視為子檢視提供建議尺寸( Proposal Size)。顧名思義,建議尺寸是父檢視為子檢視提供的建議,子檢視在計算其需求尺寸時是否考慮建議尺寸完全取決於它自己的行為設定。

以子檢視為符合 Layout 協議的自定義佈局容器舉例,父檢視通過呼叫子檢視的 sizeThatFits 方法提供建議尺寸。建議尺寸的型別為 ProposedViewSize,它的寬和高均為 Optional<CGFloat> 型別。而該自定義佈局容器又會在它的 sizeThatFits 方法中通過呼叫其子檢視代理( Subviews,子檢視在 Layout 協議中的表現方式 )的 sizeThatFits 方法為子檢視代理提供建議尺寸。建議尺寸在佈局的兩個階段(討價還價、安置子民)均會提供,但通常我們只需在第一個階段使用它( 可以在第一階段用 catch 儲存中間的計算資料,減少第二階段的計算量 )。

```swift // 程式碼來自 My_ZStackLayout

// 容器的父檢視(父容器)將通過呼叫容器的 sizeThatFits 獲取容器的需求尺寸,本方法通常會被多次呼叫,並提供不同的建議尺寸 func sizeThatFits( proposal: ProposedViewSize, // 容器的父檢視(父容器)提供的建議尺寸 subviews: Subviews, // 當前容器內的所有子檢視的代理 cache: inout CacheInfo // 快取資料,本例中用於儲存子檢視的返回的需求尺寸,減少呼叫次數 ) -> CGSize { cache = .init() // 清除快取 for subview in subviews { // 為子檢視提供建議尺寸,獲取子檢視的需求尺寸 (ViewDimensions) let viewDimension = subview.dimensions(in: proposal) // 根據 MyZStack 的 alignment 的設定獲取子檢視的 alignmentGuide let alignmentGuide: CGPoint = .init( x: viewDimension[alignment.horizontal], y: viewDimension[alignment.vertical] ) // 以子檢視的 alignmentGuide 為 (0,0) , 在虛擬的畫布中,為子檢視建立 CGRect let bounds: CGRect = .init( origin: .init(x: -alignmentGuide.x, y: -alignmentGuide.y), size: .init(width: viewDimension.width, height: viewDimension.height) ) // 儲存子檢視在虛擬畫布中的資料 cache.subviewInfo.append(.init(viewDimension: viewDimension, bounds: bounds)) }

// 根據所有子檢視在虛擬畫布中的資料,生成 MyZtack 的 CGRect
cache.cropBounds = cache.subviewInfo.map(\.bounds).cropBounds()
// 返回當前容器的理想尺寸,當前容器的父檢視將使用該尺寸在它的內部進行擺放
return cache.cropBounds.size

} ```

根據建議尺寸內容的不同,我們可以將建議尺寸細分為四種建議模式,在 SwiftUI 中,父檢視會根據它的需求選擇合適的建議模式提供給子檢視。由於可以在寬度和高度上分別選擇不同的模式,因此建議模式特指在一個維度上所提供的建議內容。

  • 最小化模式

該維度的建議尺寸為 0 。ProposedViewSize.zero 表示兩個維度都為最小化模式的建議尺寸。某些佈局容器(比如 VStack、HStack ),會通過為其子檢視代理提供最小化模式的建議尺寸以獲取子檢視在特定維度下的最小需求尺寸( 例如對檢視使用了 minWidth 設定 )

  • 最大化模式

該模式的建議尺寸為 CGFloat.infinity 。ProposedViewSize.infinity 表示兩個維度都為最大化模式的建議尺寸。當父檢視想獲得子檢視在最大模式下的需求尺寸時,會為其提供該模式的建議尺寸

  • 明確尺寸模式

非 0 或 infinity 的數值。比如在上文的例子中,ZStack 為 Text 提供了 390 x 763 的建議尺寸。

  • 未指定模式

nil,不設定任何數值。ProposedViewSize.unspecified 表示兩個維度都為未指定模式的建議尺寸。

為子檢視提供不同的建議模式的目的是獲得在該模式下子檢視的需求尺寸,具體使用哪種模式,完全取決於父檢視的行為設定。例如:ZStack 會將其父檢視提供給它的建議模式直接轉發給 ZStack 的子檢視,而 VStack、HStack 則會要求子檢視返回全部模式下的需求尺寸,以判斷子檢視是否為動態檢視( 在特定維度可以動態調整尺寸 )。

在 SwiftUI 中,通過設定或調整建議模式而進行二次佈局的場景很多,比較常用的有:frame、fixedSize 等。比如,下面的程式碼中,frame 便是無視 VStack 提供建議尺寸,強行為 Text 提供了 50 x 50 的建議尺寸。

swift VStack { Text("Hi") .frame(width: 50,height: 50) }

需求尺寸

在子檢視收到了父檢視的建議尺寸後,它將根據建議模式和自身行為特點返回需求尺寸。需求尺寸的型別為 CGSize 。在絕大多數情況下,自定義佈局容器( 符合 Layout 協議)在佈局第一階段最終返回的需求尺寸與第二階段 SwiftUI 佈局系統傳遞給它的螢幕區域( CGRect )的尺寸一致。

swift // 程式碼來自 FixedSizeLayout // 根據建議尺寸返回需求尺寸 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { guard subviews.count == 1, let content = subviews.first else { fatalError("Can't use MyFixedSizeLayout directly") } let width = horizontal ? nil : proposal.width let height = vertical ? nil : proposal.height // 獲取子檢視的需求尺寸 let size = content.sizeThatFits(.init(width: width, height: height)) return size }

比如以下是 Rectangle() 在四種建議模式下返回的結果,以兩個維度為同一種模式舉例:

  • 最小化模式

需求尺寸為 0 x 0

  • 最大化模式

需求尺寸為 infinity * infinity

  • 明確尺寸模式

需求尺寸為建議尺寸

  • 未指定模式

需求尺寸為 10 x 10( 至於為什麼是 10 x 10 ,下文中的理想尺寸將有更詳細的說明 )

Text("Hello world") 在四種建議模式下計算需求尺寸的行為與 Rectangle 則大相徑庭:

  • 最小化模式

當任意維度為最小化模式時,需求尺寸為 0 x 0

  • 最大化模式

需求尺寸為 Text 的實際顯示尺寸( 文字不折行、不省略 ) 85.33 x 20.33( 上文例子中尺寸 )

  • 明確尺寸模式

如果建議寬度大於單行顯示的需要,則需求寬度返回單行實現顯示尺寸的寬度 85.33 ;如果建議寬度小於單行顯示的需要則需求寬度返回建議尺寸的寬度;如果建議高度小於單行顯示的高度,則需求高度返回單行的顯示高度 20.33;如果建議高度高於單行顯示的高度且寬度大於單行顯示的寬度,則需求高度返回單行顯示的高度 20.33 ……

  • 未指定模式

當兩個維度均為未指定模式時,需求尺寸為單行完整顯示所需的寬和高 85.33 x 20.33

不同的檢視,在相同的建議模式及尺寸下會返回不同的需求尺寸這一事實既是 SwiftUI 的特色也是十分容易很讓人困擾的地方。不過不用太緊張,需求尺寸總體上來說還是有規律可循的:

  • Shape

除了未指定模式,其他均與建議尺寸一致

  • Text

需求尺寸的計算規則較為複雜,需求尺寸取決於建議尺寸和實際完整顯示尺寸

  • 佈局容器( ZStack 、HStack、VStack 等)

需求尺寸為容器內子檢視按指定對齊指南對齊擺放後( 已處理動態尺寸檢視 )的總尺寸,詳情請參閱 SwiftUI 佈局 —— 對齊

  • 其他控制元件例如 TextField、TextEditor、Picker 等

需求尺寸取決於建議尺寸和實際顯示尺寸

在 SwiftUI 中,frame(minWidth:,maxWidth:,minHeight:,maxHeight) 便是對子檢視的需求尺寸進行調整的典型應用。

渲染尺寸

在佈局的第二階段,當 SwiftUI 的佈局系統呼叫佈局容器( 符合 Layout 協議 )的 placeSubviews 方法時,佈局容器會將每個子檢視放置在給定的螢幕區域( 尺寸通常與該佈局容器的需求尺寸一致 )中,併為子檢視設定渲染尺寸。渲染尺寸是父檢視為子檢視設定的用於渲染的建議尺寸。

```swift // 程式碼來自 FixedSizeLayout func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { guard subviews.count == 1, let content = subviews.first else { fatalError("Can't use MyFixedSizeLayout directly") }

// 設定渲染位置及渲染尺寸。
content.place(at: .init(x: bounds.minX, y: bounds.minY), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height))

} ```

父檢視將根據自身的行為特點以及參考子檢視的需求尺寸計運算元檢視的渲染尺寸,例如:

  • 在 ZStack 中,ZStack 為子檢視設定的渲染尺寸與子檢視的需求尺寸一致
  • 在 VStack 中,VStack 將根據其父檢視提供的建議尺寸、子檢視是否為可擴充套件檢視、子檢視的檢視優先順序等資訊,為子檢視計算渲染尺寸。比如: 當固定高度的子檢視的總高度已經超出了 VStack 獲得的建議尺寸高度,那麼 Spacer 就只能獲得高度為 0 的渲染尺寸

多數情況下,渲染尺寸與子檢視的最終顯示尺寸( 檢視尺寸 )一致,但並非絕對。

SwiftUI 沒有提供可以在檢視中直接處理渲染尺寸的方式( 除了 Layout 協議 ),通常我們會通過對建議尺寸以及需求尺寸的調整,來影響渲染尺寸。

檢視尺寸

檢視渲染後在螢幕上呈現的尺寸,也是熱門提問 —— 如何獲取檢視的尺寸中所指的尺寸。

在檢視中可以通過 GeometryReader 獲取特定檢視的尺寸及位置。

```swift extension View { func printSizeInfo(_ label: String = "") -> some View { background( GeometryReader { proxy in Color.clear .task(id: proxy.size) { print(label, proxy.size) } } ) } }

VStack { Text("Hello world") .printSizeInfo() // 列印檢視尺寸 } ```

另外,我們可以通過 border 檢視修飾器更加直觀地比對不同層級的檢視尺寸:

swift VStack { Text("Hello world") .border(.red) .frame(width: 100, height: 100, alignment: .bottomLeading) .border(.blue) .padding() } .border(.green)

image-20220711134423997

檢視尺寸已經是佈局完成之後的產物了,在沒有 Layout 協議之前,開發者只能通過獲取當前檢視以及子檢視的檢視尺寸來實現自定義佈局。不僅效能較差,而且一旦設計有誤可能會導致檢視的迴圈重新整理,進而造成程式崩潰。通過 Layout 協議,開發者可以站在上帝的視角,利用建議尺寸、需求尺寸、渲染尺寸等資訊從容地進行佈局。

理想尺寸

理想尺寸( ideal size )特指在建議尺寸為未指定模式下返回的需求尺寸。例如在上文中,SwiftUI 為所有的 Shape 設定的預設理想尺寸為 10 x 10 ,Text 預設的理想尺寸為單行完整顯示全部內容所需的尺寸。

我們可以使用 frame(idealWidth:CGFloat, idealHeight:CGFloat) 為檢視設定理想尺寸,並使用 fixedSize 為檢視的特定維度提供未指定模式的建議尺寸,以使其在該維度上將理想尺寸作為其需求尺寸。

在撰寫本文之前,我發了個 推文,詢問大家對 fixedSize 的瞭解:

image-20220711140418269

FW9GLjJVsAAmDXX

swift Text("Hello world") .border(.red) .frame(idealWidth: 100, idealHeight: 100) .fixedSize() .border(.green)

image-20220711140000421

在瞭解了理想尺寸之後,我想大家應該能夠推斷出推文中以及上面程式碼的佈局結果了吧。

尺寸的應用

在上文中,我們已經提及了不少在檢視中設定或獲取尺寸的工具和手段,現做以下彙總:

  • frame(width: 50, height: 50)

為子檢視提供 50 x 50 的建議尺寸,並將 50 x 50 作為需求尺寸返回給父檢視

  • fixedSize()

為子檢視提供未指定模式的建議尺寸

  • frame(minWidth: 100, maxWidth: 300)

將子檢視的需求尺寸控制在指定的範圍中,並將調整後的尺寸作為需求尺寸返回給父檢視

  • frame(idealWidth: 100, idealHeight: 100)

如果當前檢視收到為未指定模式的建議尺寸,則返回 100 x 100 的需求尺寸

  • GeometryReader

將建議尺寸作為需求尺寸直接返回( 充滿全部可用區域 )

接下來

在上篇中,我們對 SwiftUI 中的各種尺寸概念做了介紹,在下篇中我們將通過建立 frame、fixedSize 的複製品進一步提升大家對 SwiftUI 不同尺寸概念的理解和掌握。

可在此處獲取 下篇的程式碼,提前對內容有所瞭解。

希望本文能夠對你有所幫助。

原文發表在我的部落格 wwww.fatbobman.com

歡迎訂閱我的公共號:【肘子的Swift記事本】

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿