用 SwiftUI 的方式進行佈局

語言: CN / TW / HK

highlight: a11y-dark

最近時常有朋友反映,儘管 SwiftUI 的佈局系統學習門檻很低,但當真正面對要求較高的設計需求時,好像又無從下手。SwiftUI 真的具備創建複雜用户界面的能力嗎?本文將通過用多種手段完成同一需求的方式,展示 SwiftUI 佈局系統的強大與靈活,並通過這些示例讓開發者對 SwiftUI 的佈局邏輯有更多的認識和理解。

可在 此處 獲取本文代碼。

原文發表在我的博客 wwww.fatbobman.com

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

需求

不久前,在 聊天室 中,有網友提出了這樣一個佈局需求:

有兩個豎向排列的視圖。在初始狀態時( show == false ),視圖一( 紅色視圖 )的底部與屏幕底部對齊,當 show == true 時,視圖二( 綠色視圖 )的底部與屏幕底部對齊。

大致效果如下:

layoutInSwiftUIWayDemo

解決方案

對於上面的需求,相信不少讀者都會在第一時間想出多個解決方案。下文中,我們將用 SwiftUI 佈局系統提供的多種手段來實現該要求。在這些解決方案中,有些非常簡單、直接,有些則會略顯煩瑣,曲折。我儘量讓每種方案都採用不同的佈局邏輯。

準備工作

我們首先將一些可複用的代碼提取出來,以簡化之後的工作:

```swift // 視圖一 struct RedView: View { var body: some View { Rectangle() .fill(.red) .frame(height: 600) } }

// 視圖二 struct GreenView: View { var body: some View { Rectangle() .fill(.green) .frame(height: 600) } }

// 狀態切換按鈕 struct OverlayButton: View { @Binding var show: Bool var body: some View { Button(show ? "Hide" : "Show") { show.toggle() } .buttonStyle(.borderedProminent) } }

extension View { func overlayButton(show: Binding) -> some View { self .overlay(alignment: .bottom) { OverlayButton(show: show) } } }

// 獲取視圖尺寸 struct SizeInfoModifier: ViewModifier { @Binding var size: CGSize func body(content: Content) -> some View { content .background( GeometryReader { proxy in Color.clear .task(id: proxy.size) { size = proxy.size } } ) } }

extension View { func sizeInfo(_ size: Binding) -> some View { self .modifier(SizeInfoModifier(size: size)) } } ```

一、Offset

VStack + offset 是一個相當符合直覺的處理方式。

swift struct OffsetDemo: View { @State var show = false @State var greenSize: CGSize = .zero var body: some View { Color.clear .overlay(alignment: .bottom) { VStack(spacing: 0) { RedView() GreenView() .sizeInfo($greenSize) } .offset(y: show ? 0 : greenSize.height) .animation(.default, value: show) } .ignoresSafeArea() .overlayButton(show: $show) } }

代碼提示:

  • Color.clear.ignoresSafeArea() 將創建一個與屏幕尺寸一致的視圖
  • overlay 可以很好的控制建議尺寸,同時又可享受到便捷的對齊設置
  • 通過 animation(.default, value: show) 使動畫與特定的狀態變化相關聯

在上面的代碼中,考慮到當 show == true 時,視圖二( 綠色視圖 )的底部必然與屏幕底部對齊,因此,將 overlay 的對齊指南設置為 bottom ,可以極大地簡化我們的初始佈局聲明。以此佈局為基礎,通過 offset ,分別為兩種狀態進行了位移值描述。

我們也可以使用其他的修飾符( 例如:padding、postion )採用該佈局思路實現上述需求。

swift .offset(y: show ? 0 : greenSize.height) // 替換改行為 .padding(.bottom, show ? 0 : -greenSize.height)

儘管在本例中,offset 和 padding 的視覺呈現一致,但當需要與其他視圖一起進行佈局時,兩者之間還是有很大的不同。padding 是在佈局層面進行的調整,添加 padding 後的視圖,同時也會對其他視圖的佈局產生影響。offset 則是在渲染層面進行的位置調整,即使出現了位置變化,其他視圖在佈局時,並不會將其位移考慮在其中。有關這方面的內容,請參閲 SwiftUI 佈局 —— 尺寸( 下 ) 一文中“面子和裏子”章節。

padding-offset

二、AlignmentGuide

