SwiftUI 佈局 —— 尺寸( 下 )

語言: CN / TW / HK

highlight: a11y-dark

上篇 中,我們對 SwiftUI 佈局過程中涉及的眾多尺寸概念進行了說明。本篇中,我們將通過對檢視修飾器 frame 和 offset 的仿製進一步加深對 SwiftUI 佈局機制的理解,並通過一些示例展示在佈局時需要注意的問題。

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

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

相同的長相、不同的內涵

在 SwiftUI 中,我們可以利用不同的佈局容器生成看起來幾乎一樣的顯示結果。例如,無論是 ZStack、overlay、background、VStack、HStack 都可以實現下圖的版式。

image-20220715153543755

以 ZStack、overlay、background 舉例:

```swift struct HeartView: View { var body: some View { Circle() .fill(.yellow) .frame(width: 30, height: 30) .overlay(Image(systemName: "heart").foregroundColor(.red)) } }

struct ButtonView: View { var body: some View { RoundedRectangle(cornerRadius: 12) .fill(Color.blue.gradient) .frame(width: 150, height: 50) } }

// ZStack struct IconDemo1: View { var body: some View { ZStack(alignment: .topTrailing) { ButtonView() HeartView() .alignmentGuide(.top, computeValue: { $0.height / 2 }) .alignmentGuide(.trailing, computeValue: { $0.width / 2 }) } } }

// overlay struct IconDemo2: View { var body: some View { ButtonView() .overlay(alignment: .topTrailing) { HeartView() .alignmentGuide(.top, computeValue: { $0.height / 2 }) .alignmentGuide(.trailing, computeValue: { $0.width / 2 }) } } }

// background struct IconDemo3: View { var body: some View { HeartView() .background(alignment:.center){ ButtonView() .alignmentGuide(HorizontalAlignment.center, computeValue: {$0[.trailing]}) .alignmentGuide(VerticalAlignment.center, computeValue: {$0[.top]}) } } } ```

雖然 IconDemo1、IconDemo2、IconDemo3 在單獨預覽時看起來完全一樣,但如果將它們放置到其他的佈局容器中,你會發現它們在容器內的佈局後的擺放結果明顯不同 —— 需求尺寸的構成和大小不一樣( 下圖中,用紅框標註了各自的需求尺寸 )。

image-20220715162600792

佈局容器在規劃自身的需求尺寸上的策略不同是造成上述現象的原因。

像 ZStack、VStack、HStack 這幾個容器,它們的需求尺寸是由其全部子檢視按照指定的佈局指南進行擺放後的獲得的總尺寸所構成的。而 overlay 和 background 的需求尺寸則完全取決於它們的主檢視( 本例中,overlay 的需求尺寸由 ButtonView 決定,background 的需求尺寸由 HeartView 決定 )。假設當前的設計需求是想將 ButtonView 和 HeartView 視作一個整體進行佈局,那麼 ZStack 是一個不錯的選擇。

每種容器都有其適合的場景,例如對於如下需求 —— 建立類似視訊 app 中的點贊功能的子檢視( 在佈局時,僅需考慮手勢圖示的位置和尺寸),overlay 這種需求尺寸僅依賴於主檢視的容器便有了用武之地:

```swift struct FavoriteDemo: View { var body: some View { ZStack(alignment: .bottomTrailing) { Rectangle() .fill(Color.cyan.gradient.opacity(0.5)) Favorite() .alignmentGuide(.bottom, computeValue: { $0[.bottom] + 200 }) .alignmentGuide(.trailing, computeValue: { $0[.trailing] + 100 }) } .ignoresSafeArea() } }

struct Favorite: View { @State var hearts = (String, CGFloat, CGFloat) var body: some View { Image(systemName: "hand.thumbsup") .symbolVariant(.fill) .foregroundColor(.blue) .font(.title) .overlay(alignment: .bottom) { ZStack { Color.clear ForEach(hearts, id: .0) { heart in Text("+1") .font(.title) .foregroundColor(.white) .bold() .transition(.asymmetric(insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity))) .offset(x: heart.1, y: heart.2) .task { try? await Task.sleep(nanoseconds: 500000000) if let index = hearts.firstIndex(where: { $0.0 == heart.0 }) { let _ = withAnimation(.easeIn) { hearts.remove(at: index) } } } } } .frame(width: 50, height: 100) .allowsHitTesting(false) } .onTapGesture { withAnimation(.easeOut) { hearts.append((UUID().uuidString, .random(in: -10...10), .random(in: -10...10))) } } } } ```

