Precise Animation Control with AnimationController

In this tutorial, we’ll learn how to precisely control animations and implement complex animation sequences using Flitter’s AnimationController.

🎯 Learning Goals

After completing this tutorial, you’ll be able to:

  • Understand the basic concepts and lifecycle of AnimationController
  • Define start and end values using Tween
  • Apply easing effects with CurvedAnimation
  • Control animation playback, pause, reverse, and repeat
  • Execute multiple animations sequentially
  • Implement complex animation sequences

🚀 Explicit vs Implicit Animations

The AnimatedContainer, AnimatedOpacity, etc. we’ve learned so far are implicit animations:

  • Automatically animate when property values change
  • Simple and easy to use
  • Limited control functionality

Explicit animations use AnimationController:

  • Direct control of animations (start, stop, speed, etc.)
  • Can implement complex animation sequences
  • Used when more precise control is needed

🎨 AnimationController Basic Concepts

Core Components

  1. AnimationController: Controls the animation’s progress state
  2. Tween: Defines start and end values
  3. Animation: Connects Controller and Tween
  4. CurvedAnimation: Applies easing effects (optional)

Basic Structure

class MyAnimationState extends State<MyAnimation> {
  controller = null;
  animation = null;

  initState(context) {
    super.initState(context);
    
    // 1. Create Controller
    this.controller = AnimationController({ 
      duration: 1000  // Duration (milliseconds)
    });
    
    // 2. Create Tween and Animation
    this.animation = Tween({ 
      begin: 0, 
      end: 100 
    }).animated(this.controller);
    
    // 3. Register listener (call setState)
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    // 4. Memory cleanup (required!)
    this.controller.dispose();
    super.dispose();
  }
}

📋 Step-by-Step Practice

Step 1: Basic AnimationController Usage

Let’s directly control a simple size animation:

import { AnimationController, Tween, Animation, CurvedAnimation } from "@meursyphus/flitter";

class BasicControllerAnimation extends StatefulWidget {
  createState() {
    return new BasicControllerAnimationState();
  }
}

class BasicControllerAnimationState extends State<BasicControllerAnimation> {
  controller = null;
  animation = null;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 1000 });
    this.animation = Tween({ begin: 50, end: 200 }).animated(this.controller);
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  build(context) {
    return Column({
      children: [
        // Control buttons
        Row({
          children: [
            GestureDetector({
              onClick: () => this.controller.forward(),
              child: Container({
                width: 80,
                height: 40,
                color: '#4CAF50',
                child: Text("Start")
              })
            }),
            
            GestureDetector({
              onClick: () => this.controller.reverse(),
              child: Container({
                width: 80,
                height: 40,
                color: '#FF5722',
                child: Text("Reverse")
              })
            }),
            
            GestureDetector({
              onClick: () => this.controller.reset(),
              child: Container({
                width: 80,
                height: 40,
                color: '#9E9E9E',
                child: Text("Reset")
              })
            })
          ]
        }),
        
        // Animated box
        Container({
          width: this.animation.value,
          height: this.animation.value,
          color: '#2196F3',
          child: Text(`${Math.round(this.animation.value)}px`)
        })
      ]
    });
  }
}

Step 2: Adding Easing Effects with CurvedAnimation

Let’s apply easing effects to make animations more natural:

class CurvedAnimationExample extends StatefulWidget {
  createState() {
    return CurvedAnimationExampleState();
  }
}

class CurvedAnimationExampleState extends State<CurvedAnimationExample> {
  controller = null;
  linearAnimation = null;
  curvedAnimation = null;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 2000 });
    
    // Linear animation
    this.linearAnimation = Tween({ 
      begin: 0, 
      end: 250 
    }).animated(this.controller);
    
    // Curved animation
    this.curvedAnimation = Tween({ 
      begin: 0, 
      end: 250 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: "bounceOut"
    }));
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  build(context) {
    return Column({
      children: [
        GestureDetector({
          onClick: () => {
            if (this.controller.isCompleted) {
              this.controller.reverse();
            } else {
              this.controller.forward();
            }
          },
          child: Container({
            width: 120,
            height: 50,
            color: '#673AB7',
            child: Text("Toggle Animation")
          })
        }),
        
        // Linear animation
        Container({
          width: this.linearAnimation.value,
          height: 40,
          color: '#FF9800',
          child: Text("Linear")
        }),
        
        // Curved animation
        Container({
          width: this.curvedAnimation.value,
          height: 40,
          color: '#E91E63',
          child: Text("BounceOut")
        })
      ]
    });
  }
}

