SwiftUI基础篇Animation(上)

1,440 阅读8分钟

Animation

概述

文章主要分享SwiftUI Modifier的学习过程,将使用案例的方式进行说明。内容浅显易懂,Animation未做调试结果,不过测试代码是齐全的。如果想要运行结果,可以移步Github下载code -> github案例链接

1、创建基本动画

SwiftUI通过animation()修饰符内置了对动画的支持。要使用此修饰符,请将其放置在视图的任何其他修饰符之后,告诉他想要的动画类型,并确保将其附加到特定的值,以便仅在该特定值更改时触发动画。

struct FFBasicAnimations: View {
    @State private var scale = 1.0
    @State private var scale2 = 1.0
    @State private var angle = 0.0
    @State private var borderThickness = 1.0
    
    var body: some View {
        //例如,创建一个Button,每次点击,缩放+1
        Button("Press here") {
            scale += 1
        }
        .scaleEffect(scale)
        .animation(.linear(duration: 1), value: scale)
        Divider()
        //动画的发生时间是1s,但如果你不想为动画指定精确的时间,可以使用.linear
        //重要提示:从iOS17以及更高版本以后,SwiftUI使用spring动画,在此之前默认动画是线性动画
       
        //例如,创建缩放效果动画,开始缓慢,然后变得快速
        Button("Prese here") {
            scale2 += 1
        }
        .scaleEffect(scale2)
        .animation(.easeIn, value: scale)
        Divider()
        //还有其他很多动画修饰符,比如2D和3D旋转,不透明度,边框等
        //创建一个按钮以及旋转动画,添加border,随着动画宽度+1
        Button("Press here") {
            angle += 45
            borderThickness += 1
        }
        .padding()
        .border(.red, width: borderThickness)
        .rotationEffect(.degrees(angle))
        .animation(.easeIn, value: angle)
        
    }
}

除了简单的线性动画,还可以在各种内置选项中指定曲线:

  • .easein开始缓慢,然后加速,直到结束
  • .easeout还是很快,接近结束时减速,直到结束
  • .easeinout开始很慢,在中间加速,然后在接近结束时减速
  • .smooth是一个没有弹跳的spring动画(iOS17)
  • .snappy是一个带有小弹跳的spring动画(iOS17)
  • .bouncy是一个有中等弹跳的spring动画(iOS17)

或者,可以通过指定.timingCurve来控制point

2、创建Spring Animation

SwiftUI内置了对spring动画的支持,这些动画会移动到目标点,然后反弹回来,简言之就是个弹性动画。如果只使用.spring()本身,不带参数,则会得到一个合理的默认值。

struct FFAnimationSpring: View {
    @State private var angle: Double = 0.0
    @State private var angle2: Double = 0.0
    @State private var scaless = 1.0
        
    var body: some View {
        //创建一个弹簧动画,每次点击将其旋转45度
        Button("Press here") {
            angle += 45
        }
        .padding()
        .rotationEffect(.degrees(angle))
        .animation(.spring, value: angle)
        //如果你希望对spring动画进行颗粒度控制,可以添加参数:
        //如果需要支持iOS16以及更早版本,需要指定物体的质量,弹簧的硬度,弹性减缓速度,以及在启动时开始移动的速度。
        //如果只需要支持iOS17以及以后,可以指定想要的弹力持续的时间,还可以选择添加想要的弹力和混合效果。
        Button("Press here") {
            angle2 += 45
        }
        .padding()
        .rotationEffect(.degrees(angle2))
        .animation(.interpolatingSpring(mass: 1,stiffness: 1, damping: 0.5, initialVelocity: 10), value: angle2)
        //注意:这是一个插值弹簧,这意味着如果出发了几次动画,弹簧效果会随着弹簧的结合而变得越来越强
        //这段代码或多或少做相同的事情,使用与iOS17以及跟高版本兼容
        
        Button("Press here") {
            scaless += 1
        }
        .padding()
        .scaleEffect(scaless)
        .animation(.spring(duration: 1, bounce: 0.75), value: scaless)
    }
}

3、通过绑定值来监听动画变化

SwiftUI的双向绑定可以调整程序的状态,可以通过调整视图层次来做出响应。例如,控制一些文本的隐藏或者视图的透明度等等。可以通过在绑定中添加animation(),而不是让状态立即发生变化,从而为绑定修改引起的变化过程制作动画。

