SwiftUI Layout

語言: CN / TW / HK

在介紹Layout這個iOS16的新特性之前,我們先聊點其他的。

在SwiftUI中的layout思想,跟UIKit中的佈局有點不太一樣,在UIKit中,Frame是一種絕對佈局,它的位置是相對於父View左上角的絕對座標。但在SwiftUI中,Frame這個Modifier的概念完全不同。

説到這,我們似乎有理由要介紹下在SwiftUI中的Frame

什麼是Frame

在SwiftUI 中,Frame作為一個Modifier的存在實際上並不修改視圖。大多數時候,當我們在視圖上應用修改器時,會創建一個新視圖,它添加在被“修改”的視圖周圍。可以説這個新生成的視圖就是我們的被“修改”視圖的Frame。

從這裏可以看出在SwiftUI中,View是非常廉價的。之所以敢這麼幹,也是因為在SwiftUI中,View都是值類型。

而在SwiftUI中,大多數view並沒有frame的概念,但是它們有bounds的概念,也就是説每個view都有一個範圍和大小,它們的bounds不能夠直接通過手動的方式去修改。

當某個view的frame改變後,其子視圖的size不一定會變化,比如,下面代碼中HStack容器,不管你是否添加frame,其內部Text子視圖的佈局不會發生任何變化。

var body: some View { HStack(spacing: 5) { Text("Hello, world!") .border(.red) .background(.green) } .border(.yellow) .frame(width: 300, height: 300) .border(.black) }

可以看到,SwiftUI中的View都很任性,每個view對自己需要的size,都有自己的想法 ,這裏父view提供了一個size,但是其子view會根據自身的特性,來返回一個size給父view,告訴父view需要多大空間。

簡單理解就是

  1. 父view為子view提供一個建議的size
  2. 子view根據自身的特性,返回一個size
  3. 父view根據子view返回的size為其進行佈局

這的自身特性有很多種,比如像Text,Image這種,會返回自身需要的size,而像Shape,則會返回父view建議的size。實際開發過程中,需要自己去做不同的嘗試瞭解。

這也正是SwiftUI中的佈局原則。

看一個簡單的例子:

var body: some View { Text("Hello, world") .background(Color.green) .frame(width: 200, height: 50) }

我們想象中的效果可能是:

但是實際效果是

在上邊的代碼中,.background並不會直接去修改原來的Text視圖,而是在Text圖層的下方新建了一個view。根據上面的佈局法則,.frame起的作用就是提供一個建議的size,frame為background提供了一個(200, 50)的size,background還需要去問它的child,也就是Text, Text返回了一個自身需要的size,於是background也返回了Text的實際尺寸,這就造成了綠色背景跟文本同樣大小的效果。

瞭解了這個佈局的過程,我們就明白了,要想得到上圖中理想的效果,只需要將.frame.background函數交換位置即可。

var body: some View { Text("Hello, world") .frame(width: 200, height: 50) .background(Color.green) }

思考:為什麼交換一下位置,其佈局就不同了呢?

交換了位置相當於交換了子視圖圖層位置。

梳理一下它的佈局流程(在SwiftUI中,佈局流程是從下而上的,也可以理解成是從外向內進行的):

.frame不再是為.background提供建議的size, 而是.background無法知曉自身大小,所以向子view也就是.frame詢問大小,得到的是(200,50),所以.background的大小就是(200,50),然後看Text視圖,其父View(.frame)給的建議的size為(200,50),但其只需要正好容納文本的size,因此Text的size並不會是(200,50), 可以看到下圖中的Text的size依舊和未修改代碼之前一樣。

通過上面的簡單介紹,我們大概瞭解了SwiftUI中的Frame概念, 關於Frame的更多佈局細節,這邊文章不做更深入的介紹,接下來給大家正式介紹SwiftUI Layout。

什麼是Layout Protocol

Layout是iOS16新推出來的一種佈局類型框架,該協議的功能是告訴SwiftUI 如何放置一組視圖,以及各個視圖佔用多少空間。

Layout協議和Frame不同,frame它並沒有遵循View協議,所以無法直接通過點語法進行調用,來返回ContentView的 body需要的View類型。

構建一個 Layout 類型需要我們至少實現兩個方法:sizeThatFitsplaceSubviews. 這些方法接收一些新類型作為參數:ProposedViewSizeLayoutSubview。