Step 3: Animating Multiple Properties Simultaneously

Let’s animate multiple properties simultaneously with one Controller:

class MultiPropertyAnimation extends StatefulWidget {
  createState() {
    return new MultiPropertyAnimationState();
  }
}

class MultiPropertyAnimationState extends State<MultiPropertyAnimation> {
  controller = null;
  sizeAnimation = null;
  colorAnimation = null;
  rotationAnimation = null;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 2000 });
    
    // Size animation
    this.sizeAnimation = Tween({ 
      begin: 80, 
      end: 160 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: "easeInOut"
    }));
    
    // Color animation (0-1 value for color interpolation)
    this.colorAnimation = Tween({ 
      begin: 0, 
      end: 1 
    }).animated(this.controller);
    
    // Rotation animation
    this.rotationAnimation = Tween({ 
      begin: 0, 
      end: 2 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: "elasticOut"
    }));
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  getInterpolatedColor() {
    const t = this.colorAnimation.value;
    const r = Math.round(255 * (1 - t) + 100 * t);
    const g = Math.round(150 * (1 - t) + 200 * t);
    const b = Math.round(200 * (1 - t) + 50 * t);
    return `rgb(${r}, ${g}, ${b})`;
  }

  build(context) {
    return Column({
      children: [
        GestureDetector({
          onClick: () => {
            if (this.controller.status === "completed") {
              this.controller.reverse();
            } else {
              this.controller.forward();
            }
          },
          child: Container({
            width: 150,
            height: 50,
            color: '#607D8B',
            child: Text("Multi-Property Animation")
          })
        }),
        
        Transform.rotate({
          angle: this.rotationAnimation.value * Math.PI,  // Convert to radians
          child: Container({
            width: this.sizeAnimation.value,
            height: this.sizeAnimation.value,
            color: this.getInterpolatedColor(),
            child: Text("🎨")
          })
        })
      ]
    });
  }
}

Step 4: Sequential Animation (Using Interval)

Let’s create animations that execute sequentially with one Controller:

import { Interval } from "@meursyphus/flitter";

class SequentialAnimation extends StatefulWidget {
  createState() {
    return new SequentialAnimationState();
  }
}

class SequentialAnimationState extends State<SequentialAnimation> {
  controller = null;
  slideAnimation = null;
  fadeAnimation = null;
  scaleAnimation = null;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 3000 });
    
    // 0~1 second: Slide animation
    this.slideAnimation = Tween({ 
      begin: -200, 
      end: 0 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: new Interval(0.0, 0.33, { curve: "easeOut" })
    }));
    
    // 1~2 seconds: Fade animation
    this.fadeAnimation = Tween({ 
      begin: 0.0, 
      end: 1.0 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: new Interval(0.33, 0.66, { curve: "easeIn" })
    }));
    
    // 2~3 seconds: Scale animation
    this.scaleAnimation = Tween({ 
      begin: 0.5, 
      end: 1.0 
    }).animated(CurvedAnimation({
      parent: this.controller,
      curve: new Interval(0.66, 1.0, { curve: "bounceOut" })
    }));
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  build(context) {
    return Column({
      children: [
        GestureDetector({
          onClick: () => {
            this.controller.reset();
            this.controller.forward();
          },
          child: Container({
            width: 150,
            height: 50,
            color: '#795548',
            child: Text("Start Sequential Animation")
          })
        }),
        
        Transform.translate({
          offset: new Offset(this.slideAnimation.value, 0),
          child: Transform.scale({
            scale: this.scaleAnimation.value,
            child: AnimatedOpacity({
              duration: 0,  // Immediate change (Controller controls)
              opacity: this.fadeAnimation.value,
              child: Container({
                width: 120,
                height: 80,
                color: '#009688',
                child: Text("Sequential Appear!")
              })
            })
          })
        })
      ]
    });
  }
}

Step 5: Repeating Animation

Let’s create animations that repeat automatically:

class RepeatAnimation extends StatefulWidget {
  createState() {
    return new RepeatAnimationState();
  }
}

