SwiftUI Layout 協(xié)議

在介紹Layout這個(gè)iOS16的新特性之前秩贰,我們先聊點(diǎn)其他的吆录。

在SwiftUI中的layout思想,跟UIKit中的布局有點(diǎn)不太一樣愧口,在UIKit中舞虱,F(xiàn)rame是一種絕對布局欢际,它的位置是相對于父View左上角的絕對坐標(biāo)。但在SwiftUI中矾兜,F(xiàn)rame這個(gè)Modifier的概念完全不同损趋。

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

什么是Frame

在SwiftUI 中椅寺,F(xiàn)rame作為一個(gè)Modifier的存在實(shí)際上并不修改視圖浑槽。大多數(shù)時(shí)候蒋失,當(dāng)我們在視圖上應(yīng)用修改器時(shí),會創(chuàng)建一個(gè)新視圖桐玻,它添加在被“修改”的視圖周圍篙挽。可以說這個(gè)新生成的視圖就是我們的被“修改”視圖的Frame畸冲。

從這里可以看出在SwiftUI中嫉髓,View是非常廉價(jià)的。

而在SwiftUI中邑闲,大多數(shù)view并沒有frame的概念,但是它們有bounds的概念梧油,也就是說每個(gè)view都有一個(gè)范圍和大小苫耸,它們的bounds不能夠直接通過手動(dòng)的方式去修改。

當(dāng)某個(gè)view的frame改變后儡陨,其子視圖的size不一定會變化褪子,比如,下面代碼中HStack容器骗村,不管你是否添加frame嫌褪,其內(nèi)部Text子視圖的布局不會發(fā)生任何變化。

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都很任性笼痛,每個(gè)view對自己需要的size,都有自己的想法琅拌,這里父view提供了一個(gè)size,但是其子view會根據(jù)自身的特性缨伊,來返回一個(gè)size給父view,告訴父view需要多大空間进宝。

簡單理解就是

  1. 父view為子view提供一個(gè)建議的size

  2. 子view根據(jù)自身的特性刻坊,返回一個(gè)size

  3. 父view根據(jù)子view返回的size為其進(jìn)行布局

這的自身特性有很多種,比如像Text党晋,Image這種谭胚,會返回自身需要的size,而像Shape未玻,則會返回父view建議的size灾而。實(shí)際開發(fā)過程中,需要自己去做不同的嘗試了解深胳。

這也正是SwiftUI中的布局原則绰疤。

看一個(gè)簡單的例子:

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

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

image.png

但是實(shí)際效果是

image.png

在上邊的代碼中,.background并不會直接去修改原來的Text視圖舞终,而是在Text圖層的下方新建了一個(gè)view轻庆。根據(jù)上面的布局法則癣猾,.frame起的作用就是提供一個(gè)建議的size,frame為background提供了一個(gè)(200, 50)的size余爆,background還需要去問它的child纷宇,也就是Text, Text返回了一個(gè)自身需要的size蛾方,于是background也返回了Text的實(shí)際尺寸像捶,這就造成了綠色背景跟文本同樣大小的效果。

了解了這個(gè)布局的過程桩砰,我們就明白了拓春,要想得到上圖中理想的效果,只需要將.frame.background函數(shù)交換位置即可亚隅。

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

思考:為什么交換一下位置硼莽,其布局就不同了呢?

交換了位置相當(dāng)于交換了子視圖圖層位置煮纵。

梳理一下它的布局流程(在SwiftUI中懂鸵,布局流程是從下而上的,也可以理解成是從外向內(nèi)進(jìn)行的):

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

image.png

通過上面的簡單介紹采幌,我們大概了解了SwiftUI中的Frame概念, 關(guān)于Frame的更多布局細(xì)節(jié),這邊文章不做更深入的介紹震桶,接下來給大家正式介紹SwiftUI Layout休傍。

什么是Layout Protocol

Layout是iOS16新推出來的一種布局類型框架,該協(xié)議的功能是告訴SwiftUI 如何放置一組視圖蹲姐,以及各個(gè)視圖占用多少空間磨取。

Layout協(xié)議和Frame不同,frame它并沒有遵循View協(xié)議柴墩,所以無法直接通過點(diǎn)語法進(jìn)行調(diào)用忙厌,來返回ContentView的 body需要的View類型。