struct FFAnimateBindingValues: View {
    @State private var showingWelcome = false
    @State private var showingWelcome1 = false
    @State private var showingWelcome2 = false
    
    var body: some View {
        //例如,创建一个开关,切换状态来控制视图的显示和隐藏。
        Toggle("Toggle label - normal", isOn: $showingWelcome)
            .padding()
        
        if showingWelcome {
            Text("Hi, metaBBLv")
        }
        //如果没有动画,文本视图在点击的时候立即出现/消失,这将导致视觉跳动。如果修改这个切换,将它绑定到$showingWelcome,那么文本视图将平滑的出现。
        Toggle("Toggle label - animation", isOn: $showingWelcome1.animation())
            .padding()
        
        if showingWelcome1 {
            Text("Hi, metaBBLv - animation")
        }
        //如果想要更丰富的动画效果,可以给animation()传递参数来控制,比如,添加一个spring()动画
        Toggle("Toggle label - animation(spring)", isOn: $showingWelcome2.animation(.spring))
            .padding()
        
        if showingWelcome2 {
            Text("Hi, metaBBLv - animation(spring)")
        }
    }
}

4、创建显式动画

如果将动画附加到视图上,最终会得到一个隐式动画。改变视图中的其他地方的一些状态可能会使用动画,即使只是增加一个整数或者切换一个bool值。另外一种时显式动画,在这种情况下,不给有问题的视图附加修饰符,而是让SwiftUI对想要做的精确的更改进行动画化。为此,将更改封装在withAnimation()中。

struct FFAnimationExlicit: View {
    @State private var opacity = 1.0
    
    var body: some View {
        Button("Press here") {
            withAnimation {
                opacity -= 0.2
            }
        }
        .padding()
        .font(.title)
        .background(.green)
        .opacity(opacity)
    }
}

withAnimation()也可以接受参数,指定动画类型,因此也可以创建一个3s的线性动画withAnimation(.linear(duration: 3))

显式动画通常很有用,因为他们会使每个受影响的视图都产生动画,而不是仅仅是那些附加了隐式动画的视图。

5、创建延时动画

当想创建任何动画时(隐式、显示、绑定),可以给动画附加修饰符来调整。例如,如果想让动画在一定的时间后开始,那么就需要使用delay()修饰符。

struct FFAnimationDelay: View {
    @State private var rotaitonDelay = 0.0
    
    var body: some View {
        //例如,创建一个红色矩形,当点击时,在1s后在2s时间内旋转360度。
        Rectangle()
            .fill(.red)
            .frame(width: 200, height: 200)
            .rotationEffect(.degrees(rotaitonDelay))
            .animation(.easeInOut(duration: 3).delay(1), value: rotaitonDelay)
            .onTapGesture {
                rotaitonDelay += 360
            }
    }
}

6、在视图出现后立刻启动动画

如果想要一个SwiftUI视图,应该使用onApppear()修饰符来附加动画。

//创建自定扩展:即时动画
extension View {
    func animate(using animation: Animation = .easeInOut(duration: 1), _ action: @escaping () -> Void) -> some View {
        onAppear {
            withAnimation(animation) {
                action()
            }
        }
    }
}

//即时循环动画
extension View {
    func animateForever(using animation: Animation = .easeInOut(duration: 1), autorecerses: Bool = false, _ action: @escaping () -> Void) -> some View {
        let repeated = animation.repeatForever(autoreverses: autorecerses)
        return onAppear {
            withAnimation(repeated) {
                action()
            }
        }
    }
}

struct FFAnimationViewAppears: View {
    @State var scale = 1.0
    @State var scale2 = 1.0
    
    var body: some View {
        //创建一个结合动画(放大和缩小连续),类似呼吸效果
        Circle()
            .frame(width: 200, height: 200)
            .scaleEffect(scale)
            .onAppear(perform: {
                let baseAnimation = Animation.easeInOut(duration: 1)
                let repeated = baseAnimation.repeatForever(autoreverses: true)
                
                withAnimation(repeated) {
                    scale = 0.5
                }
            })
        //如果打算频繁的添加初始动画,那么使用视图协议添加扩展是很好的方式。
        //通过扩展创建动画
        Circle()
            .frame(width: 200, height: 200)
            .scaleEffect(scale2)
            .animateForever(autorecerses: true) {
                scale2 = 0.5
            }
    }
}