``` /// - Returns: A size that indicates how much space the container /// needs to arrange its subviews. func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGSize

    /// - Parameters:
///   - bounds: The region that the container view's parent allocates to the
///     container view, specified in the parent's coordinate space.
///     Place all the container's subviews within the region.
///     The size of this region matches a size that your container
///     previously returned from a call to the
///     ``sizeThatFits(proposal:subviews:cache:)`` method.
///   - proposal: The size proposal from which the container generated the
///     size that the parent used to create the `bounds` parameter.
///     The parent might propose more than one size before calling the
///     placement method, but it always uses one of the proposals and the
///     corresponding returned size when placing the container.
///   - subviews: A collection of proxies that represent the
///     views that the container arranges. Use the proxies in the collection
///     to get information about the subviews and to tell the subviews
///     where to appear.
///   - cache: Optional storage for calculated data that you can share among
///     the methods of your custom layout container. See
///     ``makeCache(subviews:)-23agy`` for details.
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache)

```

ProposedViewSize

父視圖使用ProposedViewSize來告訴子視圖如何計算自己的大小。通過官方文檔可以得知,它是一個結構體,內部有widthheight等屬性。

這些屬性可以有具體的值,但是當給他們設置一些邊界值比如0.0nil.infinity時也有特殊含義:

  • 對於一個具體的值,例如 20,父視圖正好提供20 pt,並且視圖應該為提供的寬度確定它自己的大小。
  • 對於0.0,子視圖應以其最小尺寸響應。
  • 對於 .infinity,子視圖應以其最大尺寸響應。
  • 對於nil值,子視圖應以其理想大小響應。

此外ProposedViewSize還特別提供了一些默認的值,也就是上面説的邊界值的默認實現:

` /// A size proposal that contains zero in both dimensions. /// /// Subviews of a custom layout return their minimum size when you propose /// this value using theLayoutSubview/dimensions(in:)method. /// A custom layout should also return its minimum size from the ///Layout/sizeThatFits(proposal:subviews:cache:)`` method for this /// value. public static let zero: ProposedViewSize

/// The proposed size with both dimensions left unspecified.
///
/// Both dimensions contain `nil` in this size proposal.
/// Subviews of a custom layout return their ideal size when you propose
/// this value using the ``LayoutSubview/dimensions(in:)`` method.
/// A custom layout should also return its ideal size from the
/// ``Layout/sizeThatFits(proposal:subviews:cache:)`` method for this
/// value.
public static let unspecified: ProposedViewSize

/// A size proposal that contains infinity in both dimensions.
///
/// Both dimensions contain
/// <doc://com.apple.documentation/documentation/CoreGraphics/CGFloat/1454161-infinity>
/// in this size proposal.
/// Subviews of a custom layout return their maximum size when you propose
/// this value using the ``LayoutSubview/dimensions(in:)`` method.
/// A custom layout should also return its maximum size from the
/// ``Layout/sizeThatFits(proposal:subviews:cache:)`` method for this
/// value.
public static let infinity: ProposedViewSize

```

LayoutSubview

sizeTheFitsplaceSubviews方法中還有一個參數:Layout.Subviews,該參數是LayoutSubview元素的集合。它不是一個視圖類型,而是視圖佈局的一個代理。我們可以查詢這些代理來了解我們正在佈局的各個子視圖的佈局信息。或者每個視圖的佈局優先級等等。

如何使用Layout

基礎佈局

接下來我們來看看如何使用它。

``` struct CustomLayout1: Layout { let spacing: CGFloat

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
    let spacing = spacing * CGFloat(subviews.count - 1)
    let width = spacing + idealViewSizes.reduce(0) { $0 + $1.width }
    let height = idealViewSizes.reduce(0) { max($0, $1.height) }

    return CGSize(width: width, height: height)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    var pt = CGPoint(x: bounds.minX, y: bounds.minY)

    for v in subviews {
        v.place(at: pt, anchor: .topLeading, proposal: .unspecified)

        pt.x += v.sizeThatFits(.unspecified).width + spacing
    }
}

} ```

上面的代碼在sizeThatFits函數中的含義:

  1. 首先該通過調用具有建議大小的方法來計算每個子視圖的理想大小
  2. 接着是計算子視圖的之間的間隔總和
  3. 然後是將所有子視圖的寬度累加並和加上上面計算出來的總間距來計算整個容器大小的寬度。
  4. 最後計算高度,這裏高度是取是視圖集合中最高的子視圖的高度作為容器的高度。