iShot_2022-07-16_09.06.08.2022-07-16 09_07_08

相同長相的檢視,未必有相同的內涵。當用佈局容器建立合成檢視時,必須將構成後的合成檢視對父容器的佈局影響考慮到其中。針對不同的需求,選擇恰當的容器。

面子和裡子

與 UIKit 和 AppKit 類似,SwiftUI 的佈局操作是在檢視層面( 裡子 )進行的,而所有針對關聯圖層( backing layer )的操作仍是通過 Core Animation 來完成的。因此,針對 CALayer( 面子 )直接做出的調整,SwiftUI 的佈局系統是無法感知的。

而這種在佈局之後、渲染之前對內容進行調整的操作,大量存在於 SwiftUI 之中,例如:offset、scaleEffect、rotationEffect、shadow、background、cornerRadius 等操作都是在此階段進行的。

例如:

swift struct OffsetDemo1:View{ var body: some View{ HStack{ Rectangle() .fill(.orange.gradient) .frame(maxWidth:.infinity) Rectangle() .fill(.green.gradient) .frame(maxWidth:.infinity) Rectangle() .fill(.cyan.gradient) .frame(maxWidth:.infinity) } .border(.red) } }

image-20220716102117190

我們使用 offset 調整中間矩形的位置,並不會對 HStack 的尺寸造成任何影響,在此種情況下,面子和裡子是脫節的:

swift Rectangle() .fill(.green.gradient) .frame(width: 100, height: 50) .border(.blue) .offset(x: 30, y: 30) .border(.green)

image-20220716102351620

在 SwiftUI 中,offset 修飾符對應的是 Core Animation 中的 CGAffineTransform 操作。.offset(x: 30, y: 30) 相當於 .transformEffect(.init(translationX: 30, y: 30))。這種直接在 CALayer 層面進行的修改,並不會對佈局造成影響

上面或許就是你想要的效果,但如果想實現讓位移後的檢視能夠對它的父檢視( 容器 )的佈局有所影響 ,或許就需要換一種方式 —— 用佈局容器而非 Core Animtion 操作:

swift // 通過 padding Rectangle() .fill(.green.gradient) .frame(width: 100, height: 50) .border(.blue) .padding(EdgeInsets(top: 30, leading: 30, bottom: 0, trailing: 0)) .border(.green)

image-20220716103047458

或者:

```swift // 通過 frame Rectangle() .fill(.green.gradient) .frame(width: 100, height: 50) .border(.blue) .frame(width: 130, height: 80, alignment: .bottomTrailing) .border(.green)

// 通過 position Rectangle() .fill(.green.gradient) .frame(width: 100, height: 50) .border(.blue) .position(x: 80, y: 55) .frame(width: 130, height: 80) .border(.green) ```

相較於 offset 檢視修飾器,由於沒有現成的可替換手段,想讓 rotationEffect 修改後的結果反過來影響佈局則要略顯煩瑣:

swift struct RotationDemo: View { var body: some View { HStack(alignment: .center) { Text("HI") .border(.red) Text("Hello world") .fixedSize() .border(.yellow) .rotationEffect(.degrees(-40)) .border(.red) } .border(.blue) } }

image-20220716104438958

