diff --git a/packages/flutter/lib/src/animation/animation_controller.dart b/packages/flutter/lib/src/animation/animation_controller.dart index 1baef4d5d92..5b90cbacde5 100644 --- a/packages/flutter/lib/src/animation/animation_controller.dart +++ b/packages/flutter/lib/src/animation/animation_controller.dart @@ -242,6 +242,11 @@ class AnimationController extends Animation /// * `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 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 /// 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 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 /// [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] 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 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 _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; diff --git a/packages/flutter/lib/src/scheduler/ticker.dart b/packages/flutter/lib/src/scheduler/ticker.dart index be7c1751109..bc68de13d3e 100644 --- a/packages/flutter/lib/src/scheduler/ticker.dart +++ b/packages/flutter/lib/src/scheduler/ticker.dart @@ -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(); } diff --git a/packages/flutter/test/animation/animation_controller_test.dart b/packages/flutter/test/animation/animation_controller_test.dart index 6626e8b1b2b..98b0b0da06c 100644 --- a/packages/flutter/test/animation/animation_controller_test.dart +++ b/packages/flutter/test/animation/animation_controller_test.dart @@ -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 { diff --git a/packages/flutter/test/scheduler/ticker_test.dart b/packages/flutter/test/scheduler/ticker_test.dart index 23c10055021..062a0781ced 100644 --- a/packages/flutter/test/scheduler/ticker_test.dart +++ b/packages/flutter/test/scheduler/ticker_test.dart @@ -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(); + }); }