計算子視圖尺寸:sizeThatFits

通過上面代碼可以看出sizeThatFits函數可以告訴自定義佈局容器的父視圖,在給定的大小建議下,容器需要多少空間用來展示一組子視圖。也就是説它是用來確定CustomLayout1這個容器的大小的。另外對於這個函數的理解,我們應該認為自己既是父視圖同時又是子視圖:作為父視圖是要詢問其子視圖的尺寸。而作為子視圖時,是向其父視圖提供自己的大小。

該方法接收視圖大小建議、子視圖代理集合和緩存。緩存的作用是可以在自定義佈局容器的方法之間共享計算數據,它可能會用於提高我們的佈局和其他一些高級應用程序的性能。

sizeThatFits函數給定返回值為nil時,我們應該返回該容器的理想大小。當給定返回值是0時,我們應該返回該容器的最小size。當給定返回值是 . infinity時,我們應該返回該容器的最大size。

sizeThatFits可以根據不同的建議多次調用。對於每個維度(width , height),可以是上述情況的任意組合。例如,你完全能夠返回ProposedViewSize(width:0.0,height:.infinity)這樣的組合

佈局子視圖:placeSubviews

此方法的實現是告訴我們自定義佈局容器如何放置其子視圖。從這個方法中,調用每個子視圖的 place(at:anchor:proposal:) 方法來告訴子視圖在用户界面中出現的位置。

可以看到其接受的參數比sizeThatFits多了一個bounds。這個參數的意義就是:在父視圖的座標空間中指定和分配容器視圖的區域。將所有容器的子視圖放置在區域內。此區域的大小與先前對sizeThatFits(proposal:subviews:cache:)函數調用返回的大小是相匹配的。

在上面的代碼中:

佈局的起點是容器的左上角(0,0)。

接着遍歷子視圖,提供子視圖的座標、錨點為左上角(如果未指定,則居中佈局)和建議的大小,以便子視圖可以相應地根據提供的位置繪製自己。

子視圖大小建議:proposal

另外可以看到在sizeThatFits函數中,對於父視圖提供的建議大小proposal參數我們沒有用到,

這意味着我們的SimpleHStack容器將始終具有相同的大小。無論父視圖給出什麼樣的大小建議,容器都會使用 .unspecified計算大小和位置,也就是説SimpleHStack將始終具有理想大小。在這種情況下,容器的理想大小是讓它以自己的理想大小放置所有子視圖的大小。

我們可以給父視圖添加一行代碼來改變父視圖的大小。

var body: some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { contents() } .border(.black) SimpleHStack1(spacing: 5) { contents() } .border(.red) HStack(spacing: 5) { contents() } .border(.black) } .frame(width: 100) // 強制添加大小之後,看看自定義layout和普通的layout的區別 .background(Rectangle().stroke(.green)) .padding() .border(.red) .font(.largeTitle) }

運行代碼,我們可以看到不管 父視圖大小設置多少, SimpleHStack以其理想尺寸繪製,即適合其所有子視圖的理想尺寸。

容器對齊

Layout協議還允許我們為容器定義水平位置的對齊,這個對齊是將容器作為一個整體和其他視圖進行對齊,並非是容器內部子視圖對齊。

比如按照官方文檔的例子,將當前自定義容器往前縮進10像素:

/// Returns the position of the specified horizontal alignment guide along /// the x axis. func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGFloat? { if guide == .leading { return bounds.minX + 10 } else { return nil } }

其中guaid是指父視圖的VStack(alignment: .leading, spacing: 5)的對其方式。

佈局緩存

上面有講過這個佈局緩存, 並且SwiftUI 在佈局過程中多次調用sizeThatFitsplaceSubviews方法。因此保留不需要每次都重新計算的數據就是佈局緩存存在的意義。

Layout協議的方法採用雙向cache參數。並提供對在特定佈局實例的所有方法之間共享的可選存儲的訪問。但是使用緩存不是強制性的。事實上,SwiftUI 自己內部也做了一些緩存。例如,從子視圖代理中獲取的值會自動存儲在緩存中。使用相同參數的重複調用將使用緩存的結果。具體可以查看官方文檔makeCache(subviews:)

接下來讓我們看下是如何使用的:

  • 首先創建一個包含緩存數據的類型。它將計算視圖之間的 maxHeight 和space。

