货架上的SwiftUI:动画。第1部分

图片

最近,我碰到了一篇新鲜的文章,他们试图用SwiftUI重现一个有趣的概念。他们所做的是:

图片

我很感兴趣地研究了他们的代码,但是遇到了一些挫折。不,不是说他们做错了什么,一点也不没有。我只是没有从他们的代码中学到几乎没有新东西。它们的实现更多是关于合并而不是动画。然后,我决定构建自己的lunopark,以在SwiftUI中编写有关动画的文章,实现大致相同的概念,但是即使不是很有效,也要使用内置动画的100%功能。学习-直到结束。做实验-眨眼间:)

这就是我得到的:


但是,为了完全公开该主题,我不得不详细介绍一些基础知识。原来的文本很大,因此,我将其分为两篇文章。这是它的第一部分-而是有关动画的一般教程,与彩虹动画没有直接关系,我将在下一篇文章中详细讨论。

在本文中,我将讨论基础知识,否则,您将很容易在更复杂的示例中感到困惑。很多东西,我会讲一下,在这种或那种形式,已经在英语文章中描述,如本系列(1234另一方面,我只专注于列举工作方式,而不是描述其工作原理。和往常一样,我做了很多实验,所以我急于分享最有趣的结果。

警告:猫下有很多图片和gif动画。

TLDR


该项目位于github上您可以在TransitionRainbowView()中看到彩虹动画的当前结果,但是我不会急于您的位置,但是我等待下一篇文章。另外,在准备它时,我会梳理一下代码。

在本文中,我们将仅讨论基础知识,并且仅影响Bases文件夹的内容。

介绍


我承认,我现在不打算写这篇文章。我有一个计划,根据这个计划,有关动画的文章应该连续第三甚至第四次。但是,我无法抗拒,我真的很想提供另一种观点。

我想马上预订。我不认为所提到的文章有任何错误,或者所使用的方法不正确。一点也不。它建立了过程(动画)的对象模型,该模型响应收到的信号开始执行某些操作。但是,对于我来说,本文很可能揭示了Combine框架的工作。是的,这个框架是SwiftUI的重要组成部分,但它更多的是关于反应式样式,而不是动画。

我的选择当然不是更优雅,更快,更容易维护。但是,它更好地揭示了SwiftUI的内幕,这的确是本文的目的-首先弄清楚。

正如我在上一篇文章中所说通过SwiftUI,我立即开始使用SwiftUI进入移动开发领域,而忽略了UIKit。这当然有价格,但是有优势。根据旧宪章,我不是要住在新修道院里。老实说,我还没有任何租船合同,因此我不会拒绝新租船。这就是为什么在我看来,这篇文章不仅对像我这样的初学者有价值,而且对于那些已经以UIKit开发形式具有背景的研究SwiftUI的人也具有价值。在我看来,很多人都缺乏新鲜感。不要做同样的事情,试图在旧图纸中安装新工具,而要根据新的可能性改变视野。

我们1c尼克以“受控形式”经历了这一过程。这是1年代世界中的一种SwiftUI,它发生于10多年前。实际上,这种类比非常准确,因为托管表单只是绘制界面的一种新方法。但是,他彻底改变了整个应用程序的客户端-服务器交互,尤其是在开发人员的脑海中。这并不容易,我本人不想研究大约5年,因为我认为那里切断的许多机会对我来说都是绝对必要的。但是,正如实践所示,在托管表单上编码不仅是可能的,而且是仅必要的。

但是,让我们不再谈论它了。我得到了详细,独立的指南,该指南没有任何参考,也没有与所提到的文章或1st过去的其他链接。我们将逐步介绍细节,功能,原理和局限性。走。

动画形状


动画一般如何工作


因此,动画的主要思想是将特定的,离散的变化转换为连续的过程。例如,圆的半径为100个单位,变为50个单位。如果没有动画,则更改将立即发生,并且带有动画-平滑。怎么运行的?很简单。为了平稳地进行更改,我们需要在``曾经是...已经成为''细分中内插几个值。对于半径,我们将必须绘制几个中间圆,其半径分别为98单位,95单位,90单位... 53单位,最后是50单位。 SwiftUI可以轻松自然地做到这一点,只需将执行此更改的代码包装在withAnimation {...}中。似乎很神奇……直到您想要实现比“ hello world”更复杂的东西。

让我们继续示例。动画的最简单和最易理解的对象被认为是形式的动画。 SwiftUI中的Shape(我仍然称其为符合shape form协议的结构)是一种结构,其参数可以适合这些边界。那些。它是具有功能主体的结构(以下简称:CGRect)->路径。绘制此形状的所有运行时所需的只是请求其轮廓(函数的结果是Path类型的对象,实际上是Bezier曲线)以获取所需大小(指定为函数参数,类型为CGRect的矩形)。

形状是存储的结构。通过对其进行初始化,您可以在参数中存储绘制轮廓所需的所有内容。可以更改此表单的选择大小,然后所需要做的就是为新的CGRect框架获取新的Path值,瞧。

让我们开始编码:

struct CircleView: View{
    var radius: CGFloat
    var body: some View{
        Circle()
            .fill(Color.green)
            .frame(height: self.radius * 2)
            .overlay(
                Text("Habra")
                    .font(.largeTitle)
                    .foregroundColor(.gray)
                )

    }
}
struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}
struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
            CircleView(radius: radius)
               .frame(height: 200)
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