構(gòu)建一個(gè) Layout 類型需要我們至少實(shí)現(xiàn)兩個(gè)方法:sizeThatFitsplaceSubviews. 這些方法接收一些新類型作為參數(shù):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來告訴子視圖如何計(jì)算自己的大小逢净。通過官方文檔可以得知,它是一個(gè)結(jié)構(gòu)體,內(nèi)部有width爹土,height等屬性甥雕。

這些屬性可以有具體的值,但是當(dāng)給他們設(shè)置一些邊界值比如0.0胀茵、nil.infinity時(shí)也有特殊含義:

  • 對于一個(gè)具體的值社露,例如 20,父視圖正好提供20 pt琼娘,并且視圖應(yīng)該為提供的寬度確定它自己的大小峭弟。

  • 對于0.0,子視圖應(yīng)以其最小尺寸響應(yīng)脱拼。

  • 對于.infinity瞒瘸,子視圖應(yīng)以其最大尺寸響應(yīng)。

  • 對于nil值熄浓,子視圖應(yīng)以其理想大小響應(yīng)挨务。

此外ProposedViewSize還特別提供了一些默認(rèn)的值,也就是上面說的邊界值的默認(rèn)實(shí)現(xiàn):

    /// 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方法中還有一個(gè)參數(shù):Layout.Subviews玉组,該參數(shù)是LayoutSubview元素的集合。它不是一個(gè)視圖類型丁侄,而是視圖布局的一個(gè)代理惯雳。我們可以查詢這些代理來了解我們正在布局的各個(gè)子視圖的布局信息『枰。或者每個(gè)視圖的布局優(yōu)先級等等石景。

如何使用Layout

基礎(chǔ)布局

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

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函數(shù)中的含義:

  1. 首先該通過調(diào)用具有建議大小的方法來計(jì)算每個(gè)子視圖的理想大小

  2. 接著是計(jì)算子視圖的之間的間隔總和

  3. 然后是將所有子視圖的寬度累加并和加上上面計(jì)算出來的總間距來計(jì)算整個(gè)容器大小的寬度拙吉。

  4. 最后計(jì)算高度潮孽,這里高度是取是視圖集合中最高的子視圖的高度作為容器的高度。

計(jì)算子視圖尺寸:sizeThatFits

通過上面代碼可以看出sizeThatFits函數(shù)可以告訴自定義布局容器的父視圖筷黔,在給定的大小建議下往史,容器需要多少空間用來展示一組子視圖。也就是說它是用來確定CustomLayout1這個(gè)容器的大小的佛舱。另外對于這個(gè)函數(shù)的理解椎例,我們應(yīng)該認(rèn)為自己既是父視圖同時(shí)又是子視圖:作為父視圖是要詢問其子視圖的尺寸。而作為子視圖時(shí)请祖,是向其父視圖提供自己的大小订歪。

該方法接收視圖大小建議、子視圖代理集合和緩存肆捕。緩存的作用是可以在自定義布局容器的方法之間共享計(jì)算數(shù)據(jù)刷晋,它可能會用于提高我們的布局和其他一些高級應(yīng)用程序的性能。

當(dāng)sizeThatFits函數(shù)給定返回值為nil時(shí),我們應(yīng)該返回該容器的理想大小眼虱。當(dāng)給定返回值是0時(shí)喻奥,我們應(yīng)該返回該容器的最小size。當(dāng)給定返回值是.infinity時(shí)蒙幻,我們應(yīng)該返回該容器的最大size映凳。

sizeThatFits可以根據(jù)不同的建議多次調(diào)用。對于每個(gè)維度(width , height)邮破,可以是上述情況的任意組合诈豌。例如,你完全能夠返回ProposedViewSize(width:0.0抒和,height:.infinity)這樣的組合

布局子視圖:placeSubviews

此方法的實(shí)現(xiàn)是告訴我們自定義布局容器如何放置其子視圖矫渔。從這個(gè)方法中,調(diào)用每個(gè)子視圖的 place(at:anchor:proposal:) 方法來告訴子視圖在用戶界面中出現(xiàn)的位置摧莽。

