skip to Main Content

The simple code below moves the text by updating the @State var offset within a withAnimation block.

struct ContentView: View {
    @State var offset: CGFloat = 0
    
    var body: some View {
        NavigationView {
            VStack {
                Text("Some moving text")
                    .offset(y: offset)
                
                Button("Change Offset") {
                    withAnimation(.easeInOut(duration: 1.0)) {
                        offset = .random(in: -400...400)
                    }
                }
            }
        }
    }
}

Is it somehow possible to access the animation properties like the current value/offset and speed while the animation is runnig?

For example I would like to add blur to the Text depending on the current speed of the animation: The faster the animation the stronger the blur / grater the blur radius.

Is this possible?

2

Answers


  1. Given the use case, the speed/velocity doesn’t really matter, as I don’t believe respecting it precisely would be noticeable, and developing a method to achieve the desired effect based on the observed or calculated speed may be overkill.

    The (variable) speed is actually given by timing curve associated with the selected animation – .easeInOut. More importantly, though, is the animation duration. This duration is the same, regardless of the distance travelled by the object/text.

    If the duration is the same, it means any values changed/animated using the same animation and duration will:

    1. Start and stop at the same time.
    2. The rate of change/animation will be the same (same timing curve).

    With this in mind, if we know the text will take one second to move from its current offset to the new offset, any blur applied to it should:

    1. Be applied for no more than one second.
    2. Be reset back to zero in a way that feels natural (as opposed to being reset with no animation).

    The simplest way to achieve this is to animate the change in the blur value from zero to an appropriate value:

    1. Based on the distance travelled (since it wouldn’t feel natural for the text to get fully blurred if it travels a very short distance)
    2. In half the time it takes to reach the new offset
    3. And revert back to zero during the other half.

    This can be easily done by setting the blur value with the same (or similar) animation but with half the duration value, and then animating again to reset the value on completion of the initial animation:

    withAnimation(.easeIn(duration: animationDuration / 2 )) {
        blur = distanceBasedBlur
    } completion: {
                    
        withAnimation(.easeOut(duration: animationDuration / 2 )) {
            blur = 0
        }
    }
    

    Here’s the full code with some comments that hopefully explains everything:

    import SwiftUI
    
    struct AnimationBlur: View {
    
        //Constants
        let animationDuration: TimeInterval = 1.0
        let offsetMinLimit: CGFloat = -400
        let offsetMaxLimit: CGFloat = 400
        let maxBlur: CGFloat = 10
        
        //Computed properties
        var maxDistance: CGFloat {
            abs(offsetMaxLimit - offsetMinLimit)
        }
        
        //State values
        @State private var targetOffset: CGFloat = 0
        @State private var blur: CGFloat = 0
        
        //Body
        var body: some View {
            
            VStack {
                
                //Text
                Text("Some moving text")
                    .blur(radius: blur)
                    .offset(y: targetOffset)
                
                //Button
                Button("Change Offset") {
                    withAnimation(.easeInOut(duration: animationDuration)) {
                        targetOffset = .random(in: offsetMinLimit...offsetMaxLimit)
                    }
                }
            }
            .onChange(of: targetOffset) { currentOffset, targetOffset in
                
                //Calculate a blur value based on distance, where maxBlur is achieved if travelling the max possible distance
                let distanceBasedBlur = abs(targetOffset - currentOffset).rounded() / (maxDistance/maxBlur)
                
                //Animate the change in blur based on time, such that relative blur max value is reached in half the time it takes for the text to move
                withAnimation(.easeIn(duration: animationDuration / 2 )) {
                    blur = distanceBasedBlur
                } completion: {
                    
                    //When the animation ends, animate the text blur back to zero for the remaining animation duration
                    withAnimation(.easeOut(duration: animationDuration / 2 )) {
                        blur = 0
                    }
                }
            }
        }
    }
    
    #Preview {
        AnimationBlur()
    }
    
    Login or Signup to reply.
  2. You can get the current value of an animation by conforming to Animatable. For example,

    struct MovingText: View, Animatable {
        let text: String
        var offset: Double
        
        nonisolated var animatableData: Double {
            get { offset }
            set { offset = newValue }
        }
        
        var body: some View {
            Text(text)
                .offset(x: offset)
        }
    }
    

    Every frame, the setter of animatableData will be called with the current value of the offset, and the body will be called to re-evaluate how the view should look. Try putting some prints in body and animate the offset 🙂

    If you do onChange(of: offset) then calculate the difference between the old and new value, you can get an approximation of the velocity. You’d also need a way to detect whether the animation ended, so that the velocity can be set back to 0.

    struct MovingText: View, Animatable {
        let text: String
        var offset: Double
        let end: Double
        
        init(text: String, offset: Double) {
            self.text = text
            self.offset = offset
            self.end = offset
        }
        
        nonisolated var animatableData: Double {
            get { offset }
            set { offset = newValue }
        }
        @State private var velocity: Double = 0
        
        var body: some View {
            Text(text)
                .offset(x: offset)
                .blur(radius: abs(velocity))
                .onChange(of: offset) { oldValue, newValue in
                    if newValue == end {
                        velocity = 0
                    } else {
                        velocity = newValue - oldValue
                    }
                }
        }
    }
    

    Example Usage:

    struct ContentView: View {
        @State var offset: CGFloat = 0
        var body: some View {
            VStack {
                MovingText(text: "Hello", offset: offset)
                Button("Animate") {
                    withAnimation(.easeInOut(duration: 1)) {
                        offset = offset == 0 ? 100 : 0
                    }
                }
            }
        }
    }
    

    If you have the timing curve of the animation as a UnitCurve, you can get its velocity at a particular point directly using velocity(at:). You would also need to know the animation’s duration, as well as the start and end points.

    Here is an example using that idea.

    struct AnimationProperties {
        let curve: UnitCurve
        let duration: Double
        let start: CGFloat
        let end: CGFloat
    }
    
    struct MovingText: View, Animatable {
        let text: String
        var offset: Double
        let animationProperties: AnimationProperties?
        
        nonisolated var animatableData: Double {
            get { offset }
            set { offset = newValue }
        }
        
        var body: some View {
            Text(text)
                .offset(x: offset)
                .blur(radius: blurRadius)
        }
        
        var blurRadius: Double {
            if let animationProperties {
                if offset == animationProperties.start || offset == animationProperties.end {
                    return 0.0
                }
                let currentUnitValue = (offset - animationProperties.start) / (animationProperties.end - animationProperties.start)
                let time = animationProperties.curve.inverse.value(at: animationProperties.end < animationProperties.start ? 1 - currentUnitValue : currentUnitValue)
                return animationProperties.curve.velocity(at: time) / animationProperties.duration
            } else {
                return 0.0
            }
        }
    }
    
    struct ContentView: View {
        @State var offset: CGFloat = 0
        @State var animationProperties: AnimationProperties?
        var body: some View {
            VStack {
                let curve = UnitCurve.easeInOut
                MovingText(text: "Hello", offset: offset, animationProperties: animationProperties)
                Button("Animate") {
                    animationProperties = AnimationProperties(curve: curve, duration: 3, start: offset, end: offset == 0 ? 100 : 0)
                    withAnimation(.timingCurve(curve, duration: 3)) {
                        offset = offset == 0 ? 100 : 0
                    }
                }
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search