```swift extension View { func rotationEffectWithFrame(_ angle: Angle) -> some View { modifier(RotationEffectWithFrameModifier(angle: angle)) } }

struct RotationEffectWithFrameModifier: ViewModifier { let angle: Angle @State private var size: CGSize = .zero var bounds: CGRect { CGRect(origin: .zero, size: size) .offsetBy(dx: -size.width / 2, dy: -size.height / 2) .applying(.init(rotationAngle: CGFloat(angle.radians))) }

func body(content: Content) -> some View {
    content
        .rotationEffect(angle)
        .background(
            GeometryReader { proxy in
                Color.clear
                    .task(id: proxy.frame(in: .local)) {
                        size = proxy.size
                    }
            }
        )
        .frame(width: bounds.width, height: bounds.height)
}

}

truct RotationDemo: View { var body: some View { HStack(alignment: .center) { Text("HI") .border(.red) Text("Hello world") .fixedSize() .border(.yellow) .rotationEffectWithFrame(.degrees(-40)) .border(.red) } .border(.blue) } } ```

image-20220716104820339

scaleEffect 也可以用類似的方式實現以影響原有的佈局

在 SwiftUI 中,開發者在對檢視進行調整前需要清楚該操作是針對裡子( 基於佈局機制 )還是面子( 在 CALayer 層面),或者是想通過對面子的修改進而影響裡子,只有這樣,才能讓最終的呈現效果與預期的佈局一致。

從模仿中學習

本章中,我們將通過使用 Layout 協議實現對 frame 和 offset 的仿製以加深對佈局過程中的不同尺寸概念的認識。

有關 frame、offset 的佈局邏輯在上篇中已有描述,本文僅對關鍵程式碼進行說明。可在 此處獲取 本文的仿製程式碼

frame

SwiftUI 中有兩個版本的 frame,本節我們將仿製 frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center)

frame 檢視修飾器本質上是對佈局容器 _FrameLayout 的包裝,本例中我們將自定義的佈局容器命名為 MyFrameLayout ,檢視修飾器命名為 myFrame 。

用 viewModifier 包裝佈局容器

在 SwiftUI 中,通常需要對佈局容器進行二次包裝後再使用。例如 _VStackLayout 被包裝成 VStack,_FrameLayout 被包裝成 frame 檢視修飾器。

這種包裝行為的作用為( 以 MyFrameLayout 舉例 ):

  • 簡化程式碼

改善由 Layout 協議的 callAsFunction 所帶來的多括號問題

  • 預處理子檢視

SwiftUI 佈局 —— 對齊 一文中我們已經介紹了“對齊”是發生在容器中子檢視之間的行為,因此對於 _FrameLayout 這種開發者只提供一個子檢視同時又需要對齊的佈局容器,我們需要通過在 modifier 中新增一個 Color.clear 檢視來解決對齊物件不足的問題

```swift private struct MyFrameLayout: Layout, ViewModifier { let width: CGFloat? let height: CGFloat? let alignment: Alignment

func body(content: Content) -> some View {
    MyFrameLayout(width: width, height: height, alignment: alignment)() { // 由於 callAsFunction 所導致的多括號
        Color.clear // 新增用於輔助對齊的檢視
        content
    }
}

}

public extension View { func myFrame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { self .modifier(MyFrameLayout(width: width, height: height, alignment: alignment)) }

@available(*, deprecated, message: "Please pass one or more parameters.")
func myFrame() -> some View {
    modifier(MyFrameLayout(width: nil, height: nil, alignment: .center))
}

} ```

frame(width:,height:) 的實現

這一版本的 frame 有如下功能:

  • 當兩個維度都設定了具體值時,將使用這兩個值作為 _FrameLayout 容器的需求尺寸,以及子檢視的佈局尺寸
  • 當只有一個維度設定了具體值 A,則將該值 A 作為 _FrameLayout 容器在該維度上的需求尺寸,另一維度的需求尺寸則使用子檢視在該維度上的需求尺寸( 以 A 及 _FrameLayout 獲得的建議尺寸作為子檢視的建議尺寸 )

