SwiftUI 动画,三板斧

2,291 阅读3分钟

Swift UI 是数据驱动,不是事件驱动

数据驱动与事件驱动的对比,

例子: 添加一个 popUp 视图

数据驱动, 最上方本来就有一个 popUp,用数据控制他的显示与隐藏

事件驱动,是创建一个 popUp,添加在最上面

事件驱动,可能重复创建与添加,

数据驱动,安全些

效果 1,展开与收起

@State 修饰的属性, 作为驱动数据源, source of truth,

@State 修饰的属性改变,该 View 刷新


@State var showMoon: String? = nil

  func toggleMoons(_ name: String) -> Bool {
    return name == showMoon
  }

下面的展示有三种情况,

  • 初始化,格子们都是收起,点击一个展开
  • 有一个格子展开,点击该格子,该格子收起
  • 有一个格子 A 展开,点击格子 B,格子 A 收起, 格子 B 展开

上面的数据结构,清爽地解决,

初始化,showMoon = nil, 不等于任一个格子带的属性 name,默认全部收起

其他两种情况,逻辑类似


   Button(action: {
          withAnimation(.easeInOut) {
            self.showMoon = self.toggleMoons(planet.name) ? nil : planet.name
          }
    }) {
          Image(systemName: "moon.circle.fill")
    }

    if self.toggleMoons(planet.name) {
      MoonList(planet: planet)
    }

效果 2,自定义转场 Transition 动画

asymmetric, 不对称动画,

出场的动画 inserted,

与移除的动画 removed,

可以不一样

extension AnyTransition {
   static var customTransition: AnyTransition {
      let insertion = AnyTransition.move(edge: .top)
         .combined(with: .scale(scale: 0.2, anchor: .topTrailing))
         .combined(with: .opacity)
      let removal = AnyTransition.move(edge: .top).combined(with: .opacity)
      return .asymmetric(insertion: insertion, removal: removal)
   }
}

Transition 动画,还可以比较自由的组合效果

combined

效果 3,封面的星星轨道

轨道动画,需要用到 GeometryEffect ,几何图形动画

3.1 GeometryEffect 几何效果

GeometryEffect 协议,自带方法

func effectValue(size: CGSize) -> ProjectionTransform

这个方法提供动画点的某一时刻的计算坐标

3.1.1, GeometryEffect 协议,继承自 Animatable 协议

Animatable 自带属性,


var animatableData: Self.AnimatableData

动画时间内,动画属性 animatableData ,

在开始值和最终值,之间变化

3.1.2, 圆环轨道效应 OrbitEffect

就是给一个区域,在这个区域里面画圆

animatableData 干涉角度 angle,起作用


struct OrbitEffect: GeometryEffect {

  // 初始值,任意
  let initialAngle = CGFloat.random(in: 0 ..< 2 * .pi)

  var angle: CGFloat = 0
  let radius: CGFloat

  var animatableData: CGFloat {
    get { return angle }
    set { angle = newValue }
  }

  func effectValue(size: CGSize) -> ProjectionTransform {
    // 计算圆公式,很简单
    let pt = CGPoint(x: cos(angle + initialAngle) * radius,
                     y: sin(angle + initialAngle) * radius)
    let translation = CGAffineTransform(translationX: pt.x, y: pt.y)
    return ProjectionTransform(translation)
  }
}
3.1.3 , self.animationFlag ? 2 * .pi : 0 决定了动画的 from & to
func makeOrbitEffect(diameter: CGFloat) -> some GeometryEffect {
    return OrbitEffect(angle: self.animationFlag ? 2 * .pi : 0,
                       radius: diameter / 2.0)
  }

3.2, 错误调用示范

出现就刷新,调用动画

onAppear 的时机中,刷新 @State 修饰的值

本场景下,进入界面,就出现了 onAppear , 调用了轨道动画

展开后,该轨道动画会出现错位,bug 明显

@State private var animationFlag = false


  func makeSystem(_ geometry: GeometryProxy) -> some View {
    let planetSize = geometry.size.height * 0.25
    let moonSize = geometry.size.height * 0.1
    let radiusIncrement = (geometry.size.height - planetSize - moonSize) / CGFloat(moons.count)
    let range = 1 ... moons.count
    return
      ZStack {
         // ...
        ForEach(range, id: \.self) { index in
          // individual "moon" circles
          self.moon(planetSize: planetSize, moonSize: moonSize, radiusIncrement: radiusIncrement, index: CGFloat(index))
              .modifier(self.makeOrbitEffect(
                diameter: planetSize + radiusIncrement * CGFloat(index)
              ))
              .animation(Animation
                .linear(duration: Double.random(in: 10 ... 100))
                .repeatForever(autoreverses: false)
              )
        }
    }
    .onAppear {
      self.animationFlag.toggle()
    }
  }


  func makeOrbitEffect(diameter: CGFloat) -> some GeometryEffect {
    return OrbitEffect(angle: self.animationFlag ? 2 * .pi : 0,
                       radius: diameter / 2.0)
  }

3.3, 正确调用示范

上面的代码很合理,但是运行出问题,

因为他在方法中修改 @State 修饰的属性,

该方法为

@inlinable public func onAppear(perform action: (() -> Void)? = nil) -> some View

不具有逃逸效果,超出该函数的作用域,依然可以执行,

改为如下,则 OK


@State private var animationFlag = false

  var body: some View {
    GeometryReader { geometry in
      self.makeSystem(geometry) {
          animationFlag.toggle()
      }
    }
  }


  func makeSystem(_ geometry: GeometryProxy, action completionHandler: @escaping () -> Void) -> some View {
    let planetSize = geometry.size.height * 0.25
    let moonSize = geometry.size.height * 0.1
    let radiusIncrement = (geometry.size.height - planetSize - moonSize) / CGFloat(moons.count)
    let range = 1 ... moons.count
    return
      ZStack {
        ForEach(range, id: \.self) { index in
          // individual "moon" circles
          self.moon(moonSize: moonSize)
              .modifier(self.makeOrbitEffect(
                diameter: planetSize + radiusIncrement * CGFloat(index)
              ))
            .onAppear {
                withAnimation(Animation
                                .linear(duration: Double.random(in: 10 ... 100))
                                .repeatForever(autoreverses: false)) {
                    completionHandler()
                }
            }
        }
    }
  }
3.3.1 SwiftUI 的 debug print

View 里面的代码,只能声明式,

添加 View 、或者条件判断语句 ( if else )

extension View {
    func Print(_ vars: Any...) -> some View {
        for v in vars { print(v) }
        return EmptyView()
    }
}
3.3.2 SwiftUI 使用 func 取代 custom View

当 Model 属性计算与 View 创建,混合在一起的时候,

使用 func 创建 View,

使用 custom View, 直接声明式写 UI

声明式 UI, 代码结构与 UI 高度一致

GitHub repo