因此,我们有一个圆(Circle()),可以使用滑块更改其半径。这顺利进行,因为滑块为我们提供了所有中间值。但是,当您单击“设置默认半径”按钮时,更改也不会立即发生,而是根据withAnimation(.linear(duration:1))指令进行。在没有加速的情况下线性拉伸1秒钟。类!我们掌握了动画!我们不同意:)

但是,如果我们想实现自己的形式并为其变化添加动画效果怎么办?这很难吗?让我们检查。

我复制了Circle,如下所示:

struct CustomCircle: Shape{
    public func path(in rect: CGRect) -> Path{
        let radius = min(rect.width, rect.height) / 2
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        return Path(){path in
            if rect.width > rect.height{
                path.move(to: CGPoint(x: center.x, y: 0))
                let startAngle = Angle(degrees: 270)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }else{
                path.move(to: CGPoint(x: 0, y: center.y))
                let startAngle = Angle(degrees: 0)
                path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle:  startAngle + Angle(degrees: 360), clockwise: false)
            }
            path.closeSubpath()
        }
    }
}

圆的半径计算为分配给我们的屏幕区域边框的宽度和高度中较小的一半。如果宽度大于高度,则从上边框的中间(注1)开始,沿顺时针方向描述整个圆(注2),并在此上封闭轮廓。如果高度大于宽度,则从右边框的中间开始,我们还顺时针描述整个圆并闭合轮廓。

注1
Apple ( ) . , (0, 0), (x, y), x — , y — . .. y. y — . , . 90 , 180 — , 270 — .

笔记2
1 , “ ” “ ” . Core Graphics (SwiftUI ):
In a flipped coordinate system (the default for UIView drawing methods in iOS), specifying a clockwise arc results in a counterclockwise arc after the transformation is applied.

让我们检查一下我们的新圈子将如何响应withAnimation块中的更改:

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle()
            .fill(Color.gray)
            .frame(width: self.radius * 2, height: self.radius * 2)
            .overlay(
                Text("Habr")
                    .font(.largeTitle)
                    .foregroundColor(.green)
                )
    }
}

struct CustomCircleTestView: View {
    @State var radius: CGFloat = 50
    var body: some View {
        VStack{
                HStack{
                CircleView(radius: radius)
                    .frame(height: 200)
                CustomCircleView(radius: radius)
                    .frame(height: 200)
            }
            Slider(value: self.$radius, in: 42...100)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.radius = 50
                }
            }){
                Text("set default radius")
            }
        }
    }
}


哇!我们学习了如何制作自己的自由形式的图片并为它们设置动画!是这样吗?

并不是的。这里的所有工作都是由.frame修饰符完成的(宽度:self.radius * 2,高度:self.radius * 2)。在withAnimation块{...}中,我们进行了更改变量,它将发送信号以使用新的半径值重新初始化CustomCircleView(),此新值将落入.frame()修改器中,并且该修改器已经可以对参数更改进行动画处理。我们的CustomCircle()表单会对动画做出反应,因为它不依赖于为其选择的区域的大小。更改区域随动画发生(即逐渐在其之间插入中间值成为),因此我们的圆是用相同的动画绘制的。

让我们简化一下(或仍然复杂吗?)我们的表单。我们不会根据可用区域的大小来计算半径,但会以完成的形式(即使其成为存储的结构参数。

struct CustomCircle: Shape{
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
        //let radius = min(rect.width, rect.height) / 2
...
    }
}

struct CustomCircleView: View{
    var radius: CGFloat
    var body: some View{
        CustomCircle(radius: radius)
            .fill(Color.gray)
            //.frame(height: self.radius * 2)
...
    }
}


好吧,魔术是无法挽回的丢失。

我们从CustomCircleView()中排除了frame()修饰符,将对圆的大小的责任移到了形状本身上,动画消失了。但这没关系;教一种形式来对其参数的变化进行动画处理并不难。为此,您需要实现Animatable协议的要求:

struct CustomCircle: Shape, Animatable{
    var animatableData: CGFloat{
         get{
             radius
         }
         set{
            print("new radius is \(newValue)")
            radius = newValue
         }
     }
    var radius: CGFloat
    public func path(in rect: CGRect) -> Path{
	...
}


瞧!魔术又回来了!

现在我们可以自信地说我们的表格是真正的动画-它可以通过动画反映其参数的变化。我们为系统提供了一个窗口,可以在其中填充动画所需的内插值。如果有这样的窗口,则会显示更改的动画。如果不是这样,则更改将在没有动画的情况下进行,即 即刻。没什么复杂的,对吧?

动画修饰符


如何为视图内的变化制作动画


但是,让我们直接转到View。假设我们要为容器内元素的位置设置动画。在我们的情况下,它将是一个简单的绿色矩形,宽度为10个单位。我们将为其水平位置设置动画。

struct SimpleView: View{
    @State var position: CGFloat = 0
    var body: some View{
        VStack{
            ZStack{
                Rectangle()
                    .fill(Color.gray)
                BorderView(position: position)
            }
            Slider(value: self.$position, in: 0...1)
            Button(action: {
                withAnimation(.linear(duration: 1)){
                    self.position = 0
                }
            }){
                Text("set to 0")
            }
        }
    }
}

struct BorderView: View,  Animatable{
    public var animatableData: CGFloat {
        get {
            print("Reding position: \(position)")
            return self.position
        }
        set {
            self.position = newValue
            print("setting position: \(position)")
        }
    }
    let borderWidth: CGFloat
    init(position: CGFloat, borderWidth: CGFloat = 10){
        self.position = position
        self.borderWidth = borderWidth
        print("BorderView init")
    }
    var position: CGFloat
    var body: some View{
        GeometryReader{geometry in
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
                // .borderIn(position: position)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        print("calculating position: \(position)")
        return -inSize.width / 2 + inSize.width * position
    }
}


类!作品!现在我们知道有关动画的一切!

并不是的。如果您查看控制台,那么我们将看到以下内容:
BorderView初始
计算位置:0.4595176577568054
BorderView初始
计算位置:0.468130886554718
BorderView初始
计算位置:0.0

首先,使用滑块的位置值的每次更改都会使BorderView用新值重新初始化。这就是为什么我们在滑块之后看到绿线的平滑移动,而滑块只是经常报告变量的变化,而看上去却像是动画,但事实并非如此。调试动画时,使用滑块非常方便。您可以使用它来跟踪某些过渡状态。

其次,我们看到计算位置完全等于0,并且没有中间的对数,就像正确的圆形动画一样。为什么?

就像前面的示例一样,东西在修饰符中。这次,.offset()修饰符获得了新的缩进值,并且对更改本身进行了动画处理。那些。实际上,这不是我们要设置动画的position参数的变化,而是从中得出的.offset()修饰符中的缩进的水平变化。在这种情况下,这是无害的替换,结果是相同的。但是,既然它们来了,让我们深入研究。让我们创建自己的修饰符,该修饰符将在输入处接收位置(从0到1),它将接收可用区域的大小并计算缩进量。

struct BorderPosition: ViewModifier{
    var position: CGFloat
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
            .offset(x: self.getXOffset(inSize: geometry.size), y: 0)
            .animation(nil)
        }
    }
    func getXOffset(inSize: CGSize) -> CGFloat{
        let offset = -inSize.width / 2 + inSize.width * position
        print("at position  \(position) offset is \(offset)")
        return offset
    }
}

extension View{
    func borderIn(position: CGFloat) -> some View{
        self.modifier(BorderPosition(position: position))
    }
}

在原始的BorderView中,分别不再需要GeometryReader以及用于计算缩进的函数:

struct BorderView: View,  Animatable{
    ...
    var body: some View{
            Rectangle()
                .fill(Color.green)
                .frame(width: self.borderWidth)
                .borderIn(position: position)
    }
}


是的,我们仍然在修改器中使用.offset()修改器,但是在它之后,我们添加了.animation(nil)修改器,该修改器会阻止我们自己的偏移动画。我了解到,在此阶段,您可以确定足以删除此锁,但是,我们将无法深入浅出。事实是,我们为BorderView使用animatableData的技巧不起作用。实际上,如果您查看Animatable协议的文档,则会注意到仅AnimatableModifier,GeometryEffect和Shape支持此协议的实现。视图不在其中。

正确的方法是制作修改动画


当我们要求View对某些更改进行动画处理时,该方法本身是不正确的。对于View,不能使用与表单相同的方法。而是需要将动画嵌入每个修改器中。大多数内置修改器已经支持开箱即用的动画。如果要为自己的修改器添加动画,则可以使用AnimatableModifier协议代替ViewModifier。就像上面所做的那样,您可以在其中实现与动画形状更改相同的操作。

struct BorderPosition: AnimatableModifier {
    var position: CGFloat
    let startDate: Date = Date()
    public var animatableData: CGFloat {
        get {
            print("reading position: \(position) at time \(Date().timeIntervalSince(startDate))")
            return position
        }
        set {
            position = newValue
            print("setting position: \(position) at time \(Date().timeIntervalSince(startDate))")
        }
    }
    func body(content: Content) -> some View {
...
    }
    ...
}


