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](https://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也通过变量来控制,就可以看到最终动画是一边弧度一边往外扩大或者缩小半径来进行运动的。

文献资料

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

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

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

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

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

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