用 SwiftUI 的方式進行佈局
highlight: a11y-dark
最近時常有朋友反映,儘管 SwiftUI 的佈局系統學習門檻很低,但當真正面對要求較高的設計需求時,好像又無從下手。SwiftUI 真的具備建立複雜使用者介面的能力嗎?本文將通過用多種手段完成同一需求的方式,展示 SwiftUI 佈局系統的強大與靈活,並通過這些示例讓開發者對 SwiftUI 的佈局邏輯有更多的認識和理解。
可在 此處 獲取本文程式碼。
原文發表在我的部落格 wwww.fatbobman.com
歡迎訂閱我的公共號:【肘子的Swift記事本】
需求
不久前,在 聊天室 中,有網友提出了這樣一個佈局需求:
有兩個豎向排列的檢視。在初始狀態時( show == false ),檢視一( 紅色檢視 )的底部與螢幕底部對齊,當 show == true 時,檢視二( 綠色檢視 )的底部與螢幕底部對齊。
大致效果如下:
解決方案
對於上面的需求,相信不少讀者都會在第一時間想出多個解決方案。下文中,我們將用 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
// 獲取檢視尺寸 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
一、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 佈局 —— 尺寸( 下 ) 一文中“面子和裡子”章節。
二、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 為我們提供了眾多的佈局手段,只有充分地理解並掌握它們,方可從容應對複雜的佈局需求。
希望本文能夠對你有所幫助。同時也歡迎你通過 Twitter、 Discord 頻道 或部落格的留言板與我進行交流。
訂閱下方的 郵件列表,可以及時獲得每週的 Tips 彙總。
原文發表在我的部落格 wwww.fatbobman.com
歡迎訂閱我的公共號:【肘子的Swift記事本】
- 自定義 Button 的外觀和互動行為
- MacBook Pro 使用體驗
- 用 SwiftUI 的方式進行佈局
- 聊一聊可組裝框架( TCA )
- StateObject 與 ObservedObject
- 一些適合 SwiftUI 初學者的教程
- iBug 16 有感
- 在 SwiftUI 中實現檢視居中的若干種方法
- SwiftUI 佈局 —— 尺寸( 下 )
- SwiftUI 佈局 —— 尺寸( 上 )
- SwiftUI 佈局 —— 對齊
- Core Data with CloudKit(三)——CloudKit儀表臺
- Core Data with CloudKit(二)——同步本地資料庫到iCloud私有資料庫
- 在CoreData中使用持久化歷史跟蹤
- 用 Table 在 SwiftUI 下建立表格
- SwiftUI 4.0 的全新導航系統
- 如何在 Core Data 中進行批量操作
- Core Data 是如何在 SQLite 中儲存資料的
- 在 SwiftUI 檢視中開啟 URL 的若干方法
- 為自定義屬性包裝型別新增類 @Published 的能力