可以看到其接受的參數(shù)比sizeThatFits多了一個(gè)bounds庙洼。這個(gè)參數(shù)的意義就是:在父視圖的坐標(biāo)空間中指定和分配容器視圖的區(qū)域。將所有容器的子視圖放置在區(qū)域內(nèi)镊辕。此區(qū)域的大小與先前對sizeThatFits(proposal:subviews:cache:)函數(shù)調(diào)用返回的大小是相匹配的油够。

在上面的代碼中:

布局的起點(diǎn)是容器的左上角(0,0)。

接著遍歷子視圖征懈,提供子視圖的坐標(biāo)石咬、錨點(diǎn)為左上角(如果未指定,則居中布局)和建議的大小卖哎,以便子視圖可以相應(yīng)地根據(jù)提供的位置繪制自己鬼悠。

子視圖大小建議:proposal

另外可以看到在sizeThatFits函數(shù)中,對于父視圖提供的建議大小proposal參數(shù)我們沒有用到亏娜,

這意味著我們的SimpleHStack容器將始終具有相同的大小焕窝。無論父視圖給出什么樣的大小建議,容器都會使用.unspecified計(jì)算大小和位置维贺,也就是說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) // 強(qiáng)制添加大小之后,看看自定義layout和普通的layout的區(qū)別
        .background(Rectangle().stroke(.green))
        .padding()
        .border(.red)
        .font(.largeTitle)
    }

運(yùn)行代碼发乔,我們可以看到不管 父視圖大小設(shè)置多少熟妓, SimpleHStack以其理想尺寸繪制,即適合其所有子視圖的理想尺寸栏尚。

容器對齊

Layout協(xié)議還允許我們?yōu)槿萜鞫x水平位置的對齊起愈,這個(gè)對齊是將容器作為一個(gè)整體和其他視圖進(jìn)行對齊,并非是容器內(nèi)部子視圖對齊。

比如按照官方文檔的例子抬虽,將當(dāng)前自定義容器往前縮進(jìn)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
    }
}

其中g(shù)uaid是指父視圖的VStack(alignment: .leading, spacing: 5)的對其方式官觅。

布局緩存

上面有講過這個(gè)布局緩存, 并且SwiftUI 在布局過程中多次調(diào)用sizeThatFitsplaceSubviews方法。因此保留不需要每次都重新計(jì)算的數(shù)據(jù)就是布局緩存存在的意義阐污。

Layout協(xié)議的方法采用雙向cache參數(shù)休涤。并提供對在特定布局實(shí)例的所有方法之間共享的可選存儲的訪問。但是使用緩存不是強(qiáng)制性的笛辟。事實(shí)上功氨,SwiftUI 自己內(nèi)部也做了一些緩存。例如手幢,從子視圖代理中獲取的值會自動(dòng)存儲在緩存中捷凄。使用相同參數(shù)的重復(fù)調(diào)用將使用緩存的結(jié)果。具體可以查看官方文檔makeCache(subviews:)围来。

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

  • 首先創(chuàng)建一個(gè)包含緩存數(shù)據(jù)的類型跺涤。它將計(jì)算視圖之間的 maxHeight 和space。
struct CacheData {
    var maxHeight: CGFloat
    var spaces: [CGFloat]
}
  • 實(shí)現(xiàn)makeCache(subviews:)來計(jì)算一組子視圖监透,并返回上面定義的緩存類型桶错。
func makeCache(subviews: Subviews) -> CacheData {
    print("makeCache called <<<<<<<<")
    return CacheData(
        maxHeight: computeMaxHeight(subviews: subviews),
        spaces: computeSpaces(subviews: subviews)
    )
}
  • 實(shí)現(xiàn)updateCache(subviews:)函數(shù),如果子視圖發(fā)生變化(比如將APP退出后臺)胀蛮,SwiftUI 會調(diào)用此布局方法牛曹。該方法的默認(rèn)實(shí)現(xiàn)再次調(diào)用,重新計(jì)算數(shù)據(jù)醇滥。它基本上通過調(diào)用 makeCache 來重新創(chuàng)建緩存。
func updateCache(_ cache: inout CacheData, subviews: Subviews) {
    print("updateCache called <<<<<<<<")
     cache.maxHeight = computeMaxHeight(subviews: subviews)
     cache.spaces = computeSpaces(subviews: subviews)
 }

