VDAnimation

master

Declarative way to create animations
dankinsoid/VDAnimation

VDAnimation

Declarative Animations for SwiftUI

VDAnimation provides a powerful, declarative way to create complex animations in Swift with minimal code. Compose animations sequentially, in parallel, with custom timing and curves.

Features

  • 🎭 Declarative animation composition
  • 🔄 Sequence and parallel animations
  • 🎚 Interactive animation control
  • 🎯 Side effects
  • 🏗 Built-in support for custom value interpolation
  • 📱 Working on iOS 13 and macOS 10.15 or later
  • 🧩 SwiftUI and UIKit support

Examples

Animating Complex Types

struct LoaderAnimation: View {

    @MotionState private var state = Tween(0.0, 0.01)
    private let arcSize = 0.4

    var body: some View {
        WithMotion(_state) { value in
            Circle()
                .trim(from: value.start, to: value.end)
                .stroke(
                    Color.white,
                    style: StrokeStyle(lineWidth: 8, lineCap: .round)
                )
                .frame(width: 50, height: 50)
                .rotationEffect(-.degrees(90), anchor: .center)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color.green)
        } motion: {
            Sequential {
                Parallel()
                    .end(arcSize) // animate .end property of the state
                    .curve(.cubicEaseIn)
    
                To(Tween(1 - arcSize, 1.0)) // animate the whole state
                    .duration(.relative((1 - arcSize) / (1 + arcSize))) // compute duration to keep movement speed constant
    
                Parallel()
                    .start(1.0 - 0.01) // animate .start property of the state
                    .curve(.cubicEaseOut)
            }
            .duration(1)
            .sync() // synchronize all loaders across the app
        }
        .onAppear {
            $state.play(repeat: true)
        }
    }
}

Animating Collections

struct DotsAnimation: View {

    @MotionState private var values: [CGFloat] = [0, 0, 0]

    var body: some View {
        WithMotion(_values) { values in
            HStack(spacing: 12) {
                ForEach(Array(values.enumerated()), id: \.offset) { value in
                    Circle()
                        .fill(.white)
                        .frame(width: 12, height: 12)
                        .offset(y: value.element)
                }
            }
        } motion: {
            Parallel { index in
                To(-10)
                    .duration(0.3)
                    .curve(.easeInOut)
                    .autoreverse()
                    .delay(.relative(Double(index) / Double(values.count * 2 - 1)))
            }
            .sync() // synchronize all loaders across the app
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.blue)
        .onAppear {
            $values.play(repeat: true)
        }
    }
}

Animating Paths Morphing

struct PathAnimation: View {
    @MotionState var path: Path = Self.heartPath

    var body: some View {
        VStack {
            WithMotion(_path) { path in
                path.fill()
            } motion: {
                Sequential {
                    Wait()
                    To(Self.dropPath)
                    Wait()
                    To(Self.starPath)
                    Wait()
                    To(Self.heartPath)
                }
                .duration(2)
            }
            .frame(width: 100, height: 100)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color.purple)
            .foregroundColor(.white)
        }
        .onAppear {
            $path.play(repeat: true)
        }
    }
}

Interactive Animation Control

struct InteractiveAnimation: View {

    @MotionState private var animation = Props()
    
    @Tweenable
    struct Props {
        var color: Color = .red
        var angle: Angle = .zero
        var offset: CGFloat = -120
    }
    
    var body: some View {
        VStack(spacing: 0) {
            WithMotion(_animation) { props in
                VStack(spacing: 10) {
                    Rectangle()
                        .fill(props.color)
                        .rotationEffect(props.angle, anchor: .center)
                        .offset(x: props.offset)
                        .frame(width: 100, height: 100)
                    Slider(value: _animation.$progress, in: 0...1)
                        .padding(.horizontal)
                }
            } motion: {
                To(
                    Props(
                        color: .blue,
                        angle: .degrees(360),
                        offset: 120
                    )
                )
                .duration(2.0)
            }
            
            if $animation.isAnimating {
                Button("Pause") { $animation.pause() }
            } else {
                Button("Play") {
                    if $animation.progress == 1.0 || $animation.progress == 0.0 {
                        $animation.reverse()
                    } else {
                        $animation.play()
                    }
                }
            }
        }
    }
}

Complex movement

struct ComplexMovement: View {

    @MotionState var location = CGPoint(x: -100, y: 0)

    var body: some View {
        Circle()
            .fill(Color.white)
            .withMotion(_location) {
                $0.position($1)
            } motion: {
                Lerp { t in
                    CGPoint(
                        x: cos(Double.lerp(0, .pi * 2, t)) * 100,
                        y: sin(Double.lerp(0, .pi * 6, t)) * 40
                    )
                }
                .duration(2)
            }
            .offset(y: 10)
            .frame(width: 40, height: 40)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color.red)
            .onTapGesture {
                if $location.isAnimating {
                    $location.stop()
                } else {
                    $location.play(repeat: true)
                }
            }
    }
}

UIKit CADisplayLink wrapper

motionDisplayLink(Value(amount: 0, color: .systemRed)) { [label] value in
    label.text = "\(value.amount) USD"
    label.textColor = value.color
} motion: {
    To(Value(amount: 1000, color: .systemGreen))
        .delay(.relative(0.2))
        .delayAfter(.relative(0.2))
        .duration(2)
}
.play()

Usage

Installation

Swift Package Manager

Add the package dependency to your Package.swift:

dependencies: [
    .package(url: "https://github.com/dankinsoid/VDAnimation.git", from: "2.3.0")
]

Or add it directly in Xcode via File > Add Packages...

License

VDAnimation is available under the MIT license. See the LICENSE file for more info.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

Last updated: Tue May 13 2025 01:58:23 GMT-0900 (Hawaii-Aleutian Daylight Time)