class RepeatAnimationState extends State<RepeatAnimation> {
  controller = null;
  animation = null;
  isRunning = false;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 1000 });
    this.animation = Tween({ begin: -50, end: 50 }).animated(
      CurvedAnimation({
        parent: this.controller,
        curve: "easeInOut"
      })
    );
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  startRepeating() {
    this.isRunning = true;
    this.controller.repeat({ reverse: true });
  }

  stopRepeating() {
    this.isRunning = false;
    this.controller.stop();
  }

  build(context) {
    return Column({
      children: [
        Row({
          children: [
            GestureDetector({
              onClick: () => this.startRepeating(),
              child: Container({
                width: 80,
                height: 40,
                color: '#4CAF50',
                child: Text("Start")
              })
            }),
            
            GestureDetector({
              onClick: () => this.stopRepeating(),
              child: Container({
                width: 80,
                height: 40,
                color: '#F44336',
                child: Text("Stop")
              })
            })
          ]
        }),
        
        Container({
          width: 200,
          height: 100,
          color: '#F5F5F5',
          child: Stack({
            children: [
              Positioned({
                left: 100 + this.animation.value,
                top: 25,
                child: Container({
                  width: 50,
                  height: 50,
                  color: '#FF5722',
                  child: Text("🏃‍♂️")
                })
              })
            ]
          })
        })
      ]
    });
  }
}

🎯 Practice Challenges

TODO 1: Animation with Progress Display

Create an animation with a progress bar that shows animation progress:

class ProgressAnimation extends StatefulWidget {
  createState() {
    return new ProgressAnimationState();
  }
}

class ProgressAnimationState extends State<ProgressAnimation> {
  controller = null;
  animation = null;

  initState(context) {
    super.initState(context);
    
    this.controller = AnimationController({ duration: 3000 });
    this.animation = Tween({ begin: 0, end: 300 }).animated(
      CurvedAnimation({
        parent: this.controller,
        curve: "easeInOut"
      })
    );
    
    this.controller.addListener(() => {
      this.setState();
    });
  }

  dispose() {
    this.controller.dispose();
    super.dispose();
  }

  build(context) {
    const progress = this.controller.value; // 0.0 ~ 1.0
    
    return Column({
      children: [
        // Progress display
        Container({
          width: 300,
          height: 20,
          color: '#E0E0E0',
          child: Container({
            width: 300 * progress,
            height: 20,
            color: '#4CAF50'
          })
        }),
        
        Text(`Progress: ${Math.round(progress * 100)}%`),
        
        // Control buttons
        Row({
          children: [
            GestureDetector({
              onClick: () => this.controller.forward(),
              child: Container({
                width: 60,
                height: 40,
                color: '#2196F3',
                child: Text("")
              })
            }),
            
            GestureDetector({
              onClick: () => this.controller.reverse(),
              child: Container({
                width: 60,
                height: 40,
                color: '#FF9800',
                child: Text("")
              })
            }),
            
            GestureDetector({
              onClick: () => this.controller.stop(),
              child: Container({
                width: 60,
                height: 40,
                color: '#F44336',
                child: Text("")
              })
            }),
            
            GestureDetector({
              onClick: () => this.controller.reset(),
              child: Container({
                width: 60,
                height: 40,
                color: '#9E9E9E',
                child: Text("")
              })
            })
          ]
        }),
        
        // Animated element
        Container({
          width: this.animation.value,
          height: 60,
          color: '#E91E63',
          child: Text(`${Math.round(this.animation.value)}px`)
        })
      ]
    });
  }
}

TODO 2: Multi-Step Animation Sequence

Create a multi-step animation that proceeds to the next step with each button click:

class MultiStepAnimation extends StatefulWidget {
  createState() {
    return new MultiStepAnimationState();
  }
}

class MultiStepAnimationState extends State<MultiStepAnimation> {
  controllers = [];
  animations = [];
  currentStep = 0;
  
  steps = [
    { name: "Appear", duration: 500, curve: "easeOut" },
    { name: "Rotate", duration: 800, curve: "elasticOut" },
    { name: "Scale", duration: 600, curve: "bounceOut" },
    { name: "Fade", duration: 400, curve: "easeIn" }
  ];

  initState(context) {
    super.initState(context);
    
    // Create controllers for each step
    this.steps.forEach((step, index) => {
      const controller = AnimationController({ duration: step.duration });
      controller.addListener(() => this.setState());
      this.controllers.push(controller);
    });
    
    // Define animations for each step
    this.animations = [
      Tween({ begin: -200, end: 0 }).animated(
        CurvedAnimation({
          parent: this.controllers[0],
          curve: "easeOut"
        })
      ),
      Tween({ begin: 0, end: 1 }).animated(
        CurvedAnimation({
          parent: this.controllers[1],
          curve: "elasticOut"
        })
      ),
      Tween({ begin: 1, end: 2 }).animated(
        CurvedAnimation({
          parent: this.controllers[2],
          curve: "bounceOut"
        })
      ),
      Tween({ begin: 1, end: 0 }).animated(
        CurvedAnimation({
          parent: this.controllers[3],
          curve: "easeIn"
        })
      )
    ];
  }