现在一切正确。控制台中的消息有助于了解我们的动画确实有效,并且修饰符内的.animation(nil)完全不会对其产生干扰。但是,让我们仍然弄清楚它是如何工作的。

首先,您需要了解什么是修饰符。


在这里,我们有一个视图。正如我在上一部分中所述,这是一个具有存储的参数和汇编指令的结构。总的来说,该指令不包含一系列动作,这是我们以非声明式的方式编写的常用代码,而是一个简单的列表。它列出了其他视图,应用于它们的修饰符以及包含它们的容器。我们对容器不感兴趣,但是让我们更多地谈谈修饰符。

修饰符还是具有存储的参数和View处理指令的结构。这实际上是与View相同的指令-我们可以使用其他修饰符,容器(例如,我使用更高的GeometryReader)以及其他View。但是我们只有传入的内容,因此需要使用此指令以某种方式对其进行更改。修饰符参数是指令的一部分。但最有趣的是它们已存储。

在上一篇文章中,我说过指令本身并没有存储,而是在视图更新后每次都抛出。一切都是这样,但有细微差别。由于该指令的工作,正如我之前所说的,我们所获得的画面并不多-是一种简化。在执行该指令后,修饰符不会消失。在父视图存在时,它们仍然保留。

一些原始的类比


我们将如何以声明式方式描述表格?好吧,我们将列出4条腿和一个台面。他们会将它们组合到某种容器中,并借助一些修饰符来规定如何将它们彼此固定。例如,每条腿将指示相对于台面的方向以及位置-哪条腿固定在哪个角上。是的,组装后我们可以扔掉说明书,但是钉子会留在桌子上。修饰符也是如此。在body函数的出口处,我们还没有一张桌子。使用主体,我们创建表格元素(视图)和紧固件(修饰符),并将其全部放置在抽屉中。桌子本身由机器人组装。您在每条腿的盒子中放什么紧固件,您会得到这样的桌子。

.modifier(BorderPosition(position:position))函数使我们将BorderPosition结构变成修饰符,只在抽屉中放了一个额外的螺钉到桌腿。 BorderPosition结构就是这个螺钉。渲染时,渲染时,请提取此框,从中取出一条腿(在本例中为Rectangle()),然后从列表中顺序获取所有修饰符,并将值存储在其中。每个修饰符的主体功能是有关如何使用此螺钉将腿固定到桌面上以及具有存储属性的结构本身(即该螺钉)的说明。

为什么在动画的背景下理解这一点很重要?因为动画允许您更改一个修改器的参数而不影响其他修改器,然后重新渲染图像。如果您通过更改某些内容来执行相同操作@State参数-这将导致整个链中嵌套View,修饰符结构等的重新初始化。但是动画不是。

实际上,当我们在按下按钮时更改position的值时,它就会更改。从头到尾。没有中间状态存储在变量本身中,关于修饰符不能说中间状态。对于每个新帧,修改器参数的值会根据当前动画的进度而变化。如果动画持续1秒,则每1/60秒(iphone精确显示每秒的帧数),修改器内的animatableData值将更改,然后将被渲染读取,以进行渲染,此后再经过1/60秒,将再次更改,并通过渲染再次读取。

什么是特征,我们首先获取整个View的最终状态,并记住它,然后动画机制才开始将插值的位置值移入修改器中。初始状态不会存储在任何地方。在SwiftUI的肠道中,仅存储初始状态和最终状态之间的差异。每次将此差乘以经过时间的分数。这就是内插值的计算方式,随后将其代入animatableData。

差异=


- 当前值=钢-差异*(1--经过的时间)经过的 时间=从StartAnimations / DurationAnimations开始的时间

当前值需要计算为我们需要显示的帧数的次数。

为什么没有明确使用“ Was”?事实是,SwiftUI不存储初始状态。仅存储差异:因此,如果发生某种故障,您可以简单地关闭动画,并进入“成为”的当前状态。

这种方法使您可以使动画可逆。假设在一个动画的中间某处,用户再次按下了一个按钮,然后我们再次更改了同一变量的值。在这种情况下,我们需要做的就是漂亮地克服此更改,就是将新更改时动画中的“当前”取为“它”,记住新的“差异”,然后根据新的“蜜饯”和新的“差异”开始制作新的动画。 。是的,实际上,从一种动画过渡到另一种动画可能很难模拟惯性,但我认为含义是可以理解的。