struct CacheData { var maxHeight: CGFloat var spaces: [CGFloat] }

  • 實現makeCache(subviews:)來計算一組子視圖,並返回上面定義的緩存類型。

func makeCache(subviews: Subviews) -> CacheData { print("makeCache called <<<<<<<<") return CacheData( maxHeight: computeMaxHeight(subviews: subviews), spaces: computeSpaces(subviews: subviews) ) }

  • 實現updateCache(subviews:)函數,如果子視圖發生變化(比如將APP退出後台),SwiftUI 會調用此佈局方法。該方法的默認實現再次調用,重新計算數據。它基本上通過調用 makeCache 來重新創建緩存。

func updateCache(_ cache: inout CacheData, subviews: Subviews) { print("updateCache called <<<<<<<<") cache.maxHeight = computeMaxHeight(subviews: subviews) cache.spaces = computeSpaces(subviews: subviews) }

通過打印數據可以看出,對於高度的計算確實頻率變低了。

另外可以看到這裏的layout協議並沒有遵循View協議,但是依然可以在body中返回。

這是因為Layout實現了callAsFunction函數,非常巧妙的API設計,調用起來很簡潔。

/// Combines the specified views into a single composite view using /// the layout algorithms of the custom layout container. /// /// Don't call this method directly. SwiftUI calls it when you /// instantiate a custom layout that conforms to the ``Layout`` /// protocol: /// /// BasicVStack { // Implicitly calls callAsFunction. /// Text("A View") /// Text("Another View") /// } /// /// For information about how Swift uses the `callAsFunction()` method to /// simplify call site syntax, see /// [Methods with Special Names](http://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622) /// in *The Swift Programming Language*. /// /// - Parameter content: A ``ViewBuilder`` that contains the views to /// lay out. /// /// - Returns: A composite view that combines all the input views. public func callAsFunction<V>(@ViewBuilder _ content: () -> V) -> some View where V : View

使用 AnyLayout 切換佈局

Layout容器還可以改變容器的佈局,並且自動附帶動畫而無需進行多餘的代碼處理。這個對於SwiftUI來説應該是很簡單的,因為在SwiftUI看來,這個只是一個視圖的變更,而不是兩套視圖。聽起來有點像CollectionView的Layout

我們來看下官方的demo是怎麼處理這種佈局變化的

``` struct Profile: View { @EnvironmentObject private var model: Model

var body: some View {
    // Use a horizontal layout for a tie; use a radial layout, otherwise.
    let layout = model.isAllWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())

    Podium()
        .overlay(alignment: .top) {
            layout {
                ForEach(model.pets) { pet in
                    Avatar(pet: pet)
                        .rank(model.rank(pet))
                }
            }
            .animation(.default, value: model.pets)
        }
}

} ```

可以看到這裏是通過定義了一個AnyLayout用來做類型擦除,通過變量寵物投票結果的變動來動態更新視圖。

高級使用

自定義動畫

我們來模仿利用CollectionView製作的一組旋轉照片展示器。

首先繪製出一組圓形的矩形

``` struct SimpleHStackLayoutAnimated: View { let colors: [Color] = [.yellow, .orange, .red, .pink, .purple, .blue, .cyan, .green]

var body: some View {
    WheelLayout(radius: 130.0, rotation: .zero) {
        ForEach(0..<8) { idx in
            RoundedRectangle(cornerRadius: 8)
                .fill(colors[idx%colors.count].opacity(0.7))
                .frame(width: 70, height: 70)
                .overlay { Text("(idx+1)") }
        }
    }
}

} ```

可以看到這裏初始化出來了8個不同顏色的矩形,並且標記上對應的index。

接着通過Layout容器來對各個子視圖進行佈局,使他們間隔的旋轉角度保持一致。

``` struct WheelLayout: Layout { var radius: CGFloat var rotation: Angle

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

    let maxSize = subviews.map { $0.sizeThatFits(proposal) }.reduce(CGSize.zero) {

        return CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height))
    }

    return CGSize(width: (maxSize.width / 2 + radius) * 2,
                  height: (maxSize.height / 2 + radius) * 2)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
{
    let angleStep = (Angle.degrees(360).radians / Double(subviews.count))

    for (index, subview) in subviews.enumerated() {
        let angle = angleStep * CGFloat(index) + rotation.radians

        // 給當前座標做一個角度的映射
        var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle))

        // 在第一個view的基礎上再依次進行角度旋轉
        point.x += bounds.midX
        point.y += bounds.midY

        subview.place(at: point, anchor: .center, proposal: .unspecified)
    }
}

} ```