7、将多个动画应用到视图

使用SwiftUI的animation()修饰符的顺序会影响那些修饰符会被动画化。也可以添加多个animation修饰符来设置不同的动画。

struct FFAnimationMultiple: View {
    @State private var isEnabled = false
    
    var body: some View {
        Button("Press me") {
            isEnabled.toggle()
        }
        .foregroundStyle(.white)
        .frame(width: 200, height: 200)
        .animation(.easeInOut(duration: 1)) { content in
            content
                .background(isEnabled ? .green : .red)
        }
        .animation(.easeInOut(duration: 2)) { content in
            content
                .clipShape(.rect(cornerRadius: isEnabled ? 100 : 0))
        }
    }
}

8、使用matchedGeometryEffect()将动画从一个视图同步到另一个视图

如果相同的视图出现在视图层次结构中的两个不同部分,并且想要在他们之间进行动画。那么从列表视图切换到缩放的细节,就要使用matchedGeometryEffect()修饰符。类似Keynote中的majic move。

若要使用修饰符,将其附加到层次结构中的不同部分的一对相同的视图上。两个视图状态之间切换时,会发现SwiftUI平滑的激活了同步视图。

struct FFAnimationMatchedGeometryEffect: View {
    @State private var isFlipped = false
    
    @Namespace private var animation
    @State private var isFlipped1 = false
    
    @Namespace private var appleMusic
    @State private var isZoomed = false
    
    var frame: Double {
        isZoomed ? 300 : 44
    }
    
    var body: some View {
        //一个视图状态中有一个红色圆和文本,另外一个视图状态中,圆在文本之后并改变颜色
        VStack {
            if isFlipped {
                Circle()
                    .fill(.red)
                    .frame(width:44, height: 44)
                Text("metaBBLv - Keep loving")
                    .font(.headline)
            } else {
                Text("metaBBLv - Keep loving")
                    .font(.headline)
                Circle()
                    .fill(.blue)
                    .frame(width: 44, height: 44)
            }
        }
        .onTapGesture {
            withAnimation {
                isFlipped.toggle()
            }
        }
        //其实实现的动画效果就是文字在点击之后移动到图的下方,但是显然这种方式很愚蠢。
        //有更好的方式,首先,需要使用@Namespace属性包装器为视图创建一个全局命名空间。在实践中,这只不过是视图上的一个属性,但在原理上,可以将视图链接在一起。
        //所以,添加一个animation属性:@Namespace private var animation.
        //接下来,添加matchedGeometryEffect动画效果。
        
        VStack {
            if isFlipped1 {
                Circle()
                    .fill(.red)
                    .frame(width: 44, height: 44)
                    .matchedGeometryEffect(id: "Shape", in: animation)
                Text("metaBBLv - Keep loving")
                    .matchedGeometryEffect(id: "AlbumTitle", in: animation)
                    .font(.headline)
            } else {
                Text("metaBBLv - Keep loving")
                    .matchedGeometryEffect(id: "AlbumTitle", in: animation)
                    .font(.headline)
                Circle()
                    .fill(.red)
                    .frame(width: 44, height: 44)
                    .matchedGeometryEffect(id: "Shape", in: animation)
            }
        }
        .onTapGesture {
            withAnimation {
                isFlipped1.toggle()
            }
        }
        //在实现一个更高级的例子,关于Apple Music的专辑显示风格,点击时可以将小视图扩展到更大的视图。
        VStack {
//            Spacer()
            VStack {
                HStack {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.blue)
                        .frame(width: frame, height: frame)
                        .padding(.top, isZoomed ? 20 : 0)
                    
                    if isZoomed == false {
                        Text("metaBBLv - Keep loving")
                            .matchedGeometryEffect(id: "AlbumTitle", in: appleMusic)
                            .font(.headline)
                        Spacer()
                    }
                }
                
                if isZoomed == true {
                    Text("metaBBLv - Keep loving")
                        .matchedGeometryEffect(id: "AlbumTitle", in: appleMusic)
                        .font(.headline)
                        .padding(.bottom, 60)
                    Spacer()
                }
            }
            .onTapGesture {
                withAnimation {
                    isZoomed.toggle()
                }
            }
            .padding()
            .frame(maxWidth: .infinity)
            .frame(height: 400)
            .background(Color(white: 0.9))
            .foregroundStyle(.black)
        }
    }
}