有趣的是,每个帧的动画都要求修饰符内的当前值(使用吸气剂)。从日志中的服务记录中可以看出,这是造成“钢”状态的原因。然后,使用设置器,设置该帧当前的新状态。之后,对于下一帧,再次请求修改器的当前值-并再次“已成为”,即动画要移动到的最终值。可能会使用修改器结构的副本进行动画处理,并使用一种结构的吸气剂(实际View的真实修改器)来获取值“钢”,而使用另一种结构的setter(用于动画的临时修改器)。我还没有想出办法来确保这一点,但是通过间接指示,一切看起来都差不多。无论如何,动画中的更改不会影响当前视图的修改器结构的存储值。如果您对如何确切地了解getter和setter会发生什么有任何想法,请在评论中进行介绍,我将对此文章进行更新。

几个参数


到目前为止,我们只有一个动画参数。可能会出现问题,但是如果将多个参数传递给修饰符怎么办?如果两个都需要同时进行动画处理?例如,这是使用frame修饰符(width:height :)的方式。毕竟,我们可以同时更改此View的宽度和高度,并且希望使更改发生在一个动画中,该怎么做?毕竟,AnimatableData参数是一个,我可以替代它吗?

如果您看,Apple仅对animatableData有一个要求。替换为它的数据类型必须满足VectorArithmetic协议。该协议要求对象确保最小的算术运算,以便能够形成两个值的分段并在该分段内插点。为此所需的运算是加法,减法和乘法。困难在于我们必须使用存储多个参数的单个对象执行这些操作。那些。我们必须将整个参数列表包装在一个向量容器中。苹果提供了一个开箱即用的对象,并为我们提供了在不太困难的情况下使用交钥匙解决方案的解决方案。它称为AnimatablePair。

让我们稍微改变一下任务。我们需要一个新的修改器,该修改器不仅会移动绿色条,而且还会更改其高度。这将是两个独立的修饰符参数。我不会提供需要完成的所有更改的完整代码,您可以在github上的SimpleBorderMove文件中找到它。我将只显示修饰符本身:

struct TwoParameterBorder: AnimatableModifier {
    var position: CGFloat
    var height: CGFloat
    let startDate: Date = Date()
    public var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get {
           print("animation read position: \(position), height: \(height)")
           return AnimatablePair(position, height)
        }
        set {
            self.position = newValue.first
            print("animating position at \(position)")
            self.height = newValue.second
            print("animating height at \(height)")
        }
    }
    init(position: CGFloat, height: CGFloat){
        self.position = position
        self.height = height
    }
    func body(content: Content) -> some View {
        GeometryReader{geometry in
            content
                .animation(nil)
                .offset(x: -geometry.size.width / 2 + geometry.size.width * self.position, y: 0)
                .frame(height: self.height * (geometry.size.height - 20) + 20)
        }
    }
}


我添加了另一个滑块和一个按钮,用于在SimpleView的父视图中一次随机更改两个参数,但是没有什么有趣的,因此对于完整代码,欢迎使用github。

一切正常,我们确实在AnimatablePair元组中包装的参数对中有了一个一致的变化。不错。

在此实施过程中,没有什么混乱的吗?就我个人而言,看到此设计时我会紧张:

        
self.position = newValue.first
self.height = newValue.second

我没有指出应该首先使用哪个参数,然后再选择哪个参数。 SwiftUI如何确定将第一个值放在第二个值中?好吧,它不匹配功能参数名称和结构属性名称吗?

第一个想法是函数参数及其类型中属性的顺序,就像@EnvironmentObject一样。在这里,我们只需将值放在框中,而无需为其分配任何标签,然后我们将其移出该位置,也无需指示任何标签。在那里,类型很重要,在一种类型内,就是顺序。他们以相同的顺序将其放入包装盒中并得到。我尝试了函数的参数的不同顺序,初始化结构的参数的顺序,结构本身的属性的顺序。

然后它突然降临在我身上。我自己指出哪个参数将是吸气剂中的第一个,哪个是第二个。 SwiftUI不需要确切地知道我们如何初始化此结构。它可以获取更改前的animatableData值,更改后获取它,计算它们之间的差,并将相同的差(按经过的时间间隔成比例缩放)返回给我们的设置者。通常,它不需要了解AnimatableData内部的值本身的任何信息。而且,如果您不混淆两个相邻行中变量的顺序,那么不管其余代码的结构多么复杂,一切都会按顺序进行。

但是,让我们检查一下。毕竟,我们可以创建自己的容器向量(哦,我喜欢它,创建我们自己的现有对象的实现,您可能在上一篇文章中已经注意到了这一点)。



我们描述基本结构,声明对VectorArithmetic协议的支持,打开关于协议不符合的错误,单击“修复”,然后获得所有必需功能和计算参数的声明。它仍然只是填充它们。

同样,我们使用AdditiveArithmetic协议所需的方法填充对象(VectorArithmetic包括其支持)。

