Skip to content
Open
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
61 changes: 60 additions & 1 deletion packages/mix/lib/src/animation/animation_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,40 @@ class PhaseAnimationConfig<T extends Spec<T>, U extends Style<T>>

bool get isLooping => trigger == null;

/// Asserts the configuration invariants. Kept off the `const` constructor so
/// the public const construction path is preserved; [PhaseAnimationDriver]
/// calls this before the controller runs, so an invalid config still fails
/// before controller execution.
void validate() {
assert(
styles.length == curveConfigs.length,
'PhaseAnimationConfig requires one CurveAnimationConfig per phase '
'(got ${styles.length} styles and ${curveConfigs.length} configs).',
);
assert(
styles.isNotEmpty,
'PhaseAnimationConfig requires at least one phase.',
);
assert(
// Negative durations/delays produce out-of-order or backward tween
// weights and break TweenSequence timing.
curveConfigs.every((c) => c.duration >= .zero && c.delay >= .zero),
'PhaseAnimationConfig durations and delays must be non-negative.',
);
assert(
// A looping (untriggered) timeline is driven by `controller.repeat()`,
// which requires a positive period.
trigger != null ||
curveConfigs.fold<Duration>(
.zero,
(total, c) => total + c.totalDuration,
) >
.zero,
'A looping PhaseAnimationConfig (trigger == null) must have a positive '
'total duration.',
);
}

@override
List<Object?> get props => [styles, trigger, curveConfigs];
}
Expand Down Expand Up @@ -1042,7 +1076,11 @@ class KeyframeTrack<T> with Equatable {
this.segments, {
required this.initial,
TweenBuilder<T?>? tweenBuilder,
}) : tweenBuilder = tweenBuilder ?? Tween<T>.new;
}) : tweenBuilder = tweenBuilder ?? Tween<T>.new,
assert(
segments.every((s) => s.duration >= .zero),
'KeyframeTrack "$id" segment durations must be non-negative.',
);

Duration get totalDuration {
return segments.fold(
Expand Down Expand Up @@ -1131,6 +1169,27 @@ class KeyframeAnimationConfig<S extends Spec<S>> extends AnimationConfig

bool get isLooping => trigger == null;

/// Asserts the configuration invariants. Kept off the `const` constructor so
/// the public const construction path is preserved; [KeyframeAnimationDriver]
/// calls this before the controller runs, so an invalid config still fails
/// before controller execution. Per-track segment durations are validated by
/// [KeyframeTrack] itself.
void validate() {
assert(
// Track ids are the lookup keys for `KeyframeAnimationResult.get`; a
// duplicate silently shadows a track.
timeline.map((t) => t.id).toSet().length == timeline.length,
'KeyframeAnimationConfig requires unique track ids; found a duplicate.',
);
assert(
// A looping (untriggered) timeline is driven by `controller.repeat()`,
// which requires a positive period.
trigger != null || timeline.any((t) => t.totalDuration > .zero),
'A looping KeyframeAnimationConfig (trigger == null) must have at least '
'one track with a positive duration.',
);
}

@override
List<Object?> get props => [trigger, timeline, initialStyle];
}
18 changes: 17 additions & 1 deletion packages/mix/lib/src/animation/style_animation_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ class _StyleAnimationBuilderState<S extends Spec<S>>
};
}

/// Chooses the config that drives an animate-out transition when the new spec
/// removes its animation (`config == null`).
///
/// Only implicit drivers (curve, spring) retarget toward the new spec through
/// [StyleAnimationDriver.didUpdateSpec], so only those may be reused. Phase and
/// keyframe drivers replay their own sequence and never retarget; reusing an
/// outgoing phase/keyframe config would keep the old animation running instead
/// of showing the new target, so return `null` to let a [NoAnimationDriver]
/// expose the target immediately on the same update.
AnimationConfig? _outgoingConfigFor(AnimationConfig? oldConfig) {
return switch (oldConfig) {
CurveAnimationConfig() || SpringAnimationConfig() => oldConfig,
PhaseAnimationConfig() || KeyframeAnimationConfig() || null => null,
};
}