在 SwiftUI 中,開發者可以使用 alignmentGuide 修飾器來修改視圖某個對齊指南的值( 設置顯式值 )。由於 Color.clear.overlay 為我們提供了一個相當理想的佈局環境,因此,通過分別修改在不同狀態下兩個視圖的對齊指南,也能滿足本文的需求。

swift struct AlignmentDemo: View { @State var show = false @State var greenSize: CGSize = .zero var body: some View { Color.clear .overlay(alignment: .bottom) { RedView() .alignmentGuide(.bottom) { show ? $0[.bottom] + greenSize.height : $0[.bottom] } } .overlay(alignment: .bottom) { GreenView() .sizeInfo($greenSize) .alignmentGuide(.bottom) { show ? $0[.bottom] : $0[.top] } } .animation(.default, value: show) .ignoresSafeArea() .overlayButton(show: $show) } }

在本解決方案中,我們將兩個視圖分別置於兩個 overlay 層中,儘管在視覺上,兩者之間仍呈垂直排列,但實際上兩者之間並無關聯。

無論為同一個視圖添加多少層 overlay( 或 background ),它們為子視圖所提供的建議尺寸都是一致的( 與原視圖的尺寸一致 )。在上面的代碼中,由於兩個視圖使用了同樣的動畫曲線設定,因此,在移動時並不會出現分離的情況。但如果為視圖分別設定不同的動畫曲線( 例如:一個 linear、一個 easeIn ),狀態切換時便無法保證視圖之間的完全緊密。

有關建議尺寸、需求尺寸等內容,請參閲 SwiftUI 佈局 —— 尺寸( 上 ) 一文

三、NameSpace

從 3.0 版本( iOS 15 )開始,SwiftUI 提供了新的 NameSpace 以及 matchedGeometryEffect 修飾器,讓開發者只需少量代碼便可實現例如英雄動畫這類的複雜需求。

嚴格意義上來説,NameSpace + matchedGeometryEffect 是對一組修飾器以及代碼的統一封裝。通過命名空間以及 ID 來保存特定視圖的幾何信息( 位置、尺寸 ),並自動設置給其他有需求的視圖。