struct MyAnimatableVector: VectorArithmetic{
    static func - (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position - rhs.position, height: lhs.height - rhs.height)
    }
    
    static func + (lhs: MyAnimatableVector, rhs: MyAnimatableVector) -> MyAnimatableVector {
        MyAnimatableVector(position: lhs.position + rhs.position, height: lhs.height + rhs.height)
    }
    
    mutating func scale(by rhs: Double) {
        self.position = self.position * CGFloat(rhs)
        self.height = self.height * CGFloat(rhs)
    }
    
    var magnitudeSquared: Double{
         Double(self.position * self.position) + Double(self.height * self.height)
    }
    
    static var zero: MyAnimatableVector{
        MyAnimatableVector(position: 0, height: 0)
    }
    
    var position: CGFloat
    var height: CGFloat
}

  • 我认为为什么我们需要+和-显然。
  • 缩放是缩放的函数。我们采用“过去-它已成为”的差异,并将其乘以动画的当前阶段(从0到1)。“它变成+差异*((1-阶段)””,并且会有一个当前值,我们应该在animatableData中添加
  • 初始化其值将用于动画的新对象可能需要零。动画一开始就使用.zero,但是我不知道具体如何。但是,我认为这并不重要。
  • itudeSquared是给定向量与其自身的标量积。对于二维空间,这意味着矢量的长度平方。这可能用来比较两个对象,而不是逐个比较,而是作为一个整体。它似乎不用于动画目的。

一般来说,“-=”“ + =”功能也包含在协议支持中,但是对于结构,它们可以以这种形式自动生成

    static func -= (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs - rhs
    }

    static func += (lhs: inout MyAnimatableVector, rhs: MyAnimatableVector) {
        lhs = lhs + rhs
    }


为了清楚起见,我以图表的形式列出了所有这些逻辑。 图片是可单击的。 动画过程中得到的结果以红色突出显示-每隔一个滴答声(1/60秒),计时器将给出一个新的t值,而在修改器的设置器中,我们将获得一个新的animatableData值。这就是动画在幕后的工作方式。同时,重要的是要了解修饰符是存储的结构,并且使用具有新的当前状态的当前修饰符的副本来显示动画。





为什么AnimatableData只能是一个结构


还有一点。您不能将类用作AnimatableData对象。正式地,您可以为一个类描述相应协议的所有必要方法,但这不会奏效,这就是原因。如您所知,类是引用数据类型,而结构是基于值的数据类型。在基于另一个变量创建一个变量时(对于类而言),将链接复制到该对象,对于结构而言,则基于现有变量的值创建一个新对象。这是一个说明此差异的小示例:

    struct TestStruct{
        var value: CGFloat
        mutating func scaled(by: CGFloat){
            self.value = self.value * by
        }
    }
    class TestClass{
        var value: CGFloat
        func scaled(by: CGFloat){
             self.value = self.value * by
        }
        init(value: CGFloat){
            self.value = value
        }
    }
        var stA = TestStruct(value: 5)
        var stB = stA
        stB.scaled(by: 2)
        print("structs: a = \(stA.value), b = \(stB.value))") //structs: a = 5.0, b = 10.0)
        var clA = TestClass(value: 5)
        var clB = clA
        clB.scaled(by: 2)
        print("classes: a = \(clA.value), b = \(clB.value))") //classes: a = 10.0, b = 10.0)

对于动画,完全相同的事情也会发生。我们有一个AnimatableData对象,表示“ was”和“ became”之间的区别。我们需要计算出部分差异以反映在屏幕上。为此,我们必须复制此差异并将其乘以代表动画当前阶段的数字。在结构的情况下,这不会影响差异本身,但在类的情况下会影响。我们绘制的第一帧是“ was”状态。为此,我们需要计算Steel +差异*当前阶段-差异。在类的情况下,在第一帧中,我们将差乘以0,将其归零,然后绘制所有后续帧,以使差= 0。动画似乎已正确绘制,但实际上我们看到了从一种状态到另一种状态的即时过渡,就好像没有动画一样。

您可能可以编写某种低级代码来为乘法结果创建新的内存地址-但是为什么呢?您可以只使用结构-为此而创建的。

对于那些想彻底了解SwiftUI如何通过什么操作和什么时候计算中间值的人,消息会被推送到项目的控制台另外,我在那儿插入了0.1秒的sleep以模拟动画中的资源密集型计算,玩得开心:)

屏幕动画:.transition()


到此为止,我们已经讨论过将传递给修饰符或形式的值的变化设置为动画。这些是非常强大的工具。但是还有另一个工具也使用动画-这是View外观和消失的动画。

在上一篇文章中,我们讨论了一个事实,即以if-else的声明方式,这根本不是控制运行时中的代码流,而是Schrödinger的观点。这是一个同时包含两个View的容器,该容器根据特定条件决定显示哪个View。如果错过else块,则显示EmptyView而不是第二个视图。

在两个视图之间切换也可以设置动画。为此,请使用.transition()修饰符。

