What does animatableData in SwiftUI do?
Asked Answered
V

2

8

I was playing around with the animatableData property for a custom Shape I made but I couldn't really visualise what it does and where the system uses it.

I didn't understand how the system knows which animatableData properties it should interpolate when there's a state change. I also didn't understand what the get part of the animatableData property is used for by the system. The only thing I sort of understand is that SwiftUI will update the animatableData property to all the intermediary values between the original and final value for when an @State variable is changed.

If someone can give a very detailed order of events for the use of animatableData by the system I'll be extremely grateful. Make it as detailed as you can because I'm one of those people who feels scratchy even if I'm not understanding 1% of something (however if I do have any question I'll just ask you in the comments).

Thanks in advance!

P.S. I tried returning a constant in the getter for animatableData and my animation still worked perfectly which has confused me even more. Please let me know what the getter is used for if you can.

Valarievalda answered 12/8, 2020 at 14:23 Comment(1)
I am also such a person. Any answer?Melodramatize
Q
1

The simplest answer to your question is to override the default animatableData [inherited by the Animatable protocol] with values used to draw your View. Here's an example of how to do that:

    var animatableData: Double {
      get { return percent }
      set { percent = newValue }
   }

Here's an example for you. It:

  • Draws a Ring on the parent View.

As the value of percent [which you hook up when you define animatableData] changes, the animation updates the view by drawing a line along the circumference of the defined circle using the percent value at the time of the update.

animating circle

import SwiftUI

/// This repeats an animation until 5 seconds elapse
struct SimpleAnswer: View {
   /// the start/stop sentinel
   static var shouldAnimate = true
   /// the percentage of the circumference (arc) to draw
   @State var percent = 0.0

   /// animation duration/delay values
   var animationDuration: Double { return 1.0 }
   var animationDelay: Double { return  0.2 }
   var exitAnimationDuration: Double { return 0.3 }
   var finalAnimationDuration: Double { return 1.0 }
   var minAnimationInterval: Double { return 0.1 }

   var body: some View {
      ZStack {
         AnimatingOverlay(percent: percent)
            .stroke(Color.yellow, lineWidth: 8.0)
            .rotationEffect(.degrees(-90))
            .aspectRatio(1, contentMode: .fit)
            .padding(20)
            .onAppear() {
               self.performAnimations()
            }
            .frame(width: 150, height: 150,
                   alignment: .center)
         Spacer()
      }
      .background(Color.blue)
      .edgesIgnoringSafeArea(.all)
   }

   func performAnimations() {
      run()
      if SimpleAnswer.shouldAnimate {
         restartAnimation()
      }
      /// Stop the Animation after 5 seconds
      DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: { SimpleAnswer.shouldAnimate = false })
   }

   func run() {
      withAnimation(.easeIn(duration: animationDuration)) {
         percent = 1
      }
      let deadline: DispatchTime = .now() + animationDuration + animationDelay
      DispatchQueue.main.asyncAfter(deadline: deadline) {
         withAnimation(.easeOut(duration: self.exitAnimationDuration)) {
         }
         withAnimation(.easeOut(duration: self.minAnimationInterval)) {
         }
      }
   }

   func restartAnimation() {
      let deadline: DispatchTime = .now() + 2 * animationDuration + finalAnimationDuration
      DispatchQueue.main.asyncAfter(deadline: deadline) {
         self.percent = 0
         self.performAnimations()
      }
   }
}

/// Draws a Ring on the parent View
/// By default, `Shape` returns the instance of `EmptyAnimatableData` struct as its animatableData.
/// All you have to do is replace this default `EmptyAnimatableData` with a different value.
/// As the value of percent changes, the animation updates the view
struct AnimatingOverlay: Shape {
   var percent: Double

