diff --git a/examples/lib/api/animation/keyframe.loop.dart b/examples/lib/api/animation/keyframe.loop.dart index 31d035342f..611693c8c8 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'; @@ -17,34 +15,10 @@ class MyApp extends StatelessWidget { } } -class DemoApp extends StatefulWidget { +class DemoApp extends StatelessWidget { const DemoApp({super.key}); - @override - State createState() => _DemoAppState(); -} - -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 => .new() + BoxStyler get _boxStyle => BoxStyler() .color(Colors.blueAccent.shade400) .paddingX(16) .paddingY(8) @@ -60,9 +34,9 @@ class _DemoAppState extends State { tileMode: .clamp, ) .keyframeAnimation( - trigger: trigger, 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/animation_config.dart b/packages/mix/lib/src/animation/animation_config.dart index 78f94829a1..c7bafd943a 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({ @@ -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 f5dff7033f..b323dacdc8 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,8 +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) { + _startLoopingAnimation(); + } _setUpAnimation(); } } @@ -345,6 +360,9 @@ class KeyframeAnimationDriver> required this.context, }) : _config = config { _setUpAnimation(); + if (_isLooping) { + _startLoopingAnimation(); + } } void _onTriggerChanged() { @@ -361,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 .zero; @@ -375,7 +400,8 @@ class KeyframeAnimationDriver> @override void dispose() { - _config.trigger.removeListener(_onTriggerChanged); + _config.trigger?.removeListener(_onTriggerChanged); + controller.stop(); super.dispose(); } @@ -392,8 +418,12 @@ class KeyframeAnimationDriver> @override void updateDriver(covariant KeyframeAnimationConfig config) { - _config.trigger.removeListener(_onTriggerChanged); + _config.trigger?.removeListener(_onTriggerChanged); _config = config; + controller.reset(); + if (_isLooping) { + _startLoopingAnimation(); + } _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..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, }) { @@ -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/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 7ca7c11d80..d1556c9800 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,176 @@ 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(); + }); + + 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(); + }); }); }); @@ -663,6 +771,104 @@ 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(); + }); + + 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(); + }); + }); }); group('NoAnimationDriver', () {