struct TransitionView: View {
    let views: [AnyView] = [AnyView(CustomCircleTestView()), AnyView(SimpleBorderMove())]
    @State var currentViewInd = 0
    var body: some View {
        VStack{
            Spacer()
            ZStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                        }
                    }
                }
            }
            HStack{
                ForEach(views.indices, id: \.self){(ind: Int) in
                    RoundedRectangle(cornerRadius: 10)
                        .fill(ind == self.currentViewInd ? Color.green : Color.gray)
                        .overlay(
                            Text("\(ind + Int(1))"))
                        .onTapGesture{
                            withAnimation{
                                self.currentViewInd = ind
                            }
                    }
                }
            }
                .frame(height: 50)
            Spacer()
        }
    }
}

让我们看看它是如何工作的。首先,即使在父视图的初始化阶段,我们也要预先创建并在数组中放置几个​​视图。该数组的类型为AnyView,因为数组的元素必须具有相同的类型,否则它们不能在ForEach中使用。前一篇文章的不透明结果类型,还记得吗?

接下来,我们规定了该数组索引的枚举,对于每个数组,我们都通过该索引显示视图。我们被迫这样做,而不是立即遍历View,因为要使用ForEach,我们需要为每个元素分配一个内部标识符,以便SwiftUI可以遍历集合的内容。或者,我们必须在每个View中创建一个代理标识符,但是为什么可以使用索引呢?

我们将集合中的每个视图包装成一个条件,并仅在其处于活动状态时显示它。但是,if-else构造在这里根本不存在,编译器将其用于流控制,因此我们将所有这些都放在了Group中,以便编译器确切地了解它是View的意思,或更确切地说,是ViewBuilder创建可选的ConditionalContent容器的指令<View1,View2>。

现在,当更改currentViewInd的值时,SwiftUI将隐藏先前的活动视图,并显示当前视图。您如何在应用程序内部喜欢这种导航方式?


剩下要做的就是将currentViewInd更改放入withAnimation包装器中,并且在窗口之间切换将变得很顺畅。

添加.transition修饰符,并指定.scale作为参数。这将使每个视图的外观和消失的动画不同-使用缩放而不是默认SwiftUI使用的透明度。

                ForEach(views.indices, id: \.self){(ind: Int) in
                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                                .transition(.scale)
                        }
                    }
                }


请注意,该视图以相同的动画出现和消失,只有消失以相反的顺序滚动。实际上,我们可以为视图的外观和消失分别分配动画。为此使用非对称过渡。

                    Group{
                        if ind == self.currentViewInd{
                            self.views[ind]
                               .transition(.asymmetric(
                                    insertion: insertion: AnyTransition.scale(scale: 0.1, anchor: .leading).combined(with: .opacity),
                                    removal: .move(edge: .trailing)))
                        }
                    }


屏幕上会出现相同的.scale动画,但是现在我们指定了使用它的参数。它的开头不是零(点),而是通常的0.1。小窗口的起始位置不在屏幕中央,而是移到了左边缘。此外,外观不是由一个过渡负责,而是由两个过渡负责。它们可以与.combined(与:)组合。在这种情况下,我们增加了透明度。

视图的消失现在由另一个动画渲染-扫掠屏幕的右边缘。我将动画放慢了一些,因此您可以看一下它。

和往常一样,我迫不及待想编写自己的公交动画版本。这比动画形式或修饰符还要简单。

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            .clipped()
    }
}

extension AnyTransition {
    static func spinIn(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: -90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
    static func spinOut(anchor: UnitPoint) -> AnyTransition {
        .modifier(
            active: SpinTransitionModifier(angle: 90, anchor: anchor),
            identity: SpinTransitionModifier(angle: 0, anchor: anchor))
    }
}


首先,我们编写通常的修饰符,在该修饰符中我们传递一定的数-旋转角度(以度为单位)以及发生旋转的相对点。然后,我们使用两个函数扩展AnyTransition类型。它本来可以是一个,但对我来说似乎更方便。我发现给每个语音名称分配名称要比直接在View本身中控制旋转度要容易得多。

AnyTransition类型有一个静态修饰符方法,我们向其中传递了两个修饰符,然后得到一个AnyTransition对象,该对象描述了从一种状态到另一种状态的平滑过渡。 identity是动画视图的正常状态修改器。活动是指视图外观的动画开始或消失的动画结束的状态,即段的另一端,将在其中插值的状态。

因此,spinIn表示我将通过沿指定点沿顺时针方向旋转来使视图从屏幕外部(或为视图分配的空间)出现。 spinOut表示视图将以相同的方式消失,围绕相同的点(也沿顺时针方向)旋转。

根据我的想法,如果对View的出现和消失使用相同的点,则会获得围绕该点旋转整个屏幕的效果。

所有动画均基于标准修改器机制。如果编写完全自定义的修饰符,则必须像对TwoParameterBorder那样实现AnimatableModifier协议的要求,或者使用其中提供内置默认修饰符的内置修饰符。在这种情况下,我依靠SpinTransitionModifier修改器中的内置.rotationEffect()动画。

.transition()修饰符仅阐明将什么视为动画的起点和终点。如果我们需要在开始动画之前请求AnimatableData状态,请请求当前状态的AnimatableData修改器,计算差值,然后将减小的动画设置为1到0,那么.transition()只会更改原始数据。您没有依附于View的状态;您不是基于此状态的。您可以自己明确指定初始状态和最终状态,并从中获得AnimatableData,计算差异并对其进行动画处理。然后,在动画的结尾,您当前的View成为最重要的。

顺便说一句,标识是一个修饰符,它将在动画结束时保留应用于您的视图。否则,此处的错误将导致外观动画的末尾和消失动画的开始处的跳转。因此,过渡可以视为“二合一”-将特定的修改器直接应用于View +在View出现和消失时可以对其更改进行动画处理。

老实说,这种动画控制机制在我看来非常强大,对于我们不能将其用于任何动画,我感到有点遗憾。我不会拒绝制作无尽的封闭动画。但是,我们将在下一篇文章中讨论它。

为了更好地了解更改本身是如何发生的,我将测试视图替换为基本正方形,并用数字签名并加了边框。

