Custom Animations¶
Create reusable animation patterns
Build custom animations that integrate with Fluvie's frame-based animation system.
Table of Contents¶
Overview¶
Fluvie's animation system is frame-based and deterministic. Custom animations should:
- Accept frame numbers as input
- Return consistent values for the same frame
- Support easing curves
- Work in both preview and render modes
PropAnimation¶
Creating a Custom PropAnimation¶
class BounceAnimation extends PropAnimation {
final double bounceHeight;
final int bounces;
const BounceAnimation({
this.bounceHeight = 50.0,
this.bounces = 3,
super.curve = Curves.linear,
});
@override
Matrix4 transformAt(double progress) {
// Calculate bounce position
final bounceProgress = progress * bounces;
final currentBounce = bounceProgress.floor();
final bouncePhase = bounceProgress - currentBounce;
// Damping: each bounce is smaller
final damping = 1.0 / (currentBounce + 1);
// Parabolic motion within each bounce
final y = -4 * bouncePhase * (bouncePhase - 1) * bounceHeight * damping;
return Matrix4.identity()..translate(0.0, y);
}
@override
double opacityAt(double progress) => 1.0; // No opacity change
}
Using Your Animation¶
AnimatedProp(
startFrame: 0,
duration: 60,
animation: BounceAnimation(
bounceHeight: 100,
bounces: 4,
),
child: Image.asset('assets/ball.png'),
)
Combining with Built-in Animations¶
AnimatedProp(
startFrame: 0,
duration: 60,
animation: PropAnimation.combine([
BounceAnimation(bounceHeight: 50),
PropAnimation.fadeIn(),
]),
child: content,
)
PropAnimation Methods¶
When creating custom PropAnimation subclasses, override these methods:
transformAt(double progress)¶
Returns the transformation matrix at the given progress (0.0 to 1.0):
@override
Matrix4 transformAt(double progress) {
// progress: 0.0 = start, 1.0 = end
final scale = 1.0 + progress * 0.5; // Scale from 1x to 1.5x
final rotation = progress * pi; // Rotate 180 degrees
return Matrix4.identity()
..scale(scale)
..rotateZ(rotation);
}
opacityAt(double progress)¶
Returns the opacity at the given progress:
@override
double opacityAt(double progress) {
// Fade in during first half
if (progress < 0.5) {
return progress * 2;
}
return 1.0;
}
colorAt(double progress)¶
Returns a color tint at the given progress (optional):
Entry Animations¶
Creating a Custom EntryAnimation¶
class TypewriterEntry extends EntryAnimation {
final Duration charDelay;
const TypewriterEntry({
this.charDelay = const Duration(milliseconds: 50),
super.duration = const Duration(milliseconds: 500),
super.curve = Curves.easeOut,
});
@override
Widget buildAnimation(
BuildContext context,
Animation<double> animation,
Widget child,
) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
if (child is Text) {
final text = child.data ?? '';
final visibleChars = (text.length * animation.value).round();
return Text(
text.substring(0, visibleChars),
style: child.style,
);
}
return child!;
},
child: child,
);
}
}
Entry Animation Factory¶
class CustomEntryAnimations {
static EntryAnimation typewriter({
Duration charDelay = const Duration(milliseconds: 50),
}) {
return TypewriterEntry(charDelay: charDelay);
}
static EntryAnimation glitch({
int glitchCount = 5,
}) {
return GlitchEntry(glitchCount: glitchCount);
}
}
Interpolation Functions¶
Custom Interpolation¶
Create specialized interpolation for complex animations:
/// Interpolates along a bezier curve path
double bezierInterpolate({
required int frame,
required int startFrame,
required int endFrame,
required List<Offset> controlPoints,
Curve curve = Curves.linear,
}) {
final progress = ((frame - startFrame) / (endFrame - startFrame))
.clamp(0.0, 1.0);
final easedProgress = curve.transform(progress);
return _evaluateBezier(controlPoints, easedProgress);
}
double _evaluateBezier(List<Offset> points, double t) {
// De Casteljau's algorithm
if (points.length == 1) return points[0].dy;
final newPoints = <Offset>[];
for (var i = 0; i < points.length - 1; i++) {
newPoints.add(Offset.lerp(points[i], points[i + 1], t)!);
}
return _evaluateBezier(newPoints, t);
}
Spring Physics¶
class SpringAnimation {
final double stiffness;
final double damping;
final double mass;
const SpringAnimation({
this.stiffness = 100.0,
this.damping = 10.0,
this.mass = 1.0,
});
double valueAt(double progress) {
// Damped harmonic oscillator
final omega = sqrt(stiffness / mass);
final zeta = damping / (2 * sqrt(stiffness * mass));
if (zeta < 1) {
// Underdamped
final omegaD = omega * sqrt(1 - zeta * zeta);
return 1 - exp(-zeta * omega * progress) *
cos(omegaD * progress);
} else {
// Critically or overdamped
return 1 - exp(-omega * progress);
}
}
}
Testing Animations¶
Frame-by-Frame Testing¶
void main() {
group('BounceAnimation', () {
test('starts at rest position', () {
final animation = BounceAnimation(bounceHeight: 50);
final transform = animation.transformAt(0.0);
// Should be at y=0 at start
expect(transform.getTranslation().y, equals(0.0));
});
test('reaches peak at mid-bounce', () {
final animation = BounceAnimation(bounceHeight: 50, bounces: 1);
final transform = animation.transformAt(0.5);
// Should be at peak height mid-bounce
expect(transform.getTranslation().y, closeTo(-50.0, 0.1));
});
test('returns to rest at end', () {
final animation = BounceAnimation(bounceHeight: 50);
final transform = animation.transformAt(1.0);
expect(transform.getTranslation().y, closeTo(0.0, 0.1));
});
});
}
Visual Testing¶
testWidgets('BounceAnimation visual test', (tester) async {
final animation = BounceAnimation(bounceHeight: 100);
// Test at key frames
for (final frame in [0, 15, 30, 45, 60]) {
final progress = frame / 60;
await tester.pumpWidget(
MaterialApp(
home: Transform(
transform: animation.transformAt(progress),
child: Container(
width: 50,
height: 50,
color: Colors.blue,
),
),
),
);
await expectLater(
find.byType(Container),
matchesGoldenFile('bounce_frame_$frame.png'),
);
}
});
Examples¶
Shake Animation¶
class ShakeAnimation extends PropAnimation {
final double intensity;
final int shakes;
const ShakeAnimation({
this.intensity = 10.0,
this.shakes = 5,
super.curve = Curves.easeOut,
});
@override
Matrix4 transformAt(double progress) {
// Damped shake
final damping = 1.0 - progress;
final shakeProgress = progress * shakes * 2 * pi;
final offset = sin(shakeProgress) * intensity * damping;
return Matrix4.identity()..translate(offset, 0.0);
}
@override
double opacityAt(double progress) => 1.0;
}
Wobble Animation¶
class WobbleAnimation extends PropAnimation {
final double angle;
final int wobbles;
const WobbleAnimation({
this.angle = 0.1, // radians
this.wobbles = 3,
super.curve = Curves.easeInOut,
});
@override
Matrix4 transformAt(double progress) {
final damping = 1.0 - progress;
final wobbleAngle = sin(progress * wobbles * 2 * pi) * angle * damping;
return Matrix4.identity()..rotateZ(wobbleAngle);
}
@override
double opacityAt(double progress) => 1.0;
}
Path-Following Animation¶
class PathAnimation extends PropAnimation {
final Path path;
final PathMetric _pathMetric;
PathAnimation({required this.path})
: _pathMetric = path.computeMetrics().first;
@override
Matrix4 transformAt(double progress) {
final distance = _pathMetric.length * progress;
final tangent = _pathMetric.getTangentForOffset(distance);
if (tangent == null) return Matrix4.identity();
return Matrix4.identity()
..translate(tangent.position.dx, tangent.position.dy)
..rotateZ(tangent.angle);
}
@override
double opacityAt(double progress) => 1.0;
}
// Usage
final heartPath = Path()
..moveTo(100, 50)
..cubicTo(100, 0, 50, 0, 50, 50)
..cubicTo(50, 80, 100, 100, 100, 130)
..cubicTo(100, 100, 150, 80, 150, 50)
..cubicTo(150, 0, 100, 0, 100, 50);
AnimatedProp(
startFrame: 0,
duration: 90,
animation: PathAnimation(path: heartPath),
child: Icon(Icons.favorite, color: Colors.red),
)
Related¶
- PropAnimation - Built-in animations
- Entry Animations - Entry animation system
- Interpolate - Interpolation function
- TimeConsumer - Frame-based widget