Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 4 additions & 30 deletions examples/lib/api/animation/keyframe.loop.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:mix/mix.dart';
Expand All @@ -17,34 +15,10 @@ class MyApp extends StatelessWidget {
}
}

class DemoApp extends StatefulWidget {
class DemoApp extends StatelessWidget {
const DemoApp({super.key});

@override
State<DemoApp> createState() => _DemoAppState();
}

class _DemoAppState extends State<DemoApp> {
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)
Expand All @@ -60,9 +34,9 @@ class _DemoAppState extends State<DemoApp> {
tileMode: .clamp,
)
.keyframeAnimation(
trigger: trigger,
timeline: [
KeyframeTrack<double>('progress', [.ease(1, 2000.ms)], initial: -1),
// ignore: avoid-inferrable-type-arguments
KeyframeTrack<double>('progress', [.ease(1.0, 2000.ms)], initial: -1),
],
styleBuilder: (values, style) => style.foregroundDecoration(
.gradient(
Expand Down
4 changes: 2 additions & 2 deletions packages/mix/lib/src/animation/animation_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ class PhaseAnimationConfig<T extends Spec<T>, U extends Style<T>>
with Equatable {
final List<U> styles;
final List<CurveAnimationConfig> curveConfigs;
final Listenable trigger;
final Listenable? trigger;
final VoidCallback? onEnd;

const PhaseAnimationConfig({
Expand Down Expand Up @@ -1050,7 +1050,7 @@ class KeyframeAnimationResult {

class KeyframeAnimationConfig<S extends Spec<S>> extends AnimationConfig
with Equatable {
final Listenable trigger;
final Listenable? trigger;
final List<KeyframeTrack> timeline;
final KeyframeStyleBuilder<S, Style<S>> styleBuilder;
final Style<S> initialStyle;
Expand Down
42 changes: 36 additions & 6 deletions packages/mix/lib/src/animation/style_animation_driver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
required this.context,
}) {
_setUpAnimation();
if (_isLooping) {
_startLoopingAnimation();
}
}

void _setUpAnimation() {
Expand All @@ -251,7 +254,7 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
// 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) {
Expand Down Expand Up @@ -301,6 +304,13 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
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(
Expand All @@ -311,7 +321,8 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {

@override
void dispose() {
config.trigger.removeListener(_onTriggerChanged);
config.trigger?.removeListener(_onTriggerChanged);
controller.stop();
super.dispose();
}

Expand All @@ -324,8 +335,12 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {

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

Copilot AI Jan 6, 2026

Copy link

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.
}
Expand All @@ -345,6 +360,9 @@ class KeyframeAnimationDriver<S extends Spec<S>>
required this.context,
}) : _config = config {
_setUpAnimation();
if (_isLooping) {
_startLoopingAnimation();
}
}

void _onTriggerChanged() {
Expand All @@ -361,9 +379,16 @@ class KeyframeAnimationDriver<S extends Spec<S>>
_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;

Expand All @@ -375,7 +400,8 @@ class KeyframeAnimationDriver<S extends Spec<S>>

@override
void dispose() {
_config.trigger.removeListener(_onTriggerChanged);
_config.trigger?.removeListener(_onTriggerChanged);
controller.stop();
super.dispose();
}

Expand All @@ -392,8 +418,12 @@ class KeyframeAnimationDriver<S extends Spec<S>>

@override
void updateDriver(covariant KeyframeAnimationConfig<S> config) {
_config.trigger.removeListener(_onTriggerChanged);
_config.trigger?.removeListener(_onTriggerChanged);

Copilot AI Jan 6, 2026

Copy link

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.
_config = config;
controller.reset();
if (_isLooping) {
_startLoopingAnimation();
}
_setUpAnimation();
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/mix/lib/src/style/mixins/animation_style_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ mixin AnimationStyleMixin<T extends Style<S>, S extends Spec<S>> on Style<S> {

/// Creates a keyframe animation. It will animate through the given timeline.
T keyframeAnimation({
required Listenable trigger,
Listenable? trigger,
required List<KeyframeTrack> timeline,
required KeyframeStyleBuilder<S, T> styleBuilder,
}) {
Expand All @@ -26,7 +26,7 @@ mixin AnimationStyleMixin<T extends Style<S>, S extends Spec<S>> on Style<S> {

/// Creates a phase animation. It will animate through the given phases.
T phaseAnimation<P>({
required Listenable trigger,
Listenable? trigger,
required List<P> phases,
required T Function(P phase, T style) styleBuilder,
required CurveAnimationConfig Function(P phase) configBuilder,
Expand Down
2 changes: 1 addition & 1 deletion packages/mix/test/src/animation/keyframe_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ void main() {
final trigger = ValueNotifier(false);
final timeline = [
KeyframeTrack<double>('test', const [
Keyframe.linear(1.0, Duration(milliseconds: 100)),
Keyframe.linear(1, Duration(milliseconds: 100)),
], initial: 0.0),
];

Expand Down
Loading
Loading