                    Group{
                        if ind == self.currentViewInd{
                            //self.views[ind]
                            Rectangle()
                                .fill(Color.gray)
                                .frame(width: 100, height: 100)
                                .border(Color.black, width: 2)
                                .overlay(Text("\(ind + 1)"))
                              .transition(.asymmetric(
                                  insertion: .spinIn(anchor: .bottomTrailing),
                                  removal: .spinOut(anchor: .bottomTrailing)))
                        }
                    }


为了使此动作更好,我从SpinTransitionModifier修饰符中删除了.clipped():

struct SpinTransitionModifier: ViewModifier {
    let angle: Double
    let anchor: UnitPoint
    func body(content: Content) -> some View {
        content
            .rotationEffect(Angle(degrees: angle), anchor: anchor)
            //.clipped()
    }
}


顺便说一下,现在我们总共需要在自己的修饰符中使用SpinTransitionModifier。创建它仅是为了将两个修改器rotationEffect和clip(())组合为一个,以便旋转动画不会超出为View选择的范围。现在,我们可以直接在.modifier()内部使用.rotationEffect(),而无需使用SpinTransitionModifier形式的中介。

当视图死亡时


有趣的一点是View生命周期(如果放置在if-else中)。视图虽然已启动并记录为数组元素,但未存储在内存中。她所有下次它们出现在屏幕上时,参数将重置为默认值。这几乎与初始化相同。尽管对象结构本身仍然存在,但渲染器将其从其视野中删除,因为它实际上不存在。一方面,这减少了内存使用。如果数组中有大量复杂的View,则渲染必须不断绘制所有视图,并对更改做出反应-这会对性能产生负面影响。如果我没记错的话,那就是Xcode 11.3更新之前的情况。现在,非活动视图将从渲染内存中卸载。

另一方面,我们必须将所有重要状态移出该视图的范围。为此,最好使用@EnvironmentObject变量。

返回生命周期,还应注意,如果.onAppear {}修饰符(如果已在此View中注册),则在更改View的条件和外观后,甚至在动画开始之前,都可以立即使用。因此,消失动画结束后会触发onDisappear {}。如果您打算将它们与过渡动画一起使用,请记住这一点。

下一步是什么?


ew 结果非常详尽,但是很详细,我希望可以理解。老实说,我希望在一篇文章中谈论彩虹动画,但是我无法及时了解细节。因此,等待继续。

以下部分等待着我们:

  • 使用渐变:线性,圆形和角度-一切都会派上用场
  • 颜色根本不是颜色:明智地选择。
  • 循环动画:如何开始和如何停止以及如何立即停止(没有动画,更改动画-是的,也有一个)
  • 当前流动画:优先级,替代,不同对象的不同动画
  • 有关动画时间的详细信息:我们将在尾部和鬃毛中驱动时间,直到我们自己实现的TimingCurve为止(哦,让我保持七个:))
  • 如何找出所播放动画的当前时刻
  • 如果SwiftUI还不够

我将通过创建彩虹动画的示例来详细讨论所有这些,如图所示:


我没有走简单的路,而是收集了我可以达到的所有耙,并按照上述原理将动画体现了出来。关于这个的故事应该证明是很有信息的,并且有许多技巧和各种各样的黑客手段,关于这些东西的报道很少,对于那些决定成为SwiftUI先锋的人来说,这将是有用的。它将大约在一两个星期内出现。顺便说一句,您可以订阅,以免错过。但是,当然,前提是该材料对您似乎有用并且演示方法已被批准。然后,您的订阅将有助于快速将新文章推到顶部,并尽早将它们带给更广泛的受众。否则,请在注释中写错。

All Articles