Merge e0ed7c3ef30321d9adad436589cb39670439f4fd into 06df71c51446e96939c6a615b7c34ce9123806ba

This commit is contained in:
zionjo89757 2026-02-19 14:16:17 -03:00 committed by GitHub
commit 7bc7bb23e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 344 additions and 3 deletions

View File

@ -242,6 +242,11 @@ class AnimationController extends Animation<double>
/// * `vsync` is the required [TickerProvider] for the current context. It can
/// be changed by calling [resync]. See [TickerProvider] for advice on
/// obtaining a ticker provider.
///
/// * [pauseWhenNoListeners] controls whether the animation controller should
/// pause frame scheduling when there are no listeners. When set to true,
/// the controller will not request frames (saving CPU/GPU resources) until
/// a listener is added. Defaults to false for backward compatibility.
AnimationController({
double? value,
this.duration,
@ -250,6 +255,7 @@ class AnimationController extends Animation<double>
this.lowerBound = 0.0,
this.upperBound = 1.0,
this.animationBehavior = AnimationBehavior.normal,
this.pauseWhenNoListeners = false,
required TickerProvider vsync,
}) : assert(upperBound >= lowerBound),
_direction = _AnimationDirection.forward {
@ -272,6 +278,9 @@ class AnimationController extends Animation<double>
/// be changed by calling [resync]. See [TickerProvider] for advice on
/// obtaining a ticker provider.
///
/// * [pauseWhenNoListeners] controls whether the animation controller should
/// pause frame scheduling when there are no listeners. Defaults to false.
///
/// This constructor is most useful for animations that will be driven using a
/// physics simulation, especially when the physics simulation has no
/// pre-determined bounds.
@ -282,6 +291,7 @@ class AnimationController extends Animation<double>
this.debugLabel,
required TickerProvider vsync,
this.animationBehavior = AnimationBehavior.preserve,
this.pauseWhenNoListeners = false,
}) : lowerBound = double.negativeInfinity,
upperBound = double.infinity,
_direction = _AnimationDirection.forward {
@ -308,6 +318,24 @@ class AnimationController extends Animation<double>
/// [AnimationController.unbounded] constructor.
final AnimationBehavior animationBehavior;
/// Whether the animation controller should pause frame scheduling when there
/// are no listeners.
///
/// When a listener is added, frame scheduling resumes automatically.
///
/// Defaults to false for backward compatibility. Set to true to enable this
/// optimization.
///
/// Example usage:
/// ```dart
/// final controller = AnimationController(
/// vsync: this,
/// duration: const Duration(seconds: 1),
/// pauseWhenNoListeners: true, // Enable optimization
/// );
/// ```
final bool pauseWhenNoListeners;
/// Returns an [Animation<double>] for this animation controller, so that a
/// pointer to this object can be passed around without allowing users of that
/// pointer to mutate the [AnimationController] state.
@ -327,6 +355,44 @@ class AnimationController extends Animation<double>
Ticker? _ticker;
/// The number of registered listeners (both value and status listeners).
///
/// This is used to pause the ticker when there are no listeners, avoiding
/// unnecessary frame scheduling. Only used when [pauseWhenNoListeners] is true.
int _listenerCount = 0;
/// Called when a listener is registered with [addListener] or [addStatusListener].
///
/// When [pauseWhenNoListeners] is true, this resumes the ticker when the
/// first listener is added while the animation is running.
@override
void didRegisterListener() {
if (!pauseWhenNoListeners) {
return;
}
_listenerCount += 1;
if (_listenerCount > 0 && isAnimating) {
// Resume ticker when we get our first listener while animating.
_ticker?.resume();
}
}
/// Called when a listener is unregistered with [removeListener] or [removeStatusListener].
///
/// When [pauseWhenNoListeners] is true, this pauses the ticker when the
/// last listener is removed while the animation is running.
@override
void didUnregisterListener() {
if (!pauseWhenNoListeners) {
return;
}
_listenerCount -= 1;
if (_listenerCount == 0 && isAnimating) {
// Pause ticker when we lose our last listener while animating.
_ticker?.pause();
}
}
/// Recreates the [Ticker] with the new [TickerProvider].
void resync(TickerProvider vsync) {
final Ticker oldTicker = _ticker!;
@ -864,6 +930,18 @@ class AnimationController extends Animation<double>
_lastElapsedDuration = Duration.zero;
_value = clampDouble(simulation.x(0.0), lowerBound, upperBound);
final TickerFuture result = _ticker!.start();
if (pauseWhenNoListeners) {
if (_listenerCount == 0) {
if (!_ticker!.isPaused) {
_ticker!.pause();
}
} else {
if (_ticker!.isPaused) {
_ticker!.resume();
}
}
}
_status = (_direction == _AnimationDirection.forward)
? AnimationStatus.forward
: AnimationStatus.reverse;

View File

@ -131,8 +131,8 @@ class Ticker {
/// Whether this [Ticker] has scheduled a call to call its callback
/// on the next frame.
///
/// A ticker that is [muted] can be active (see [isActive]) yet not be
/// ticking. In that case, the ticker will not call its callback, and
/// A ticker that is [muted] or [isPaused] can be active (see [isActive]) yet
/// not be ticking. In that case, the ticker will not call its callback, and
/// [isTicking] will be false, but time will still be progressing.
///
/// This will return false if the [SchedulerBinding.lifecycleState] is one
@ -145,6 +145,9 @@ class Ticker {
if (muted) {
return false;
}
if (isPaused) {
return false;
}
if (SchedulerBinding.instance.framesEnabled) {
return true;
}
@ -256,6 +259,57 @@ class Ticker {
@protected
bool get scheduled => _animationId != null;
/// Whether this ticker is currently paused by its consumer.
///
/// A paused ticker will not schedule frames, but time continues to elapse.
///
/// Unlike [muted], which is controlled by the [TickerProvider] (e.g., based
/// on [TickerMode]), this property is controlled by the ticker's consumer
/// (e.g., [AnimationController]) to temporarily suspend frame scheduling.
///
/// See also:
///
/// * [pause], which sets this to true.
/// * [resume], which sets this to false.
bool _isPaused = false;
/// Whether this ticker is currently paused by its consumer.
bool get isPaused => _isPaused;
/// Pauses frame scheduling for this ticker.
///
/// When paused, the ticker remains active and time continues to elapse,
/// but no frames are scheduled and the callback is not called.
///
/// This is typically used by [AnimationController] to suspend frame
/// scheduling when there are no listeners.
///
/// See also:
///
/// * [resume], which resumes frame scheduling.
void pause() {
if (!_isPaused) {
_isPaused = true;
unscheduleTick();
}
}
/// Resumes frame scheduling for this ticker after a [pause].
///
/// If [shouldScheduleTick] is true, the ticker will schedule a frame.
///
/// See also:
///
/// * [pause], which pauses frame scheduling.
void resume() {
if (_isPaused) {
_isPaused = false;
if (shouldScheduleTick) {
scheduleTick();
}
}
}
/// Whether a tick should be scheduled.
///
/// If this is true, then calling [scheduleTick] should succeed.
@ -265,8 +319,9 @@ class Ticker {
/// * A tick has already been scheduled for the coming frame.
/// * The ticker is not active ([start] has not been called).
/// * The ticker is not ticking, e.g. because it is [muted] (see [isTicking]).
/// * The ticker is [isPaused].
@protected
bool get shouldScheduleTick => !muted && isActive && !scheduled;
bool get shouldScheduleTick => !muted && !isPaused && isActive && !scheduled;
void _tick(Duration timeStamp) {
assert(isTicking);
@ -331,6 +386,7 @@ class Ticker {
assert(_future == null);
assert(_startTime == null);
assert(_animationId == null);
assert(!_isPaused);
assert(
(originalTicker._future == null) == (originalTicker._startTime == null),
'Cannot absorb Ticker after it has been disposed.',
@ -338,6 +394,7 @@ class Ticker {
if (originalTicker._future != null) {
_future = originalTicker._future;
_startTime = originalTicker._startTime;
_isPaused = originalTicker._isPaused;
if (shouldScheduleTick) {
scheduleTick();
}

View File

@ -1333,6 +1333,103 @@ void main() {
},
);
});
group('pauseWhenNoListeners', () {
test('does not schedule frames when pauseWhenNoListeners is true and no listeners', () {
final controller = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: const TestVSync(),
pauseWhenNoListeners: true,
);
// Start animation without listeners
controller.forward();
// Animation should be in forward status but value should not update
// because ticker is muted (no frames scheduled)
expect(controller.status, AnimationStatus.forward);
expect(controller.isAnimating, isTrue);
tick(const Duration(milliseconds: 10));
tick(const Duration(milliseconds: 50));
// Value should still be 0 because no frames were scheduled
expect(controller.value, 0.0);
controller.dispose();
});
test('schedules frames when listener is added with pauseWhenNoListeners true', () {
final controller = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: const TestVSync(),
pauseWhenNoListeners: true,
);
// Start animation without listeners
controller.forward();
expect(controller.value, 0.0);
// Add a listener - this should unmute the ticker
controller.addListener(() {});
tick(const Duration(milliseconds: 10));
tick(const Duration(milliseconds: 60));
// Now value should update because ticker is no longer muted
expect(controller.value, moreOrLessEquals(0.5));
controller.dispose();
});
test('stops scheduling frames when last listener is removed with pauseWhenNoListeners true', () {
final controller = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: const TestVSync(),
pauseWhenNoListeners: true,
);
void listener() {}
controller.addListener(listener);
controller.forward();
tick(const Duration(milliseconds: 10));
tick(const Duration(milliseconds: 30));
// Value should update because we have a listener
expect(controller.value, moreOrLessEquals(0.2));
// Remove the listener
controller.removeListener(listener);
// Advance time - value should not change because ticker is now muted
final valueBeforeRemoval = controller.value;
tick(const Duration(milliseconds: 60));
tick(const Duration(milliseconds: 90));
expect(controller.value, valueBeforeRemoval);
controller.dispose();
});
test('default pauseWhenNoListeners is false (backward compatible)', () {
final controller = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: const TestVSync(),
);
// Start animation without listeners
controller.forward();
tick(const Duration(milliseconds: 10));
tick(const Duration(milliseconds: 60));
// Value should update even without listeners (backward compatible behavior)
expect(controller.value, moreOrLessEquals(0.5));
controller.dispose();
});
});
}
class TestSimulation extends Simulation {

View File

@ -211,4 +211,113 @@ void main() {
areCreateAndDispose,
);
});
testWidgets('Ticker pause control test', (WidgetTester tester) async {
var tickCount = 0;
void handleTick(Duration duration) {
tickCount += 1;
}
final ticker = Ticker(handleTick);
addTearDown(ticker.dispose);
expect(ticker.isPaused, isFalse);
expect(ticker.isTicking, isFalse);
expect(ticker.isActive, isFalse);
ticker.start();
expect(ticker.isPaused, isFalse);
expect(ticker.isTicking, isTrue);
expect(ticker.isActive, isTrue);
expect(tickCount, equals(0));
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(1));
ticker.pause();
expect(ticker.isPaused, isTrue);
expect(ticker.isTicking, isFalse);
expect(ticker.isActive, isTrue);
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(1));
ticker.resume();
expect(ticker.isPaused, isFalse);
expect(ticker.isTicking, isTrue);
expect(ticker.isActive, isTrue);
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(2));
ticker.stop();
});
testWidgets('Ticker pause before start has no effect', (WidgetTester tester) async {
var tickCount = 0;
void handleTick(Duration duration) {
tickCount += 1;
}
final ticker = Ticker(handleTick);
addTearDown(ticker.dispose);
ticker.pause();
expect(ticker.isPaused, isTrue);
expect(ticker.isActive, isFalse);
ticker.start();
expect(ticker.isPaused, isTrue);
expect(ticker.isActive, isTrue);
expect(ticker.isTicking, isFalse);
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(0));
ticker.resume();
expect(ticker.isTicking, isTrue);
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(1));
ticker.stop();
});
testWidgets('Ticker stop clears pause state implicitly via restart', (WidgetTester tester) async {
var tickCount = 0;
void handleTick(Duration duration) {
tickCount += 1;
}
final ticker = Ticker(handleTick);
addTearDown(ticker.dispose);
ticker.start();
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(1));
ticker.pause();
expect(ticker.isPaused, isTrue);
ticker.stop();
expect(ticker.isActive, isFalse);
ticker.start();
expect(ticker.isActive, isTrue);
expect(ticker.isPaused, isTrue);
expect(ticker.isTicking, isFalse);
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(1)); // Still paused
ticker.resume();
await tester.pump(const Duration(milliseconds: 10));
expect(tickCount, equals(2));
ticker.stop();
});
}