  dispose() {
    this.controllers.forEach(controller => controller.dispose());
    super.dispose();
  }

  nextStep() {
    if (this.currentStep < this.steps.length) {
      this.controllers[this.currentStep].forward();
      this.currentStep++;
    }
  }

  reset() {
    this.controllers.forEach(controller => controller.reset());
    this.currentStep = 0;
    this.setState();
  }

  build(context) {
    return Column({
      children: [
        Text(`Current Step: ${this.currentStep} / ${this.steps.length}`),
        
        Row({
          children: [
            GestureDetector({
              onClick: () => this.nextStep(),
              child: Container({
                width: 100,
                height: 40,
                color: this.currentStep < this.steps.length ? '#4CAF50' : '#BDBDBD',
                child: Text("Next Step")
              })
            }),
            
            GestureDetector({
              onClick: () => this.reset(),
              child: Container({
                width: 80,
                height: 40,
                color: '#FF5722',
                child: Text("Reset")
              })
            })
          ]
        }),
        
        // Animation element
        Transform.translate({
          offset: new Offset(this.animations[0].value, 0),
          child: Transform.rotate({
            angle: this.animations[1].value * Math.PI,
            child: Transform.scale({
              scale: this.animations[2].value,
              child: AnimatedOpacity({
                duration: 0,
                opacity: this.animations[3].value,
                child: Container({
                  width: 80,
                  height: 80,
                  color: '#9C27B0',
                  child: Text("🎭")
                })
              })
            })
          })
        })
      ]
    });
  }
}

🎨 Expected Results

When completed, the following features should work:

  1. Basic control: Control animations with forward, reverse, reset
  2. Curve effects: See the difference between linear and curved animations
  3. Multiple properties: Size, color, and rotation animate simultaneously
  4. Sequential execution: Execute in order: slide → fade → scale
  5. Repeating animation: Automatically repeat back and forth
  6. Progress display: Check animation progress in real-time

💡 Additional Challenges

For more challenges:

  1. Complex sequences: Complex animation sequences with 5+ steps
  2. Interactive control: Direct control of animation progress with drag
  3. Conditional animations: Execute different animations based on specific conditions
  4. Physics-based: Simulate spring effects or gravity effects

⚠️ Common Mistakes and Solutions

1. Forgetting dispose()

// ❌ No dispose - Memory leak!
dispose() {
  super.dispose();  // Missing controller.dispose()
}

// ✅ Always call dispose
dispose() {
  this.controller.dispose();
  super.dispose();
}

2. Not Calling setState in addListener

// ❌ No setState - UI won't update
this.controller.addListener(() => {
  // No setState() call
});

// ✅ Update UI with setState
this.controller.addListener(() => {
  this.setState();
});

3. Wrong Tween Range Settings

// ❌ Unintended range
Tween({ begin: 100, end: 0 })  // Decreasing animation

// ✅ Check intended range
Tween({ begin: 0, end: 100 })  // Increasing animation

4. Interval Range Errors

// ❌ Wrong range (exceeds 0.0~1.0)
new Interval(0.5, 1.5)  // Exceeds 1.0

// ✅ Correct range
new Interval(0.0, 0.5)  // First half
new Interval(0.5, 1.0)  // Second half

5. Confusing Rotation Angle Units

// ❌ Using degree units
Transform.rotate({ angle: 90 })  // Not 90 degrees!

// ✅ Using radian units
Transform.rotate({ angle: Math.PI / 2 })  // 90 degrees

🎓 Key Summary

AnimationController Lifecycle

  1. initState(): Create Controller, set up Animation, register Listener
  2. build(): Build UI using animation.value
  3. dispose(): Release Controller memory (required!)

Control Methods

  • forward(): Animate from start → end
  • reverse(): Animate from end → start (reverse direction)
  • reset(): Move to start position immediately
  • stop(): Stop at current position
  • repeat(): Repeating animation
  • animateTo(value): Animate to specific value

Status Check

  • controller.value: Current progress (0.0 ~ 1.0)
  • controller.status: Animation status
  • controller.isCompleted: Whether completed
  • controller.isAnimating: Whether in progress

AnimationController is the most powerful animation tool in Flitter. It’s essential for implementing advanced animations that require precise control and complex sequences.

🚀 Next Steps

In the next tutorial, let’s learn Custom Animations:

  • Creating reusable animation widgets by inheriting AnimatedWidget
  • Combining CustomPaint with animations
  • Implementing physics-based animations
  • Applying performance optimization techniques

Next: Custom Animations →