feat: add loop animation support for Phase and Keyframe animations#824
feat: add loop animation support for Phase and Keyframe animations#824tilucasoli merged 4 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
To view this pull requests documentation preview, visit the following URL: Documentation is deployed and generated using docs.page. |
There was a problem hiding this comment.
Pull request overview
This PR introduces loop animation support for PhaseAnimationDriver and KeyframeAnimationDriver by making the trigger parameter optional. When no trigger is provided, animations loop continuously using controller.repeat(), enabling use cases like spinning loaders and pulsing effects. When a trigger is provided, animations behave as before - only animating when the trigger value changes.
Key Changes:
- Made
triggerparameter nullable in animation configs and mixins - Added looping mode detection via
_isLoopinggetter - Implemented
_startLoopingAnimation()to initiate repeating animations - Updated resource cleanup in
dispose()withcontroller.stop()
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/mix/lib/src/animation/animation_config.dart | Made trigger field nullable in PhaseAnimationConfig and KeyframeAnimationConfig |
| packages/mix/lib/src/style/mixins/animation_style_mixin.dart | Made trigger parameter optional in keyframeAnimation() and phaseAnimation() methods |
| packages/mix/lib/src/animation/style_animation_driver.dart | Implemented looping logic with _isLooping getter, _startLoopingAnimation() method, and updated disposal/config update handling |
| packages/mix/test/src/animation/style_animation_driver_test.dart | Added test groups for looping behavior in both driver types, refactored triggered animation tests |
| examples/lib/api/animation/keyframe.loop.dart | Removed manual timer-based trigger mechanism to demonstrate automatic looping |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| void updateDriver(covariant PhaseAnimationConfig config) { | ||
| config.trigger.removeListener(_onTriggerChanged); | ||
| config.trigger?.removeListener(_onTriggerChanged); | ||
| if (_isLooping) { | ||
| controller.reset(); | ||
| _startLoopingAnimation(); | ||
| } | ||
| this.config = config; | ||
| _setUpAnimation(); | ||
| } |
There was a problem hiding this comment.
The _isLooping check uses the old config's trigger value instead of the new config's trigger value. The check on line 339 evaluates _isLooping which accesses this.config.trigger, but this.config is not updated until line 343. This means:
- If updating from a looping config (no trigger) to a triggered config, it will incorrectly restart looping
- If updating from a triggered config to a looping config, it won't start looping
The old trigger listener is removed using the new config parameter (line 338), but the looping check uses the old config. Either check _isLooping after updating this.config, or check the new config parameter directly with config.trigger == null.
| _config.trigger?.removeListener(_onTriggerChanged); | ||
| if (_isLooping) { | ||
| controller.reset(); | ||
| _startLoopingAnimation(); | ||
| } | ||
| _config = config; |
There was a problem hiding this comment.
The _isLooping check uses the old config's trigger value instead of the new config's trigger value. The check on line 422 evaluates _isLooping which accesses _config.trigger, but _config is not updated until line 426. This means:
- If updating from a looping config (no trigger) to a triggered config, it will incorrectly restart looping
- If updating from a triggered config to a looping config, it won't start looping
The old trigger listener is removed using the new config parameter (line 421), but the looping check uses the old config. Either check _isLooping after updating _config, or check the new config parameter directly with config.trigger == null.
| _config.trigger?.removeListener(_onTriggerChanged); | |
| if (_isLooping) { | |
| controller.reset(); | |
| _startLoopingAnimation(); | |
| } | |
| _config = config; | |
| final oldTrigger = _config.trigger; | |
| oldTrigger?.removeListener(_onTriggerChanged); | |
| _config = config; | |
| if (_isLooping) { | |
| controller.reset(); | |
| _startLoopingAnimation(); | |
| } |
| @override | ||
| void updateDriver(covariant PhaseAnimationConfig config) { | ||
| config.trigger.removeListener(_onTriggerChanged); | ||
| config.trigger?.removeListener(_onTriggerChanged); |
There was a problem hiding this comment.
The listener is being removed from the wrong trigger. Line 338 attempts to remove the listener from the new config's trigger (config.trigger), but the listener was added to the old config's trigger (this.config.trigger). This should be this.config.trigger?.removeListener(_onTriggerChanged) to properly clean up the old listener before updating to the new config.
Currently, if the old and new configs have different trigger instances, the old trigger will retain a dangling listener reference, potentially causing memory leaks and unexpected behavior.
| config.trigger?.removeListener(_onTriggerChanged); | |
| this.config.trigger?.removeListener(_onTriggerChanged); |
| @override | ||
| void updateDriver(covariant KeyframeAnimationConfig<S> config) { | ||
| _config.trigger.removeListener(_onTriggerChanged); | ||
| _config.trigger?.removeListener(_onTriggerChanged); |
There was a problem hiding this comment.
The listener is being removed from the wrong trigger. Line 421 attempts to remove the listener from the new config's trigger (config.trigger), but the listener was added to the old config's trigger (this._config.trigger). This should be this._config.trigger?.removeListener(_onTriggerChanged) to properly clean up the old listener before updating to the new config.
Currently, if the old and new configs have different trigger instances, the old trigger will retain a dangling listener reference, potentially causing memory leaks and unexpected behavior.
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing test coverage for switching between looping and triggered modes via updateDriver. The tests only verify initial looping behavior, but don't test the critical scenario where a config update changes between looping mode (trigger: null) and triggered mode (trigger: notNull), or vice versa. This is especially important given the bugs in the updateDriver implementation where _isLooping is checked before updating the config.
| group('looping', () { | ||
| KeyframeAnimationConfig<MockSpec> createLoopingConfig({ | ||
| Listenable? trigger, | ||
| }) { | ||
| return KeyframeAnimationConfig<MockSpec>( | ||
| trigger: trigger, | ||
| timeline: [ | ||
| KeyframeTrack<double>('opacity', [ | ||
| Keyframe.linear(0.5, 100.ms), | ||
| Keyframe.ease(1.0, 200.ms), | ||
| ], initial: 0.0), | ||
| ], | ||
| styleBuilder: (result, style) { | ||
| return MockStyle(result.get<double>('opacity')); | ||
| }, | ||
| initialStyle: MockStyle(0.0), | ||
| ); | ||
| } | ||
|
|
||
| KeyframeAnimationDriver<MockSpec> createLoopingDriver( | ||
| KeyframeAnimationConfig<MockSpec> config, | ||
| ) { | ||
| return KeyframeAnimationDriver<MockSpec>( | ||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing test coverage for switching between looping and triggered modes via updateDriver. The tests only verify initial looping behavior, but don't test the critical scenario where a config update changes between looping mode (trigger: null) and triggered mode (trigger: notNull), or vice versa. This is especially important given the bugs in the updateDriver implementation where _isLooping is checked before updating the config.
Related issue
Closes #788
Description
This PR adds support for infinite/looping animations in
PhaseAnimationDriverandKeyframeAnimationDriver. When no trigger is provided, the animation will loop continuously, enabling use cases like spinning loaders or pulsing effects defined entirely through styles.The approach follows SwiftUI's animation model: when no trigger is provided, the animation loops continuously. If a trigger is provided, it only animates when the trigger value changes.
Examples
Loop Animation (no trigger)
Triggered Animation
Changes
triggerparameter optional inPhaseAnimationConfigandKeyframeAnimationConfig_isLoopinggetter to detect loop mode (whentrigger == null)_startLoopingAnimation()method that callscontroller.repeat()dispose()to properly stop the controllerupdateDriver()to reset and restart looping animations when config changeskeyframe.loop.dartto demonstrate the featureReview Checklist
Additional Information (optional)
The loop mode is activated by omitting the
triggerparameter. This is a non-breaking change since existing code that provides a trigger will continue to work as before.