```swift func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFrameLayout directly") } var result: CGSize = .zero

if let width, let height { // 兩個維度都有設定
    result = .init(width: width, height: height)
}

if let width, height == nil {  // 僅寬度有設定
    let contentHeight = content.sizeThatFits(.init(width: width, height: proposal.height)).height // 子檢視在該維度上的需求尺寸
    result = .init(width: width, height: contentHeight)
}

if let height, width == nil {
    let contentWidth = content.sizeThatFits(.init(width: proposal.width, height: height)).width
    result = .init(width: contentWidth, height: height)
}

if height == nil, width == nil {
    result = content.sizeThatFits(proposal)
}

return result

} ```

在 placeSubviews 中,我們將利用 modifier 中新增的輔助檢視,對子檢視進行對齊擺放。

swift func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { guard subviews.count == 2, let background = subviews.first, let content = subviews.last else { fatalError("Can't use MyFrameLayout directly") } // 在 bounds 中滿鋪 Color.clear background.place(at: .zero, anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height)) // 獲取 Color.clear 對齊指南的位置 let backgroundDimensions = background.dimensions(in: .init(width: bounds.width, height: bounds.height)) let offsetX = backgroundDimensions[alignment.horizontal] let offsetY = backgroundDimensions[alignment.vertical] // 獲取子檢視對齊指南的位置 let contentDimensions = content.dimensions(in: .init(width: bounds.width, height: bounds.height)) // 計算 content 的 topLeading 偏移量 let leading = offsetX - contentDimensions[alignment.horizontal] + bounds.minX let top = offsetY - contentDimensions[alignment.vertical] + bounds.minY content.place(at: .init(x: leading, y: top), anchor: .topLeading, proposal: .init(width: bounds.width, height: bounds.height)) }

現在我們已經可以在檢視中使用 myFrame 替代 frame ,並實現完全一樣的效果。

fixedSize

fixedSize 為子檢視的特定維度提供未指定模式( nil )的建議尺寸,以使其在該維度上將理想尺寸作為其需求尺寸返回,並以該尺寸作為自身的需求尺寸返回給父檢視。

```swift private struct MyFixedSizeLayout: Layout, ViewModifier { let horizontal: Bool let vertical: Bool

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 // 如果 horizontal 為 true 則提交非指定模式的建議尺寸,否則則提供父檢視在改維度上的建議尺寸
    let height = vertical ? nil : proposal.height // 如果 vertical 為 true 則提交非指定模式的建議尺寸,否則則提供父檢視在改維度上的建議尺寸
    let size = content.sizeThatFits(.init(width: width, height: height)) // 向子檢視提交上方確定的建議尺寸,並獲取子檢視的需求尺寸
    return size // 以子檢視的需求尺寸作為 MyFixedSizeLayout 容器的需求尺寸
}

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))
}

func body(content: Content) -> some View {
    MyFixedSizeLayout(horizontal: horizontal, vertical: vertical)() {
        content
    }
}

}

public extension View { func myFixedSize(horizontal: Bool, vertical: Bool) -> some View { modifier(MyFixedSizeLayout(horizontal: horizontal, vertical: vertical)) }

func myFixedSize() -> some View {
    myFixedSize(horizontal: true, vertical: true)
}

} ```

又見 frame

鑑於兩個版本的 frame 無論在功能上還是實現上均有巨大的不同,因此在 SwiftUI 中它們分別對應著不同的佈局容器。 frame(minWidth:, idealWidth: , maxWidth: , minHeight: , idealHeight:, maxHeight: , alignment:) 是對佈局容器 _FlexFrameLayout 的二次包裝。

_FlexFrameLayout 實際上是兩個功能的結合體:

  • 在設定了 ideal 值且父檢視的在該維度上提供了未指定模式的建議尺寸時,以 ideal value 作為需求尺寸返回,並將其作為子檢視的佈局尺寸
  • 當 min 或( 和 ) max 有值時,會按如下規則返回 _FlexFrameLayout 的在該維度上的需求尺寸( 下圖來自於 SwiftUI-Lab

frame-flow-chart

```swift func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { guard subviews.count == 2, let content = subviews.last else { fatalError("Can't use MyFlexFrameLayout directly") }

var resultWidth: CGFloat = 0
var resultHeight: CGFloat = 0

let contentWidth = content.sizeThatFits(proposal).width // 以父檢視的建議尺寸為建議尺寸,獲取子檢視在寬度上的需求尺寸
// idealWidth 有值,且父檢視在寬度上的建議尺寸為未指定模式,需求寬度為 idealWidth
if let idealWidth, proposal.width == nil {
    resultWidth = idealWidth
} else if minWidth == nil, maxWidth == nil { // min 和 max 均沒有指定,返回子檢視在寬度上的需求尺寸
    resultWidth = contentWidth
} else if let minWidth, let maxWidth { // min 和 max 都有值時
        resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
} else if let minWidth { // min 有值時,確保需求尺寸不小於最小值
    resultWidth = clamp(min: minWidth, max: maxWidth, source: contentWidth)
} else if let maxWidth { // max 有值時,確保需求尺寸不大於最大值
    resultWidth = clamp(min: minWidth, max: maxWidth, source: proposal.width ?? contentWidth)
}

// 將上面確定的需求寬度作為建議寬度,獲取子檢視的需求高度
let contentHeight = content.sizeThatFits(.init(width: proposal.width == nil ? nil : resultWidth, height: proposal.height)).height
if let idealHeight, proposal.height == nil {
    resultHeight = idealHeight
} else if minHeight == nil, maxHeight == nil {
    resultHeight = contentHeight
} else if let minHeight, let maxHeight {
        resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
} else if let minHeight {
    resultHeight = clamp(min: minHeight, max: maxHeight, source: contentHeight)
} else if let maxHeight {
    resultHeight = clamp(min: minHeight, max: maxHeight, source: proposal.height ?? contentHeight)
}

let size = CGSize(width: resultWidth, height: resultHeight)
return size

}

// 將值限制在最小和最大之間 func clamp(min: CGFloat?, max: CGFloat?, source: CGFloat) -> CGFloat { var result: CGFloat = source if let min { result = Swift.max(source, min) } if let max { result = Swift.min(source, max) } return result } ```

在 View 擴充套件中需要判斷 min、ideal、max 的值是否滿足了升序要求:

```swift public extension View { func myFrame(minWidth: CGFloat? = nil, idealWidth: CGFloat? = nil, maxWidth: CGFloat? = nil, minHeight: CGFloat? = nil, idealHeight: CGFloat? = nil, maxHeight: CGFloat? = nil, alignment: Alignment = .center) -> some View { // 判斷是否 min < ideal < max func areInNondecreasingOrder( _ min: CGFloat?, _ ideal: CGFloat?, _ max: CGFloat? ) -> Bool { let min = min ?? -.infinity let ideal = ideal ?? min let max = max ?? ideal return min <= ideal && ideal <= max }

    // SwiftUI 官方實現在數值錯誤的情況下仍會執行,但會在控制檯顯示錯誤資訊。
    if !areInNondecreasingOrder(minWidth, idealWidth, maxWidth)
        || !areInNondecreasingOrder(minHeight, idealHeight, maxHeight) {
        fatalError("Contradictory frame constraints specified.")
    }

    return modifier(MyFlexFrameLayout(minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth, minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight, alignment: alignment))
}

} ```

總結

Layout 協議為我們提供了一個絕佳的可以深入瞭解 SwiftUI 佈局機制的視窗,無論你在未來的工作中是否需要使用 Layout 協議建立自定義佈局容器,掌握它都將獲得莫大的好處。

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

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

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

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