How to animate a view without explicitly triggering model changes?

First, a little context. I was trying to create a simple animation of a view that pulsates indefinitely and found it to be a really frustrating experience, probably because I don’t yet understand the SwiftUI animation model correctly. If someone has a simple solution, please share.


Anyway, it occurred to me that it would be nice to have a view modifier that you attach to a view to express the desire to “animate property X of the view from value ‘from’ to value ‘to’, starting at value ‘startingAt’, using animation ‘animation’. All the view needs to do is expose that property X, as a key path or maybe a binding, and SwiftUI does the rest.


This would allow for an easy way to animate several properties independently but simultaneously, each with their own animation parameters, as well as a way to animate a group of properties together (the view exposes some property which it uses internally to control a group of properties).


Is this something that’s already possible and easy to do? If so, how?


Here’s what the setup above would/might look like in actual code:


struct ContentView: View {


// Note that, in this setup, x, y, and z are all animated independently from

// each other, and with their own custom parameters.


// Properties can be animated together by exposing a single property that

// controls them internally (as 'z' does in the example below).


// All parameters are optional:

// - 'from' defaults to 0

// - 'to' defaults to 1

// - 'startingAt' defaults to 'from'

// - the animation defaults to 'basic()' with its default parameters


// Instead of exposing a key-path, perhaps exposing a binding would make

// more sense and/or be more appropriate.


var body: some View {

SomeView()

// .animating(\.x, from: 0.3, to: 0.8, startingAt: 0.4,

// Animation.basic().repeatForever(autoreverses: true))

// .animating(\.y, from: 5.0, to: 3.0, startingAt: 3.5,

// Some Animation here)

// .animating(\.z, from: 0.25, to: 0.7, startingAt: 0.5,

// Some Animation here)

}

}


struct SomeView: View {


// Some property exposed by SomeView, which some

// aspect of its layout or rendering depends on.

var x: CGFloat = 0


// Some other property exposed by SomeView, which some

// other aspect of its layout or rendering depends on.

var y: Double = 0


// This property is used internally to control several other

// such properties, in essence animating them together.

var z: Double = 0


var body: some View {

Circle()

.fill(Color.red)

}

}

Replies

Upon further reflection, I think the idea I suggested above (assuming it makes sense and that there’s no simpler way to do it already) could be enhanced by adding an ‘isActive’ boolean to the view modifier function ‘.animating(...)’ so that we can programmatically start or stop the animation.


Moreover, since the animation is entirely under SwiftUI’s control, maybe those exposed properties should be marked with a property wrapper that gives SwiftUI read-write access but gives the view that exposes them read-only access.


So, something like the following:


struct SomeView {
     @AutoAnimated private var x: CGFloat // the compiler ensures that x is read-only to us but read-write to SwiftUI
     // ....
}

struct EnclosingView {
     @State private var xAnimActive = true // this view can set and change this value to control the animation life cycle
     SomeView()
          .animating(\.x, from: /* some value */, to: /* some value */, startingAt: /* some value */,
                            animation: /* some animation here */,
                            active: xAnimActive)
}

For reference, I’ve logged a suggestion with Apple’s issue tracker. The issue number is FB6145347.

I nearly got a working implementation (well, in theory) but I got stuck because of a private API.


import SwiftUI

struct ContentView: View {

    var body: some View {
        CircleView()
            .animating(\.s, // QQQ Not sure why the generic parameter Value could not be inferred
                       from: 0.2, to: 0.8, startingAt: 0.3,
                       animation: .basic(duration: 0.8, curve: .easeInEaseOut),
                       isActive: true)
    }

}

struct CircleView: View {

    @AutoAnimated private var s: CGFloat

    var body: some View {
        Circle()
            .fill(Color.red)
            .scaleEffect(s)
    }

}

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

// =========================================== //

// QQQ There should be an extension to ViewModifier that does work
// nearly identical to that of the extension to View below, so that
// we can call '.animating(...)' on the result of another view modifier.
public extension ViewModifier {}

// ==================== //

public extension View {

    func animating<Value: BinaryFloatingPoint>(_ propertyKeyPath: WritableKeyPath<Body, Value>,
                                               from fromValue: Value = 0,
                                               to toValue: Value = 1,
                                               startingAt startValue: Value,
                                               animation: Animation = .default,
                                               isActive: Bool = true) -> some View
        where Body == AutoAnimationViewModifier<Value>.Body {

            return AutoAnimationViewModifier<Value>()
                .animating(propertyKeyPath,
                           from: fromValue,
                           to: toValue,
                           startingAt: startValue,
                           animation: animation,
                           isActive: isActive)
                .body(content: self.body) // QQQ Don't know how to fix the type mismatch here,
                                          // since it involves a type private to SwiftUI

    }

}

// ==================== //

public struct AutoAnimationViewModifier<Value: BinaryFloatingPoint>: ViewModifier {

    public init() {}

    public func animating(_ propertyKeyPath: WritableKeyPath<Body, Value>,
                          from fromValue: Value = 0,
                          to toValue: Value = 1,
                          startingAt startValue: Value = 0,
                          animation: Animation = .default,
                          isActive: Bool = true) -> AutoAnimationViewModifier {

        .init(propertyKeyPath,
              from: fromValue,
              to: toValue,
              startingAt: startValue,
              animation: animation,
              isActive: isActive)

    }

    public func body(content: Content) -> some View {
        content // QQQ Not sure how to apply the animation since the whole point
                // is not to have to explicitly trigger model changes
    }

    private var animatedPropertyKeyPath: WritableKeyPath<Body, Value>!
    private var from: Value = 0
    private var to: Value = 1
    private var startingAt: Value = 0
    private var animation: Animation = .default
    private var isActive: Bool = true

    private init(_ propertyKeyPath: WritableKeyPath<Body, Value>! = nil,
                 from fromValue: Value = 0,
                 to toValue: Value = 1,
                 startingAt startValue: Value,
                 animation: Animation = .default,
                 isActive: Bool = true) {

        self.animatedPropertyKeyPath = propertyKeyPath
        self.from = fromValue
        self.to = toValue
        let low = min(fromValue, toValue)
        let high = max(fromValue, toValue)
        self.startingAt = min(max(low, startingAt), high)
        self.animation = animation
        self.isActive = isActive

    }

}

// ==================== //

@propertyWrapper
public struct AutoAnimated<Value: BinaryFloatingPoint> {

    @State private var storage: Value = 0

    public init() {}

    public var value: Value {
        storage
    }

}