其最後靜態的效果如下:

接着我們添加一個按鈕,來觸發這個矩形的旋轉。

設置旋轉角度

@State var angle: Angle = .zero

添加button按鈕來控制角度的變化,然後將角度傳遞到WheelLayout容器中

``` var body: some View { WheelLayout(radius: 130.0, rotation: angle) { ... }

    Button("Rotate") {
        withAnimation(.easeInOut(duration: 2.0)) {
            self.angle = (angle == .zero ? .degrees(90) : .zero)
        }
    }
}

```

這裏設置了旋轉90°,可以看到最後的效果。

暫時無法在飛書文檔外展示此內容

這個動畫的效果是系統默認的,我們來探究下具體的動畫軌跡,看下系統是怎麼做這個動畫的。

單獨看矩形1的變化,可以看到它是以中心點沿着矩形1到矩形3組成的直角的斜邊這條一條直線完成移動的。

那整體的運行軌跡就是:

也就是説,系統計算出來了每個矩形的起始位置和終點位置,然後在動畫期間內插入它們的位置,進行兩點之間的直線平移,按照這個假設,如果旋轉的角度是360°,那麼起點會和終點重合,也就是沒有任何動畫效果產生。

將angle設置為360°,查看效果確實如此。

那如果我們不想這樣的軌跡移動,想沿着每個矩形的中心點的軌跡然後圍繞這個WheetLayout中心移動呢?類似下圖紅色的軌跡:

我們可以用到Animatable協議,使用動畫路徑來繪製。

// Step4 路徑動畫, Layout遵循了Animatable協議,因此可以實現改動畫模型,告訴系統在執行動畫過程中需要插入的值 var animatableData: AnimatablePair<CGFloat, CGFloat> { get { AnimatablePair(rotation.radians, radius) } set { rotation = Angle.radians(newValue.first) radius = newValue.second } }

animatableDataAnimatable協議的一個屬性,好在Layout遵循了Animatable協議,因此可以直接實現該動畫模型,告訴SwiftUI在執行動畫過程中需要插入的值。

可以看到這個半徑和旋轉角度之前是外部傳進來的,但是現在通過動畫模型在每次執行動畫的時候都變更這個rotation屬性,而半徑不變。 就相當於告訴系統,每次在終點和起點的位置之間每次動畫旋轉的角度值。這就可以達到動畫路徑是按照上面的圓路徑來執行。

關於animatableData的理解:這個在網上搜了很多資料,包括官方文檔的描述都是很模糊的,以下是我個人對一些疑問的理解,歡迎補充。

  1. 它是怎麼知道對哪個屬性做動畫的:How does Animatable know which pro… | Apple Developer Forums

    1. 這個個人理解是的你定義的變量,以及與這個變量計算有關的相關UI屬性

      1. 比如上面的point是通過rotationradius計算出來的,所以最終的動畫作用是在point上。
  2. 系統如何知道animatableData在狀態發生變化時應該插入哪些屬性What does animatableData in SwiftUI do?

    1. 這個個人理解是首先如果你實現了animatableData屬性,那麼系統會通過get函數來獲取動畫模型的組成,然後通過返回原始的插值(newValue)(我們可以通過代碼看到,如果不對rotation進行計算,那麼這個動畫就是默認的動畫,也就是沿着直角斜邊運動,這就可以認為是原始的插值)。通過set來計算自定義的動畫路徑插值(幀),也就是我們想要的通過弧度來運行,這個rotation是不斷變化的,而之前的rotation要麼是90°要麼是0°。

小實驗:

將上面demo的radius也通過變量來控制,就可以看到最終動畫是一邊弧度一邊往外擴大或者縮小半徑來進行運動的。

文獻資料

http://developer.apple.com/documentation/swiftui/composing_custom_layouts_with_swiftui

http://developer.apple.com/documentation/swiftui/layout

http://www.hackingwithswift.com/articles/217/complete-guide-to-layout-in-swiftui

http://developer.apple.com/documentation/swiftui/viewmodifier

http://swiftui-lab.com/layout-protocol-part-1/

http://swiftui-lab.com/frame-behaviors/