通過打印數(shù)據(jù)可以看出超营,對于高度的計(jì)算確實(shí)頻率變低了鸳玩。

image.png

另外可以看到這里的layout協(xié)議并沒有遵循View協(xié)議,但是依然可以在body中返回演闭。

這是因?yàn)長ayout實(shí)現(xiàn)了callAsFunction函數(shù)不跟,非常巧妙的API設(shè)計(jì),調(diào)用起來很簡潔米碰。

    /// 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容器還可以改變?nèi)萜鞯牟季治迅铮⑶易詣?dòng)附帶動(dòng)畫而無需進(jìn)行多余的代碼處理。這個(gè)對于SwiftUI來說應(yīng)該是很簡單的吕座,因?yàn)樵赟wiftUI看來虐译,這個(gè)只是一個(gè)視圖的變更,而不是兩套視圖吴趴。聽起來有點(diǎn)像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)
            }
    }
}

可以看到這里是通過定義了一個(gè)AnyLayout用來做類型擦除漆诽,通過變量寵物投票結(jié)果的變動(dòng)來動(dòng)態(tài)更新視圖。

高級使用

自定義動(dòng)畫

我們來模仿利用CollectionView制作的一組旋轉(zhuǎn)照片展示器。

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

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個(gè)不同顏色的矩形厢拭,并且標(biāo)記上對應(yīng)的index兰英。

接著通過Layout容器來對各個(gè)子視圖進(jìn)行布局,使他們間隔的旋轉(zhuǎn)角度保持一致供鸠。

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

            // 給當(dāng)前坐標(biāo)做一個(gè)角度的映射
            var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle))

            // 在第一個(gè)view的基礎(chǔ)上再依次進(jìn)行角度旋轉(zhuǎn)
            point.x += bounds.midX
            point.y += bounds.midY

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

其最后靜態(tài)的效果如下:

image.png

接著我們添加一個(gè)按鈕畦贸,來觸發(fā)這個(gè)矩形的旋轉(zhuǎn)。

設(shè)置旋轉(zhuǎn)角度

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

這里設(shè)置了旋轉(zhuǎn)90°薄坏,可以看到最后的效果是沿著1-3的直線運(yùn)行。

這個(gè)動(dòng)畫的效果是系統(tǒng)默認(rèn)的泡一,我們來探究下具體的動(dòng)畫軌跡颤殴,看下系統(tǒng)是怎么做這個(gè)動(dòng)畫的。

單獨(dú)看矩形1的變化鼻忠,可以看到它是以中心點(diǎn)沿著矩形1到矩形3組成的直角的斜邊這條一條直線完成移動(dòng)的涵但。

image.png

那整體的運(yùn)行軌跡就是:

image.png

也就是說,系統(tǒng)計(jì)算出來了每個(gè)矩形的起始位置和終點(diǎn)位置帖蔓,然后在動(dòng)畫期間內(nèi)插入它們的位置矮瘟,進(jìn)行兩點(diǎn)之間的直線平移,按照這個(gè)假設(shè)塑娇,如果旋轉(zhuǎn)的角度是360°澈侠,那么起點(diǎn)會和終點(diǎn)重合,也就是沒有任何動(dòng)畫效果產(chǎn)生埋酬。

將angle設(shè)置為360°哨啃,查看效果確實(shí)如此。

那如果我們不想這樣的軌跡移動(dòng)写妥,想沿著每個(gè)矩形的中心點(diǎn)的軌跡然后圍繞這個(gè)WheetLayout中心移動(dòng)呢拳球?類似下圖紅色的軌跡:

image.png

我們可以用到Animatable協(xié)議,使用動(dòng)畫路徑來繪制珍特。

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

animatableDataAnimatable協(xié)議的一個(gè)屬性扎筒,好在Layout遵循了Animatable協(xié)議莱找,因此可以直接實(shí)現(xiàn)該動(dòng)畫模型,告訴SwiftUI在執(zhí)行動(dòng)畫過程中需要插入的值嗜桌。