@override
void dispose() {
animationDriver.dispose();
Expand All @@ -96,7 +112,7 @@ class _StyleAnimationBuilderState<S extends Spec<S>>
} else {
animationDriver.dispose();
animationDriver = _createAnimationDriver(
config: config ?? oldConfig,
config: config ?? _outgoingConfigFor(oldConfig),
initialSpec: oldWidget.spec,
);
}
Expand Down
41 changes: 32 additions & 9 deletions packages/mix/lib/src/animation/style_animation_driver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ class CurveAnimationDriver<S extends Spec<S>>
TweenSequence<StyleSpec<S>?> _createTweenSequence() => .new([
if (config.delay > .zero)
TweenSequenceItem(
tween: ConstantTween(_initialSpec),
// Hold the currently displayed value during the delay. `_animateTo`
// sets `_tween.begin` to the current interpolated spec just before
// this runs, so an interrupted transition holds where it is visually
// instead of snapping back to the driver's original `_initialSpec`.
tween: ConstantTween(_tween.begin),
weight: config.delay.inMilliseconds.toDouble(),
),
TweenSequenceItem(
Expand Down Expand Up @@ -238,7 +242,15 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
required super.initialSpec,
required this.context,
}) {
// Validate before any controller work so invalid configs fail early. Kept
// here (not in the const constructor) to preserve const construction.
config.validate();
_setUpAnimation();
// Register the completion listener on the controller once, for the driver's
// whole lifetime. `_setUpAnimation` re-drives `_animation` on every
// `updateDriver`, so registering the listener there (as before) leaked a new
// listener per update and fired `onEnd` once per accumulated listener.
controller.addStatusListener(_onStatusChanged);
if (config.isLooping) {
_startLoopingAnimation();
}
Expand All @@ -255,14 +267,15 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
_animation = controller.drive(_PhasedSpecTween(_tweenSequence));

config.trigger?.addListener(_onTriggerChanged);
}

// Add status listener for onEnd callback
if (config.curveConfigs.last.onEnd != null) {
_animation.addStatusListener((status) {
if (status == .completed) {
config.curveConfigs.last.onEnd!();
}
});
// Phase completion is owned by `PhaseAnimationConfig.onEnd`. The per-phase
// `CurveAnimationConfig.onEnd` values are not a completion contract; they only
// carry timing/curve data for each transition. Reads the current `config` so
// an `updateDriver` that swaps the callback is honored.
void _onStatusChanged(AnimationStatus status) {
if (status == .completed) {
config.onEnd?.call();
}
}

Expand All @@ -279,7 +292,11 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
final currentIndex = i % specs.length;
final nextIndex = (i + 1) % specs.length;

if (configs[currentIndex].delay > .zero) {
// The transition into `nextIndex` is owned by `configs[nextIndex]`: its
// delay, duration, and curve all describe arriving at `specs[nextIndex]`.
// Use `nextIndex` for both the delay check and its weight so placement and
// duration always agree (previously the check read `currentIndex.delay`).
if (configs[nextIndex].delay > .zero) {
items.add(
TweenSequenceItem(
tween: ConstantTween(specs[currentIndex]),
Expand Down Expand Up @@ -320,6 +337,7 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {
@override
void dispose() {
config.trigger?.removeListener(_onTriggerChanged);
controller.removeStatusListener(_onStatusChanged);
controller.stop();
super.dispose();
}
Expand All @@ -333,6 +351,7 @@ class PhaseAnimationDriver<S extends Spec<S>> extends StyleAnimationDriver<S> {

@override
void updateDriver(covariant PhaseAnimationConfig config) {
config.validate();
this.config.trigger?.removeListener(_onTriggerChanged);
if (config != this.config) {
controller.reset();
Expand Down Expand Up @@ -361,6 +380,9 @@ class KeyframeAnimationDriver<S extends Spec<S>>
required super.initialSpec,
required this.context,
}) : _config = config {
// Validate before any controller work so invalid configs fail early. Kept
// here (not in the const constructor) to preserve const construction.
config.validate();
_setUpAnimation();
if (config.isLooping) {
_startLoopingAnimation();
Expand Down Expand Up @@ -418,6 +440,7 @@ class KeyframeAnimationDriver<S extends Spec<S>>

@override
void updateDriver(covariant KeyframeAnimationConfig<S> config) {
config.validate();
_config.trigger?.removeListener(_onTriggerChanged);
if (_config != config) {
controller.reset();
Expand Down
6 changes: 6 additions & 0 deletions packages/mix/lib/src/style/mixins/animation_style_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@ mixin AnimationStyleMixin<T extends Style<S>, S extends Spec<S>> on Style<S> {
}

/// Creates a phase animation. It will animate through the given phases.
///
/// [onEnd] fires once each time a triggered sequence completes; it is the
/// single phase-completion contract. The per-phase [CurveAnimationConfig]s
/// returned by [configBuilder] only carry timing/curve data.
T phaseAnimation<P>({
Listenable? trigger,
required List<P> phases,
required T Function(P phase, T style) styleBuilder,
required CurveAnimationConfig Function(P phase) configBuilder,
VoidCallback? onEnd,
}) {
final styles = <T>[];
final configs = <CurveAnimationConfig>[];
Expand All @@ -44,6 +49,7 @@ mixin AnimationStyleMixin<T extends Style<S>, S extends Spec<S>> on Style<S> {
styles: styles,
curveConfigs: configs,
trigger: trigger,
onEnd: onEnd,
),
);
}
Expand Down
149 changes: 149 additions & 0 deletions packages/mix/test/src/animation/animation_config_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -655,4 +655,153 @@ void main() {
trigger2.dispose();
});
});

group('input validation', () {
group('PhaseAnimationConfig', () {
test('empty phase list throws', () {
expect(
() => PhaseAnimationConfig<MockSpec<double>, MockStyle<double>>(
styles: const [],
curveConfigs: const [],
trigger: null,
).validate(),
throwsA(isA<AssertionError>()),
);
});

test('mismatched styles and curveConfigs throws', () {
expect(
() => PhaseAnimationConfig<MockSpec<double>, MockStyle<double>>(
styles: [MockStyle(0.0), MockStyle(1.0)],
curveConfigs: const [
CurveAnimationConfig(
duration: Duration(milliseconds: 100),
curve: Curves.linear,
),
],
trigger: null,
).validate(),
throwsA(isA<AssertionError>()),
);
});

test('negative duration throws', () {
expect(
() => PhaseAnimationConfig<MockSpec<double>, MockStyle<double>>(
styles: [MockStyle(0.0)],
curveConfigs: const [
CurveAnimationConfig(
duration: Duration(milliseconds: -100),
curve: Curves.linear,
),
],
trigger: null,
).validate(),
throwsA(isA<AssertionError>()),
);
});

test('looping config with zero total duration throws', () {
expect(
() => PhaseAnimationConfig<MockSpec<double>, MockStyle<double>>(
styles: [MockStyle(0.0)],
curveConfigs: const [
CurveAnimationConfig(
duration: Duration.zero,
curve: Curves.linear,
),
],
trigger: null,
).validate(),
throwsA(isA<AssertionError>()),
);
});
});

group('KeyframeTrack', () {
test('negative segment duration throws', () {
expect(
() => KeyframeTrack<double>('t', const [
Keyframe.linear(1.0, Duration(milliseconds: -50)),
], initial: 0.0),
throwsA(isA<AssertionError>()),
);
});
});

group('KeyframeAnimationConfig', () {
test('duplicate track ids throws', () {
final trigger = ValueNotifier(false);
addTearDown(trigger.dispose);

expect(
() => KeyframeAnimationConfig<MockSpec<double>>(
trigger: trigger,
timeline: [
KeyframeTrack<double>('dup', const [
Keyframe.linear(1.0, Duration(milliseconds: 100)),
], initial: 0.0),
KeyframeTrack<double>('dup', const [
Keyframe.linear(1.0, Duration(milliseconds: 100)),
], initial: 0.0),
],
styleBuilder: (result, style) => style,
initialStyle: MockStyle(0.0),
).validate(),
throwsA(isA<AssertionError>()),
);
});

test('looping timeline with no positive duration throws', () {
expect(
() => KeyframeAnimationConfig<MockSpec<double>>(
trigger: null,
timeline: [
KeyframeTrack<double>('t', const [
Keyframe.linear(1.0, Duration.zero),
], initial: 0.0),
],
styleBuilder: (result, style) => style,
initialStyle: MockStyle(0.0),
).validate(),
throwsA(isA<AssertionError>()),
);
});

test('triggered empty timeline is allowed', () {
final trigger = ValueNotifier(false);
addTearDown(trigger.dispose);

expect(
() => KeyframeAnimationConfig<MockSpec<double>>(
trigger: trigger,
timeline: const [],
styleBuilder: (result, style) => style,
initialStyle: MockStyle(0.0),
).validate(),
returnsNormally,
);
});
});
});

group('phaseAnimation onEnd contract', () {
test('fluent phaseAnimation forwards onEnd to PhaseAnimationConfig', () {
var called = false;
final style = BoxStyler().phaseAnimation(
phases: const [0, 1],
styleBuilder: (phase, s) => s,
configBuilder: (phase) => const CurveAnimationConfig(
duration: Duration(milliseconds: 100),
curve: Curves.linear,
),
onEnd: () => called = true,
);

final config = style.$animation! as PhaseAnimationConfig;
expect(config.onEnd, isNotNull);
config.onEnd!();
expect(called, isTrue);
});
});
}
Loading
Loading