From 5c74f70c4d5ecb2cfcec317c850f96b6a471be82 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62367544+tilucasoli@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:51:07 -0300 Subject: [PATCH 1/3] feat: add loop animation on PhaseAnimationDriver --- .../lib/src/animation/animation_config.dart | 2 +- .../src/animation/style_animation_driver.dart | 21 ++- .../style/mixins/animation_style_mixin.dart | 2 +- .../style_animation_driver_test.dart | 166 +++++++++++++----- 4 files changed, 138 insertions(+), 53 deletions(-) diff --git a/packages/mix/lib/src/animation/animation_config.dart b/packages/mix/lib/src/animation/animation_config.dart index 24caaf7ffe..1fd782b89d 100644 --- a/packages/mix/lib/src/animation/animation_config.dart +++ b/packages/mix/lib/src/animation/animation_config.dart @@ -779,7 +779,7 @@ class PhaseAnimationConfig, U extends Style> with Equatable { final List styles; final List curveConfigs; - final Listenable trigger; + final Listenable? trigger; final VoidCallback? onEnd; const PhaseAnimationConfig({ diff --git a/packages/mix/lib/src/animation/style_animation_driver.dart b/packages/mix/lib/src/animation/style_animation_driver.dart index 6ed26a0b92..f5f6679660 100644 --- a/packages/mix/lib/src/animation/style_animation_driver.dart +++ b/packages/mix/lib/src/animation/style_animation_driver.dart @@ -239,6 +239,9 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { required this.context, }) { _setUpAnimation(); + if (_isLooping) { + _startLoopingAnimation(); + } } void _setUpAnimation() { @@ -251,7 +254,7 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { // Override the animation to use TweenSequence wrapped in a tween _animation = controller.drive(_PhasedSpecTween(_tweenSequence)); - config.trigger.addListener(_onTriggerChanged); + config.trigger?.addListener(_onTriggerChanged); // Add status listener for onEnd callback if (config.curveConfigs.last.onEnd != null) { @@ -301,6 +304,13 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { return TweenSequence(items); } + void _startLoopingAnimation() { + controller.duration = totalDuration; + controller.repeat(); + } + + bool get _isLooping => config.trigger == null; + /// Gets the total duration of all animation phases combined. Duration get totalDuration { return config.curveConfigs.fold( @@ -311,7 +321,8 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { @override void dispose() { - config.trigger.removeListener(_onTriggerChanged); + config.trigger?.removeListener(_onTriggerChanged); + controller.stop(); super.dispose(); } @@ -324,7 +335,11 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { @override void updateDriver(covariant PhaseAnimationConfig config) { - config.trigger.removeListener(_onTriggerChanged); + config.trigger?.removeListener(_onTriggerChanged); + if (_isLooping) { + controller.reset(); + _startLoopingAnimation(); + } this.config = config; _setUpAnimation(); } diff --git a/packages/mix/lib/src/style/mixins/animation_style_mixin.dart b/packages/mix/lib/src/style/mixins/animation_style_mixin.dart index f1bdfc2454..f892f97acd 100644 --- a/packages/mix/lib/src/style/mixins/animation_style_mixin.dart +++ b/packages/mix/lib/src/style/mixins/animation_style_mixin.dart @@ -26,7 +26,7 @@ mixin AnimationStyleMixin, S extends Spec> on Style { /// Creates a phase animation. It will animate through the given phases. T phaseAnimation

({ - required Listenable trigger, + Listenable? trigger, required List

phases, required T Function(P phase, T style) styleBuilder, required CurveAnimationConfig Function(P phase) configBuilder, diff --git a/packages/mix/test/src/animation/style_animation_driver_test.dart b/packages/mix/test/src/animation/style_animation_driver_test.dart index 7ca7c11d80..b58f6410ae 100644 --- a/packages/mix/test/src/animation/style_animation_driver_test.dart +++ b/packages/mix/test/src/animation/style_animation_driver_test.dart @@ -342,15 +342,12 @@ void main() { }); group('PhaseAnimationDriver', () { - late PhaseAnimationDriver driver; - late ValueNotifier trigger; - late MockBuildContext mockContext; - - setUp(() { - trigger = ValueNotifier(false); - mockContext = MockBuildContext(); + final mockContext = MockBuildContext(); - final config = PhaseAnimationConfig( + PhaseAnimationConfig createConfig({ + ValueNotifier? trigger, + }) { + return PhaseAnimationConfig( styles: [ MockStyle(MockSpec(resolvedValue: 0.0).toStyleSpec()), MockStyle(MockSpec(resolvedValue: 1.0).toStyleSpec()), @@ -367,65 +364,138 @@ void main() { ], trigger: trigger, ); + } - driver = PhaseAnimationDriver( + PhaseAnimationDriver createDriver( + PhaseAnimationConfig config, + ) { + return PhaseAnimationDriver( vsync: const TestVSync(), config: config, initialSpec: MockSpec(resolvedValue: 0.0).toStyleSpec(), context: mockContext, ); - }); + } - tearDown(() { - trigger.dispose(); - driver.dispose(); - }); + group('triggered', () { + late PhaseAnimationDriver driver; + late ValueNotifier trigger; - test('initializes with correct config', () { - expect(driver.config.styles.length, 2); - expect(driver.config.curveConfigs.length, 2); - }); + setUp(() { + trigger = ValueNotifier(false); + driver = createDriver(createConfig(trigger: trigger)); + }); - testWidgets('animates when trigger updates', (tester) async { - trigger.value = true; + test('initializes with correct config', () { + expect(driver.config.styles.length, 2); + expect(driver.config.curveConfigs.length, 2); - // Animation should be running - await tester.pump(); - expect(driver.animation.isAnimating, true); + trigger.dispose(); + driver.dispose(); + }); - // Let the animation complete - await tester.pumpAndSettle(); - expect(driver.animation.isAnimating, false); + testWidgets('animates when trigger updates', (tester) async { + trigger.value = true; - // Change the trigger back to false, which should start the animation again - trigger.value = false; - await tester.pump(); - expect(driver.animation.isAnimating, true); + // Animation should be running + await tester.pump(Duration.zero); + expect(driver.animation.isAnimating, true); - await tester.pumpAndSettle(); - expect(driver.animation.isAnimating, false); + // Let the animation complete + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + + await tester.pump(1.ms); + expect(driver.animation.isAnimating, false); + + await tester.pump(100.ms); + expect(driver.animation.isAnimating, false); + + trigger.dispose(); + driver.dispose(); + }); + + testWidgets('should run the animation only once when trigger is updated', ( + tester, + ) async { + trigger.value = true; + + // Animation should be running + await tester.pump(); + expect(driver.animation.isAnimating, true); + + // Let the animation complete + await tester.pumpAndSettle(); + expect(driver.animation.isAnimating, false); + + // Change the trigger back to false, which should start the animation again + trigger.value = false; + await tester.pump(); + expect(driver.animation.isAnimating, true); + + await tester.pumpAndSettle(); + expect(driver.animation.isAnimating, false); + + trigger.dispose(); + driver.dispose(); + }); + + testWidgets('triggers animation status changes', (tester) async { + int startCount = 0; + int completeCount = 0; + + driver.animation.addStatusListener((status) { + if (status == AnimationStatus.forward || + status == AnimationStatus.reverse) { + startCount++; + } + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + completeCount++; + } + }); + + trigger.value = true; + await tester.pumpAndSettle(); + + expect(startCount, 1); + expect(completeCount, 1); + + trigger.dispose(); + driver.dispose(); + }); }); - testWidgets('triggers animation status changes', (tester) async { - int startCount = 0; - int completeCount = 0; + group('looping', () { + testWidgets('should auto run animation when no trigger is provided', ( + tester, + ) async { + final driver = createDriver(createConfig()); - driver.animation.addStatusListener((status) { - if (status == AnimationStatus.forward || - status == AnimationStatus.reverse) { - startCount++; - } - if (status == AnimationStatus.completed || - status == AnimationStatus.dismissed) { - completeCount++; - } + await tester.pump(300.ms); + + expect(driver.animation.isAnimating, true); + driver.dispose(); }); - trigger.value = true; - await tester.pumpAndSettle(); + testWidgets('should auto run repeating animation when trigger is null', ( + tester, + ) async { + final driver = createDriver(createConfig()); - expect(startCount, 1); - expect(completeCount, 1); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + driver.dispose(); + }); }); }); From aa6c9703a27089d77c44c85e1ff585a59a34e933 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62367544+tilucasoli@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:17:28 -0300 Subject: [PATCH 2/3] feat: add loop animation on KeyframeAnimationDriver --- examples/lib/api/animation/keyframe.loop.dart | 24 +------- .../lib/src/animation/animation_config.dart | 2 +- .../src/animation/style_animation_driver.dart | 21 ++++++- .../style/mixins/animation_style_mixin.dart | 2 +- .../style_animation_driver_test.dart | 58 +++++++++++++++++++ 5 files changed, 79 insertions(+), 28 deletions(-) diff --git a/examples/lib/api/animation/keyframe.loop.dart b/examples/lib/api/animation/keyframe.loop.dart index 9b167c2a39..4d0ec71901 100644 --- a/examples/lib/api/animation/keyframe.loop.dart +++ b/examples/lib/api/animation/keyframe.loop.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; @@ -25,26 +23,7 @@ class DemoApp extends StatefulWidget { } class _DemoAppState extends State { - final trigger = ValueNotifier(0); - Timer? _timer; - - @override - void initState() { - super.initState(); - - _timer = Timer.periodic(5.s, (timer) { - trigger.value++; - }); - } - - @override - void dispose() { - _timer?.cancel(); - trigger.dispose(); - super.dispose(); - } - - BoxMix get _boxStyle => BoxStyler() + BoxStyler get _boxStyle => BoxStyler() .color(Colors.blueAccent.shade400) .paddingX(16) .paddingY(8) @@ -60,7 +39,6 @@ class _DemoAppState extends State { tileMode: .clamp, ) .keyframeAnimation( - trigger: trigger, timeline: [ KeyframeTrack('progress', [.ease(1, 2000.ms)], initial: -1), ], diff --git a/packages/mix/lib/src/animation/animation_config.dart b/packages/mix/lib/src/animation/animation_config.dart index 1fd782b89d..0404d1d6d0 100644 --- a/packages/mix/lib/src/animation/animation_config.dart +++ b/packages/mix/lib/src/animation/animation_config.dart @@ -1050,7 +1050,7 @@ class KeyframeAnimationResult { class KeyframeAnimationConfig> extends AnimationConfig with Equatable { - final Listenable trigger; + final Listenable? trigger; final List timeline; final KeyframeStyleBuilder> styleBuilder; final Style initialStyle; diff --git a/packages/mix/lib/src/animation/style_animation_driver.dart b/packages/mix/lib/src/animation/style_animation_driver.dart index f5f6679660..b947753f04 100644 --- a/packages/mix/lib/src/animation/style_animation_driver.dart +++ b/packages/mix/lib/src/animation/style_animation_driver.dart @@ -360,6 +360,9 @@ class KeyframeAnimationDriver> required this.context, }) : _config = config { _setUpAnimation(); + if (_isLooping) { + _startLoopingAnimation(); + } } void _onTriggerChanged() { @@ -376,9 +379,16 @@ class KeyframeAnimationDriver> _KeyframeAnimatable(_sequenceMap, _config, context), ); - _config.trigger.addListener(_onTriggerChanged); + _config.trigger?.addListener(_onTriggerChanged); + } + + void _startLoopingAnimation() { + controller.duration = duration; + controller.repeat(); } + bool get _isLooping => _config.trigger == null; + Duration get duration { if (_config.timeline.isEmpty) return Duration.zero; @@ -390,7 +400,8 @@ class KeyframeAnimationDriver> @override void dispose() { - _config.trigger.removeListener(_onTriggerChanged); + _config.trigger?.removeListener(_onTriggerChanged); + controller.stop(); super.dispose(); } @@ -407,7 +418,11 @@ class KeyframeAnimationDriver> @override void updateDriver(covariant KeyframeAnimationConfig config) { - _config.trigger.removeListener(_onTriggerChanged); + _config.trigger?.removeListener(_onTriggerChanged); + if (_isLooping) { + controller.reset(); + _startLoopingAnimation(); + } _config = config; _setUpAnimation(); } diff --git a/packages/mix/lib/src/style/mixins/animation_style_mixin.dart b/packages/mix/lib/src/style/mixins/animation_style_mixin.dart index f892f97acd..256d9f28db 100644 --- a/packages/mix/lib/src/style/mixins/animation_style_mixin.dart +++ b/packages/mix/lib/src/style/mixins/animation_style_mixin.dart @@ -10,7 +10,7 @@ mixin AnimationStyleMixin, S extends Spec> on Style { /// Creates a keyframe animation. It will animate through the given timeline. T keyframeAnimation({ - required Listenable trigger, + Listenable? trigger, required List timeline, required KeyframeStyleBuilder styleBuilder, }) { diff --git a/packages/mix/test/src/animation/style_animation_driver_test.dart b/packages/mix/test/src/animation/style_animation_driver_test.dart index b58f6410ae..485d14ed23 100644 --- a/packages/mix/test/src/animation/style_animation_driver_test.dart +++ b/packages/mix/test/src/animation/style_animation_driver_test.dart @@ -733,6 +733,64 @@ void main() { ); }); }); + + group('looping', () { + KeyframeAnimationConfig createLoopingConfig({ + Listenable? trigger, + }) { + return KeyframeAnimationConfig( + trigger: trigger, + timeline: [ + KeyframeTrack('opacity', [ + Keyframe.linear(0.5, 100.ms), + Keyframe.ease(1.0, 200.ms), + ], initial: 0.0), + ], + styleBuilder: (result, style) { + return MockStyle(result.get('opacity')); + }, + initialStyle: MockStyle(0.0), + ); + } + + KeyframeAnimationDriver createLoopingDriver( + KeyframeAnimationConfig config, + ) { + return KeyframeAnimationDriver( + vsync: const TestVSync(), + config: config, + initialSpec: MockSpec(resolvedValue: 0.0).toStyleSpec(), + context: mockContext, + ); + } + + testWidgets('should auto run animation when no trigger is provided', ( + tester, + ) async { + final driver = createLoopingDriver(createLoopingConfig()); + + await tester.pump(300.ms); + + expect(driver.animation.isAnimating, true); + driver.dispose(); + }); + + testWidgets('should auto run repeating animation when trigger is null', ( + tester, + ) async { + final driver = createLoopingDriver(createLoopingConfig()); + + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + await tester.pump(300.ms); + expect(driver.animation.isAnimating, true); + driver.dispose(); + }); + }); }); group('NoAnimationDriver', () { From 1c701e104c8b6fb82d5ce6093073f1f1af651ece Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62367544+tilucasoli@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:23:06 -0300 Subject: [PATCH 3/3] Fix StyleAnimationBuilder progress --- examples/lib/api/animation/keyframe.loop.dart | 10 +-- .../src/animation/style_animation_driver.dart | 10 +-- .../mix/test/src/animation/keyframe_test.dart | 2 +- .../style_animation_driver_test.dart | 78 +++++++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/examples/lib/api/animation/keyframe.loop.dart b/examples/lib/api/animation/keyframe.loop.dart index 45f219b049..611693c8c8 100644 --- a/examples/lib/api/animation/keyframe.loop.dart +++ b/examples/lib/api/animation/keyframe.loop.dart @@ -15,14 +15,9 @@ class MyApp extends StatelessWidget { } } -class DemoApp extends StatefulWidget { +class DemoApp extends StatelessWidget { const DemoApp({super.key}); - @override - State createState() => _DemoAppState(); -} - -class _DemoAppState extends State { BoxStyler get _boxStyle => BoxStyler() .color(Colors.blueAccent.shade400) .paddingX(16) @@ -40,7 +35,8 @@ class _DemoAppState extends State { ) .keyframeAnimation( timeline: [ - KeyframeTrack('progress', [.ease(1, 2000.ms)], initial: -1), + // ignore: avoid-inferrable-type-arguments + KeyframeTrack('progress', [.ease(1.0, 2000.ms)], initial: -1), ], styleBuilder: (values, style) => style.foregroundDecoration( .gradient( diff --git a/packages/mix/lib/src/animation/style_animation_driver.dart b/packages/mix/lib/src/animation/style_animation_driver.dart index e57b7b1db9..b323dacdc8 100644 --- a/packages/mix/lib/src/animation/style_animation_driver.dart +++ b/packages/mix/lib/src/animation/style_animation_driver.dart @@ -335,12 +335,12 @@ class PhaseAnimationDriver> extends StyleAnimationDriver { @override void updateDriver(covariant PhaseAnimationConfig config) { - config.trigger?.removeListener(_onTriggerChanged); + this.config.trigger?.removeListener(_onTriggerChanged); + this.config = config; + controller.reset(); if (_isLooping) { - controller.reset(); _startLoopingAnimation(); } - this.config = config; _setUpAnimation(); } } @@ -419,11 +419,11 @@ class KeyframeAnimationDriver> @override void updateDriver(covariant KeyframeAnimationConfig config) { _config.trigger?.removeListener(_onTriggerChanged); + _config = config; + controller.reset(); if (_isLooping) { - controller.reset(); _startLoopingAnimation(); } - _config = config; _setUpAnimation(); } } diff --git a/packages/mix/test/src/animation/keyframe_test.dart b/packages/mix/test/src/animation/keyframe_test.dart index e97a2ac98f..72fffa7759 100644 --- a/packages/mix/test/src/animation/keyframe_test.dart +++ b/packages/mix/test/src/animation/keyframe_test.dart @@ -341,7 +341,7 @@ void main() { final trigger = ValueNotifier(false); final timeline = [ KeyframeTrack('test', const [ - Keyframe.linear(1.0, Duration(milliseconds: 100)), + Keyframe.linear(1, Duration(milliseconds: 100)), ], initial: 0.0), ]; diff --git a/packages/mix/test/src/animation/style_animation_driver_test.dart b/packages/mix/test/src/animation/style_animation_driver_test.dart index 485d14ed23..d1556c9800 100644 --- a/packages/mix/test/src/animation/style_animation_driver_test.dart +++ b/packages/mix/test/src/animation/style_animation_driver_test.dart @@ -496,6 +496,44 @@ void main() { expect(driver.animation.isAnimating, true); driver.dispose(); }); + + testWidgets('switches from looping to triggered mode on updateDriver', ( + tester, + ) async { + final trigger = ValueNotifier(false); + final driver = createDriver(createConfig()); + + await tester.pump(); + expect(driver.animation.isAnimating, true); + + driver.updateDriver(createConfig(trigger: trigger)); + await tester.pump(); + expect(driver.animation.isAnimating, false); + + trigger.value = true; + await tester.pump(); + expect(driver.animation.isAnimating, true); + + trigger.dispose(); + driver.dispose(); + }); + + testWidgets('switches from triggered to looping mode on updateDriver', ( + tester, + ) async { + final trigger = ValueNotifier(false); + final driver = createDriver(createConfig(trigger: trigger)); + + await tester.pump(); + expect(driver.animation.isAnimating, false); + + driver.updateDriver(createConfig()); + await tester.pump(); + expect(driver.animation.isAnimating, true); + + trigger.dispose(); + driver.dispose(); + }); }); }); @@ -790,6 +828,46 @@ void main() { expect(driver.animation.isAnimating, true); driver.dispose(); }); + + testWidgets('switches from looping to triggered mode on updateDriver', ( + tester, + ) async { + final trigger = ValueNotifier(false); + final driver = createLoopingDriver(createLoopingConfig()); + + await tester.pump(); + expect(driver.animation.isAnimating, true); + + driver.updateDriver(createLoopingConfig(trigger: trigger)); + await tester.pump(); + expect(driver.animation.isAnimating, false); + + trigger.value = true; + await tester.pump(); + expect(driver.animation.isAnimating, true); + + trigger.dispose(); + driver.dispose(); + }); + + testWidgets('switches from triggered to looping mode on updateDriver', ( + tester, + ) async { + final trigger = ValueNotifier(false); + final driver = createLoopingDriver( + createLoopingConfig(trigger: trigger), + ); + + await tester.pump(); + expect(driver.animation.isAnimating, false); + + driver.updateDriver(createLoopingConfig()); + await tester.pump(); + expect(driver.animation.isAnimating, true); + + trigger.dispose(); + driver.dispose(); + }); }); });