   func path(in rect: CGRect) -> Path {
      let end = percent * 360
      var p = Path()

      p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
               radius: rect.size.width/2,
               startAngle: Angle(degrees: 0),
               endAngle: Angle(degrees: end),
               clockwise: false)
      return p
   }

   /// This example defines `percent` as the value to animate by
   /// overriding the value of `animatableData`
   /// inherited as Animatable.animatableData
   var animatableData: Double {
      get { return percent }
      set { percent = newValue }
   }
}

#if DEBUG
struct SimpleAnswer_Previews : PreviewProvider {
   static var previews: some View {
      SimpleAnswer()
   }
}
#endif

I found these links to help me answer your question. You should find them useful as well.

Wenderlich - How to Create a Splash Screen With SwiftUI

Majid - The Magic of Animatable Values

Animations in SwiftUI - Majid

Quickman answered 11/5, 2022 at 18:33 Comment(2)
How does SwiftUI "hook up" animatableData to the correct property?Valarievalda
You do this by defining how it "sets" the animatableData variable. You hook it up by pointing it to a variable you have defined - in this case percent. The animatable protocol provides this hook - when you conform to it you are required to define what you want to change in your view - so the protocol defines that animatableData is updated, and you make sure that when animatableData is updated it updates your variable however you want it updated. Let me know if that clears things up.Quickman
C
0

I had the same question, but only found explanations of how to use animatableData, not how it really works, so I decided to investigate, using print statements and by returning random numbers from animatableData.get. Here’s how I now understand it to work.

How it works conceptually

First, let's establish a mental model: you can think of the animatableData property as a "lever" that SwiftUI manipulates during an animation and whose position has some relationship to the view's stored properties. This relationship is defined by your animatableData.get and .set implementations. It may well be a simple 1:1 mapping to a stored property, but it could also be more complex.

For an animation, SwiftUI needs to know:

  • What are the start and end values of the lever, corresponding to the view's state before and after a change to its stored properties? animatableData.get is called for those two states to find out.
  • What would be the view's property values (and ultimately its body value in particular) for intermediate lever positions, so that these can be rendered as animation frames? It calls animatableData.set for each frame to find this out.

The lever’s possible positions need to be of a type that supports interpolating values, by conforming to the VectorArithmetic protocol. In the simplest case, that’s a Double, but it can also be a more complex, multi-dimensional structure you define.

What happens moment to moment

First, you don't need to worry about your app's state actually getting messed around with during an animation. SwiftUI creates dummy instances of your View struct (lots of them) to manage an animation. These dummies don't reference the same @State and @StateObject memory addresses as the real view (or even as each other). This means that your app's true, canonical state is unaffected during an animation.

So here’s my understanding of the sequence of events during an animation:

  1. When the view is created, SwiftUI creates a dummy copy of your struct and calls animatableData.get to find out the starting “lever position” to use if an animation is later triggered (let’s call this value x1). If, at any point after this, the view gets changed without an animation, the same thing happens and x1 is replaced with the new return value of animatableData.get.
  2. If the view's stored properties are updated with an animation:
    1. animatableData.get is called on a dummy copy of the updated view struct to find out the end value (x2) for the animation “lever”.
    2. For every frame of the animation:
      1. animatableData.get is called on another dummy instance in the end state. This will usually give the same as x2 because your get method is presumably deterministic, but let’s call it x2' so you can see where it goes in the formula below.
      2. Another dummy instance is created to call animatableData.set with a value interpolated between the start and end values reported by your animatableData.get function (x1 and x2). The interpolated value is equal to x2' - (x2 - x1) * (1 - n), where n is the output of the animations' timing curve for the current progress, e.g. 0.25 for one quarter into a linear animation.
      3. SwiftUI now has a struct instance whose properties were updated by your animatableData.set method for the interpolated input value, and can accordingly render an animation frame to show what its body property returns. But remember this is only a dummy view; your real view already has its final value and isn't touched during the animation.
    3. After the animation, x1 is replaced by the value of x2 as the stored “before” value to be used next time an animation runs.
Caracole answered 3/4 at 10:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.