SwiftUI Layout
在介紹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需要多大空間。
簡單理解就是
- 父view為子view提供一個建議的size
- 子view根據自身的特性,返回一個size
- 父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 型別需要我們至少實現兩個方法:sizeThatFits
和placeSubviews
. 這些方法接收一些新型別作為引數:ProposedViewSize和LayoutSubview。
``` /// - 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
來告訴子檢視如何計算自己的大小。通過官方文件可以得知,它是一個結構體,內部有width
,height
等屬性。
這些屬性可以有具體的值,但是當給他們設定一些邊界值比如0.0、nil或 .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 the
LayoutSubview/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
sizeTheFits
和placeSubviews
方法中還有一個引數: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
函式中的含義:
- 首先該通過呼叫具有建議大小的方法來計算每個子檢視的理想大小
- 接著是計運算元檢視的之間的間隔總和
- 然後是將所有子檢視的寬度累加並和加上上面計算出來的總間距來計算整個容器大小的寬度。
- 最後計算高度,這裡高度是取是檢視集合中最高的子檢視的高度作為容器的高度。
計運算元檢視尺寸: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 在佈局過程中多次呼叫sizeThatFits
和placeSubviews
方法。因此保留不需要每次都重新計算的資料就是佈局快取存在的意義。
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
}
}
animatableData
Animatable協議的一個屬性,好在Layout遵循了Animatable協議,因此可以直接實現該動畫模型,告訴SwiftUI在執行動畫過程中需要插入的值。
可以看到這個半徑和旋轉角度之前是外部傳進來的,但是現在通過動畫模型在每次執行動畫的時候都變更這個rotation
屬性,而半徑不變。 就相當於告訴系統,每次在終點和起點的位置之間每次動畫旋轉的角度值。這就可以達到動畫路徑是按照上面的圓路徑來執行。
關於animatableData
的理解:這個在網上搜了很多資料,包括官方文件的描述都是很模糊的,以下是我個人對一些疑問的理解,歡迎補充。
-
它是怎麼知道對哪個屬性做動畫的:How does Animatable know which pro… | Apple Developer Forums
-
這個個人理解是的你定義的變數,以及與這個變數計算有關的相關UI屬性
- 比如上面的point是通過
rotation
和radius
計算出來的,所以最終的動畫作用是在point上。
- 比如上面的point是通過
-
-
系統如何知道
animatableData
在狀態發生變化時應該插入哪些屬性What does animatableData in SwiftUI do?- 這個個人理解是首先如果你實現了
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/