Skip to content

feat: add loop animation support for Phase and Keyframe animations#824

Merged
tilucasoli merged 4 commits intomainfrom
feat/loop-animation
Feb 11, 2026
Merged

feat: add loop animation support for Phase and Keyframe animations#824
tilucasoli merged 4 commits intomainfrom
feat/loop-animation

Conversation

@tilucasoli
Copy link
Copy Markdown
Collaborator

@tilucasoli tilucasoli commented Jan 6, 2026

Related issue

Closes #788

Description

This PR adds support for infinite/looping animations in PhaseAnimationDriver and KeyframeAnimationDriver. 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)

BoxStyler()
    .color(Colors.deepPurple)
    .phaseAnimation(
      phases: [1.0, 1.2],
      styleBuilder: (phase, style) => style.scale(phase),
      configBuilder: (phase) => .springWithDampingRatio(800.ms, ratio: 0.3),
    );

Triggered Animation

final trigger = ValueNotifier(0);

BoxStyler()
    .color(Colors.deepPurple)
    .phaseAnimation(
      trigger: trigger, // Only animates when trigger changes
      phases: [1.0, 1.2],
      styleBuilder: (phase, style) => style.scale(phase),
      configBuilder: (phase) => .springWithDampingRatio(800.ms, ratio: 0.3),
    );

Changes

  • Made trigger parameter optional in PhaseAnimationConfig and KeyframeAnimationConfig
  • Added _isLooping getter to detect loop mode (when trigger == null)
  • Added _startLoopingAnimation() method that calls controller.repeat()
  • Updated dispose() to properly stop the controller
  • Updated updateDriver() to reset and restart looping animations when config changes
  • Updated example keyframe.loop.dart to demonstrate the feature
  • Added comprehensive tests for looping behavior in both drivers

Review Checklist

  • Testing: Have you tested your changes, including unit tests and integration tests for affected code?
  • Breaking Changes: Does this change introduce breaking changes affecting existing code or users?
  • Documentation Updates: Are all relevant documentation files (e.g. README, API docs) updated to reflect the changes in this PR?
  • Website Updates: Is the website containing the updates you make on documentation?

Additional Information (optional)

The loop mode is activated by omitting the trigger parameter. This is a non-breaking change since existing code that provides a trigger will continue to work as before.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jan 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mix-docs Ready Ready Preview, Comment Feb 11, 2026 2:24am

@docs-page
Copy link
Copy Markdown

docs-page Bot commented Jan 6, 2026

To view this pull requests documentation preview, visit the following URL:

docs.page/btwld/mix~824

Documentation is deployed and generated using docs.page.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 trigger parameter nullable in animation configs and mixins
  • Added looping mode detection via _isLooping getter
  • Implemented _startLoopingAnimation() to initiate repeating animations
  • Updated resource cleanup in dispose() with controller.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.

Comment on lines 337 to 345
void updateDriver(covariant PhaseAnimationConfig config) {
config.trigger.removeListener(_onTriggerChanged);
config.trigger?.removeListener(_onTriggerChanged);
if (_isLooping) {
controller.reset();
_startLoopingAnimation();
}
this.config = config;
_setUpAnimation();
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. If updating from a looping config (no trigger) to a triggered config, it will incorrectly restart looping
  2. 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.

Copilot uses AI. Check for mistakes.
Comment on lines 421 to 426
_config.trigger?.removeListener(_onTriggerChanged);
if (_isLooping) {
controller.reset();
_startLoopingAnimation();
}
_config = config;
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. If updating from a looping config (no trigger) to a triggered config, it will incorrectly restart looping
  2. 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.

Suggested change
_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();
}

Copilot uses AI. Check for mistakes.
@override
void updateDriver(covariant PhaseAnimationConfig config) {
config.trigger.removeListener(_onTriggerChanged);
config.trigger?.removeListener(_onTriggerChanged);
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
config.trigger?.removeListener(_onTriggerChanged);
this.config.trigger?.removeListener(_onTriggerChanged);

Copilot uses AI. Check for mistakes.
@override
void updateDriver(covariant KeyframeAnimationConfig<S> config) {
_config.trigger.removeListener(_onTriggerChanged);
_config.trigger?.removeListener(_onTriggerChanged);
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +472 to 499
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();
});
});
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +737 to +793
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();
});
});
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@tilucasoli tilucasoli merged commit bd31f42 into main Feb 11, 2026
7 checks passed
@tilucasoli tilucasoli deleted the feat/loop-animation branch February 11, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support infinit animations in a style

2 participants