在介绍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)
}

咱们幻想中的作用可能是:

SwiftUI Layout

可是实践作用是

SwiftUI Layout

在上边的代码中,.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 Layout

经过上面的简单介绍,咱们大约了解了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 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

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

经过打印数据能够看出,关于高度的核算的确频率变低了。

SwiftUI Layout

别的能够看到这儿的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)
        }
    }
}

其终究静态的作用如下:

SwiftUI Layout

接着咱们增加一个按钮,来触发这个矩形的旋转。

设置旋转视点

@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组成的直角的斜边这条一条直线完成移动的。

SwiftUI Layout

那全体的运转轨道便是:

SwiftUI Layout

也便是说,体系核算出来了每个矩形的起始方位和终点方位,然后在动画期间内插入它们的方位,进行两点之间的直线平移,按照这个假设,假如旋转的视点是360,那么起点会和终点重合,也便是没有任何动画作用产生。

将angle设置为360,检查作用的确如此。

那假如咱们不想这样的轨道移动,想沿着每个矩形的中心点的轨道然后围绕这个WheetLayout中心移动呢?类似下图红色的轨道:

SwiftUI Layout

咱们能够用到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也经过变量来操控,就能够看到终究动画是一边弧度一边往外扩大或许缩小半径来进行运动的。

文献资料

developer.apple.com/documentati…

developer.apple.com/documentati…

www.hackingwithswift.com/articles/21…

developer.apple.com/documentati…

swiftui-lab.com/layout-prot…

swiftui-lab.com/frame-behav…