可以看到這個(gè)半徑和旋轉(zhuǎn)角度之前是外部傳進(jìn)來的奥溺,但是現(xiàn)在通過動(dòng)畫模型在每次執(zhí)行動(dòng)畫的時(shí)候都變更這個(gè)rotation屬性,而半徑不變骨宠。 就相當(dāng)于告訴系統(tǒng)谚赎,每次在終點(diǎn)和起點(diǎn)的位置之間每次動(dòng)畫旋轉(zhuǎn)的角度值淫僻。這就可以達(dá)到動(dòng)畫路徑是按照上面的圓路徑來執(zhí)行。

關(guān)于animatableData的理解:這個(gè)在網(wǎng)上搜了很多資料壶唤,包括官方文檔的描述都是很模糊的雳灵,以下是我個(gè)人對一些疑問的理解,歡迎補(bǔ)充闸盔。

  1. 它是怎么知道對哪個(gè)屬性做動(dòng)畫的:How does Animatable know which pro… | Apple Developer Forums

    1. 這個(gè)個(gè)人理解是的你定義的變量悯辙,以及與這個(gè)變量計(jì)算有關(guān)的相關(guān)UI屬性

      1. 比如上面的point是通過rotationradius計(jì)算出來的,所以最終的動(dòng)畫作用是在point上迎吵。
  2. 系統(tǒng)如何知道animatableData在狀態(tài)發(fā)生變化時(shí)應(yīng)該插入哪些屬性What does animatableData in SwiftUI do?

    1. 這個(gè)個(gè)人理解是首先如果你實(shí)現(xiàn)了animatableData屬性躲撰,那么系統(tǒng)會通過get函數(shù)來獲取動(dòng)畫模型的組成,然后通過返回原始的插值(newValue)(我們可以通過代碼看到击费,如果不對rotation進(jìn)行計(jì)算拢蛋,那么這個(gè)動(dòng)畫就是默認(rèn)的動(dòng)畫,也就是沿著直角斜邊運(yùn)動(dòng)蔫巩,這就可以認(rèn)為是原始的插值)谆棱。通過set來計(jì)算自定義的動(dòng)畫路徑插值(幀),也就是我們想要的通過弧度來運(yùn)行圆仔,這個(gè)rotation是不斷變化的垃瞧,而之前的rotation要么是90°要么是0°。

小實(shí)驗(yàn):

將上面demo的radius也通過變量來控制坪郭,就可以看到最終動(dòng)畫是一邊弧度一邊往外擴(kuò)大或者縮小半徑來進(jìn)行運(yùn)動(dòng)的个从。

文獻(xiàn)資料

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/

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市歪沃,隨后出現(xiàn)的幾起案子嗦锐,更是在濱河造成了極大的恐慌,老刑警劉巖沪曙,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件意推,死亡現(xiàn)場離奇詭異,居然都是意外死亡珊蟀,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門外驱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來育灸,“玉大人,你說我怎么就攤上這事昵宇“跽福” “怎么了?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵瓦哎,是天一觀的道長砸喻。 經(jīng)常有香客問我柔逼,道長,這世上最難降的妖魔是什么割岛? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任愉适,我火速辦了婚禮,結(jié)果婚禮上癣漆,老公的妹妹穿的比我還像新娘维咸。我一直安慰自己,他們只是感情好惠爽,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布癌蓖。 她就那樣靜靜地躺著,像睡著了一般婚肆。 火紅的嫁衣襯著肌膚如雪租副。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天较性,我揣著相機(jī)與錄音用僧,去河邊找鬼。 笑死两残,一個(gè)胖子當(dāng)著我的面吹牛永毅,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播人弓,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼沼死,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了崔赌?” 一聲冷哼從身側(cè)響起意蛀,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎健芭,沒想到半個(gè)月后县钥,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡慈迈,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年若贮,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片痒留。...
    茶點(diǎn)故事閱讀 38,100評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡谴麦,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出伸头,到底是詐尸還是另有隱情匾效,我是刑警寧澤,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布恤磷,位于F島的核電站面哼,受9級特大地震影響野宜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜魔策,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一匈子、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧代乃,春花似錦旬牲、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至堕仔,卻和暖如春擂橘,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背摩骨。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工通贞, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人恼五。 一個(gè)月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓昌罩,卻偏偏與公主長得像,于是被迫代替她去往敵國和親灾馒。 傳聞我的和親對象是個(gè)殘疾皇子茎用,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容