swift struct NameSpaceDemo: View { @State var show = false @Namespace var placeHolder @State var greenSize: CGSize = .zero @State var redSize: CGSize = .zero var body: some View { Color.clear // green placeholder .overlay(alignment: .bottom) { Color.clear // GreenView().opacity(0.01) .frame(height: greenSize.height) .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: .bottom, isSource: true) .matchedGeometryEffect(id: "top", in: placeHolder, anchor: .top, isSource: true) } .overlay( GreenView() .sizeInfo($greenSize) .matchedGeometryEffect(id: "bottom", in: placeHolder, anchor: show ? .bottom : .top, isSource: false) ) .overlay( RedView() .matchedGeometryEffect(id: "top", in: placeHolder, anchor: show ? .bottom : .top, isSource: false) ) .animation(.default, value: show) .ignoresSafeArea() .overlayButton(show: $show) } }

在上面的代碼中,我們在第一個 overlay 中繪製了一個與視圖二尺寸一致的視圖( 不顯示 ),並將其底邊與屏幕底邊對齊。通過 matchedGeometryEffect 分別為該站位視圖的頂部和底部設置了兩個標識符以保存信息。

讓視圖一、視圖二在兩個狀態下分別使用對應的 ID 位置,即可實現本文需求。

NameSpace + matchedGeometryEffect 是一個十分強大的組合,尤其擅長面對同時有位置及尺寸變化的場景。不過需要注意的是,NameSpace 只適用於在同一棵視圖樹中分享數據,如果出現了例如 一段因 @State 注入機制所產生的“靈異代碼” 一文中提到了兩棵樹的情況,則無法實現幾何信息的共享。

四、ScrollView

考慮到本文需求的動畫形態( 豎向滾動 ),使用 ScrollViewReader 提供的滾動定位功能,同樣可以滿足需求。

swift struct ScrollViewDemo: View { @State var show = false @State var screenSize: CGSize = .zero @State var redViewSize: CGSize = .zero var body: some View { Color.clear .overlay( ScrollViewReader { proxy in ScrollView { VStack(spacing: 0) { Color.clear .frame(height: screenSize.height - redViewSize.height) RedView() .sizeInfo($redViewSize) .id("red") GreenView() .id("green") } } .scrollDisabled(true) .onAppear { proxy.scrollTo("red", anchor: .bottom) } .onChange(of: show) { _ in withAnimation { if show { proxy.scrollTo("green", anchor: .bottom) } else { proxy.scrollTo("red", anchor: .bottom) } } } } ) .sizeInfo($screenSize) .ignoresSafeArea() .overlayButton(show: $show) } }

儘管都是垂直構圖( axis 為 vertical ),但 ScrollView 與 VStack 在處理各種尺寸的邏輯上還是有非常大的差別。

ScrollView 會使用父視圖給定的全部建議尺寸創建滾動區域,但在詢問其子視圖的需求尺寸時只會提供理想尺寸。這意味着,在 ScrollView 中,子視圖最好明確的設定尺寸( 提出明確地需求尺寸 )。因此,在上面的代碼中,需要通過屏幕高度和視圖一的高度差來計算上方的空白站位視圖高度。

通過設定 scrollTo 的 anchor,在合理的要求下,我們可以讓視圖停在特定位置。scrollDisabled( 則讓我們可以在 iOS 16+ 中屏蔽 ScrollView 的滾動手勢 )。

五、LayoutPriority

在 SwiftUI 中,設置視圖優先級( 使用 layoutPriority )是一個好用但並不常用的功能。SwiftUI 在進行佈局時,當佈局容器給出的建議尺寸無法滿足全部子視圖的需求尺寸時,會根據子視圖的 Priority,優先滿足級別較高的視圖的佈局需求。

swift struct LayoutPriorityDemo: View { @State var show = false @State var screenSize: CGSize = .zero @State var redViewSize: CGSize = .zero var body: some View { Color.clear .overlay(alignment: show ? .bottom : .top) { VStack(spacing: 0) { Spacer() .frame(height: screenSize.height - redViewSize.height) .layoutPriority(show ? 0 : 2) RedView() .sizeInfo($redViewSize) .layoutPriority(show ? 1 : 2) GreenView().layoutPriority(show ? 2 : 0) } .animation(.default, value: show) } .sizeInfo($screenSize) .ignoresSafeArea() .overlayButton(show: $show) } }

在上面的代碼中,我們讓 overlay 在兩種狀態時,採取不同的佈局指南策略,並讓視圖具備不同的優先級狀態( 狀態切換時 ),以此來獲得想要的佈局結果。

儘管 Spacer 給定了明確的尺寸,但在狀態二時,受限於建議尺寸,其並不會參與佈局。視圖二同理

六、再戰 AlignmentGuide

在上面使用 AlignmentGuide 的例子中,我們通過 GeometryReader 獲取了視圖二的高度信息,並通過設置顯式對齊指南來完成了移動。從某種邏輯上來説,這種方式與 offset 類似,都需要獲取到明確的位移值才能滿足需要。

在本例中,儘管仍使用 AlignmentGuide,但無需獲取具體尺寸值,便可達成目標。

swift struct AlignmentWithoutGeometryReader: View { @State var show = false var body: some View { Color.clear .overlay(alignment: .bottom) { GreenView() .alignmentGuide(.bottom) { show ? $0[.bottom] : 0 } .overlay(alignment: .top) { RedView() .alignmentGuide(.top) { $0[.bottom] } } .animation(.default, value: show) } .ignoresSafeArea() .overlayButton(show: $show) } }

在上面的代碼中,我們利用 overlay 嵌套 + alignmentGuide 的方式實現了將視圖一的底邊與視圖二的頂部對齊綁定。因此,只需要在狀態切換時,調整視圖二的對齊指南即可( 視圖一將自動跟隨視圖二移動 )。

此種方式在視覺上與通過 VStack 的實現類似,但兩者在需求尺寸上有明顯不同。VStack 的縱向需求尺寸為視圖一與視圖二的高度和,而通過 overlay 嵌套,縱向需求尺寸僅為視圖二的高度( 儘管視覺上視圖一在視圖二的上方且緊密相連 )。

七、Transition

通過為視圖設定 Transition( 轉場 ),在視圖插入或將其移出視圖樹時,SwiftUI 將自動生成對應的動畫效果。

swift struct TransitionDemo:View { @State var show = false var body: some View { Color.clear .overlay(alignment:.bottom){ VStack(spacing:0) { RedView() if show { GreenView() .transition(.move(edge: .bottom)) } } .animation(.default, value: show) } .ignoresSafeArea() .overlayButton(show: $show) // 不能使用顯式動畫 } }

請注意,轉場對動畫設定的位置、方式要求很高。稍不注意便會出現轉場完全失效或部分失效的情況,例如在本例中,如果在 Button 中( 切換 show 狀態時 )添加 withAnimation 進行顯式動畫設定,將導致進入轉場失效。

轉場是 SwiftUI 提供的強大能力之一,可以極大地簡化動畫實現的難度。我寫的視圖管理器 SwiftUI Overlay Container ,便是建立在對轉場功能的充分應用之上。

有關轉場動畫的更多內容,請參閲 SwiftUI 的動畫機制 一文

八、Layout 協議

在 4.0 版本中,SwiftUI 增加了 Layout 協議,通過該協議,開發者可以針對特定的場景,創建自定義佈局容器。儘管當前的需求僅有兩個視圖,但我們仍然可以從中提煉出場景特性:在垂直排列的前提下,在特定狀態時,指定視圖的底部與容器視圖的底部對齊。

```swift struct LayoutProtocolDemo: View { @State var show = false var body: some View { Color.clear .overlay( AlignmentBottomLayout { RedView() .alignmentActive(show ? false : true) // 設定當前的活動視圖 GreenView() .alignmentActive(show ? true : false) } .animation(.default, value: show) ) .ignoresSafeArea() .overlayButton(show: $show) } }

struct ActiveKey: LayoutValueKey { static var defaultValue = false }

extension View { func alignmentActive(_ isActive: Bool) -> some View { layoutValue(key: ActiveKey.self, value: isActive) } }

struct AlignmentBottomLayout: Layout { func makeCache(subviews: Subviews) -> Catch { .init() }

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Catch) -> CGSize {
    guard !subviews.isEmpty else { return .zero }
    var height: CGFloat = .zero
    for i in subviews.indices {
        let subview = subviews[i]
        if subview[ActiveKey.self] == true { // 獲取活動視圖
            cache.activeIndex = i
        }
        let viewDimension = subview.dimensions(in: proposal)
        height += viewDimension.height
        cache.sizes.append(.init(width: viewDimension.width, height: viewDimension.height))
    }
    return .init(width: proposal.replacingUnspecifiedDimensions().width, height: proposal.replacingUnspecifiedDimensions().height)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Catch) {
    guard !subviews.isEmpty else { return }
    var currentY: CGFloat = bounds.height - cache.alignmentHeight + bounds.minY // 初始 y 位置
    for i in subviews.indices {
        let subview = subviews[i]
        subview.place(at: .init(x: bounds.minX, y: currentY), anchor: .topLeading, proposal: proposal)
        currentY += cache.sizes[i].height
    }
}

}

struct Catch { var activeIndex = 0 var sizes: [CGSize] = []

var alignmentHeight: CGFloat {
    guard !sizes.isEmpty else { return .zero }
    return sizes[0...activeIndex].map { $0.height }.reduce(0,+)
}

} ```

在上面的代碼中,我們通過 alignmentActive( LayoutValueKey )指示當前與容器底部對齊的視圖。

毋庸置疑,這是所有方案中最複雜的實現。不過,如果我們有類似的需求,使用該自定義容器將十分地便利。

swift struct LayoutProtocolExample: View { let views = (0..<8).map { _ in CGFloat.random(in: 100...150) } @State var index = 0 var body: some View { VStack { Picker("", selection: $index) { ForEach(views.indices, id: \.self) { i in Text("\(i)").tag(i) } } .pickerStyle(.segmented) .zIndex(2) AlignmentBottomLayout { ForEach(views.indices, id: \.self) { i in RoundedRectangle(cornerRadius: 20) .fill(.orange.gradient) .overlay(Text("\(i)").font(.title)) .padding([.horizontal, .top], 10) .frame(height: views[i]) .alignmentActive(index == i ? true : false) } } .animation(.default, value: index) .frame(width: 300, height: 400) .clipped() .border(.blue) } .padding(20) } }

自定義佈局容器

總結

同大多的佈局框架一樣,最終決定佈局能力的上限主要取決於開發者。SwiftUI 為我們提供了眾多的佈局手段,只有充分地理解並掌握它們,方可從容應對複雜的佈局需求。

希望本文能夠對你有所幫助。同時也歡迎你通過 TwitterDiscord 頻道 或博客的留言板與我進行交流。

訂閲下方的 郵件列表,可以及時獲得每週的 Tips 彙總。

原文發表在我的博客 wwww.fatbobman.com

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