概覽
在 SwiftUI 的世界里,我們無數次都夢想著視圖可以自動根據布局上下文“因勢而變”?。大多數情況下,SwiftUI 會將每個視圖尺寸處理的井井有條,不過在某些時候我們還是得親力親為。
如上圖所示,無論頂部 TabView 容器里子視圖高度如何變化,TabView 本身的高度都能“隨遇而安”。如何用最簡單、最現代化、最有趣且最切中要害的方法讓容器尺寸與子視圖的高度“如影隨形”呢?
在本篇博文中,您將學到如下內容:
- 概覽
- 9. 最“相得益彰”的實現:自定義布局 Layout
- 9.1 重裝上陣 Layout
- 9.2 “奇怪的” TabView
- 9.3 MaxHeightLayout 的實現
- 總結
相信學完本課后,小伙伴們必能腦洞大開、格局打開,用“千姿百態”的方法讓問題的解決一發入魂、九轉功成!
那還等什么呢?Let‘s go!!!😉
9. 最“相得益彰”的實現:自定義布局 Layout
在一口氣介紹完上面 5 種“五花八門”的實現之后,我們完全可以“鳴金收兵”。但是為了面面俱到,我們最后還是決定用自定義布局 Layout 來為整個系列博文畫一個圓滿的句號。
9.1 重裝上陣 Layout
所謂自定義布局 Layout,其實就是創建一款遵守 Layout 協議的“容器”(嚴格說應該是視圖集合 Collection of views),然后“恣意”為內部的子視圖“排兵布陣”:
為什么說用 Layout 這種方法更加“鞭辟入里”呢?因為這是處理多個同一層級子視圖布局最自然的方式。
大家回憶一下:我們是將所有喜愛的成語用 ForEach 挨個放在 TabView 容器里的,在父容器中對它們的布局“運籌帷幄”是理所當然的事。
關于自定義布局的進一步介紹,請小伙伴們移步如下鏈接觀賞精彩的文章:
- SwiftUI 打造一款收縮自如的 HStack(四):Layout 自定義布局
9.2 “奇怪的” TabView
我們的目標是創建一個通用自定義布局 MaxHeightLayout,然后實時計算出所有子視圖中最高的 Height。由于 MaxHeightLayout 是作為一個“容器”放在 TabView 中的,我們必須顯式設置 TabView 的高度,而不能通過設置 MaxHeightLayout 的高度來間接影響 Tabview。
為什么會這樣呢?這是由于 TabView 自身的特殊性質造成的。
比如在下面的代碼中,我們在 TabView 里放置了一個高度為 200 的圓形:
TabView {Circle().foregroundStyle(.green.gradient).frame(height: 200)
}
.tabViewStyle(.page)
盡管我們將內部圓形的高度設置為 200,明確“暗示” TabView 把自己的高度也做出相應調整 ,但 TabView 還是會無動于衷:
要想 TabView 能夠充分容納高度為 200 的圓形,我們必須將 TabView 的高度顯式設置為 200:
TabView {Circle().foregroundStyle(.green.gradient)
}
.tabViewStyle(.page)
.frame(height: 200)
換句話說,TabView 不會站在子視圖的角度考慮問題,它會完全忽略子視圖尺寸的提議,“一意孤行”。
9.3 MaxHeightLayout 的實現
上面討論的結果迫使我們必須讓自定義布局 MaxHeightLayout 想辦法將計算產生的最大高度傳遞向外給 TabView 才行。
有很多種方法可以達到目的,這里我們采用最簡單的一種:綁定(Binding)。
struct MaxHeightLayout: Layout {var spacing: CGFloat?@Binding var maxHeight: CGFloatfunc sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {let proposalWidth = proposal.width!let idealViewSizes = subviews.map { $0.sizeThatFits(.init(width: proposalWidth / CGFloat(subviews.count), height: nil)) }let totalHeight = idealViewSizes.map {$0.height}.max() ?? 0.0// 防止反復賦值造成渲染循環if totalHeight > maxHeight {maxHeight = totalHeight}return CGSize(width: proposalWidth, height: totalHeight)}private func calcSpaces(subviews: Subviews) -> [CGFloat] {if let spacing {[CGFloat](repeating: spacing, count: subviews.count - 1)} else {subviews.indices.map { idx inguard idx < subviews.count - 1 else { return 0 }return subviews[idx].spacing.distance(to: subviews[idx+1].spacing, along: .horizontal)}}}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {let spaces = calcSpaces(subviews: subviews)var point = CGPoint(x: bounds.minX, y: bounds.minY)let subviewWidth = bounds.width / CGFloat(subviews.count)for idx in subviews.indices {subviews[idx].place(at: point, proposal: .init(width: subviewWidth - spaces[idx], height: maxHeight))if idx < subviews.count - 1 {point.x += subviewWidth - spaces[idx]}}}
}
在上面的代碼中,我們主要做了這么幾件事:
- 讓 MaxHeightLayout “容器”中每個子視圖的寬都平分容器的寬度;
- 用 calcSpaces 方法計算子視圖間的空隙,并確保 placeSubviews 方法在布局子視圖時應用它們;
- 只在必要時更新 maxHeight 綁定的值(totalHeight > maxHeight 時),這是避免“遞歸渲染”的重要手段;
最后,只要將 TabView 中原來內層的 ForEach 循環以及相關邏輯放在 MaxHeightLayout 里就可以啦:
Section("喜愛的成語") {TabView {ForEach(likeIdioms.chunked(into: 2), id: \.self) { idiomChunk inVStack {MaxHeightLayout(maxHeight: $maxHeight) { ForEach(idiomChunk) { idiom inlikeIdiomCard(idiom)}if idiomChunk.count < 2 {Rectangle().foregroundStyle(.clear)}}Spacer()}}}.tabViewStyle(.page).frame(height: maxHeight).padding(.bottom, 8)
}
運行代碼可以發現結果和其它的實現毫無二致!
借助自定義布局 Layout 的靈活性,我們可以非常輕松的改變 TabView 中成語顯示的數量,比如改為 3 列也不在話下:
至此,我們圓滿完成了本系列博文中的所有任務。禿頭小伙伴們還不趕緊給自己一個大大的贊吧!愛你們哦!?
想要進一步系統地學習 Swift 開發的小伙伴們,可以來我的《Swift 語言開發精講》專欄逛一逛哦:
- 《Swift 語言開發精講》
總結
在本篇博文中,我們介紹了如何使用自定義布局 Layout 來實現 SwiftUI 視圖高度的“遙相呼應”,精彩的大結局小伙伴們不容錯過哦!
感謝觀賞,再會啦!😎