From 54e60e23aba8098fdf0353b420427cf64ae2ee0f Mon Sep 17 00:00:00 2001 From: fin Date: Sun, 15 Feb 2026 21:52:37 +0200 Subject: [PATCH] fix: store controller listener for disposal and recreate ticker on restart Two bugs fixed: 1. The anonymous closure added to controller.isAnimating was never removed in dispose(), causing stale callbacks (and potential setState-after-dispose crashes) when the widget is removed while the controller outlives it. Fix: store the listener reference in _controllerListener and remove it in dispose(). 2. After calling _ticker.stop(), calling _ticker.start() again hits Flutter's "Ticker already started" assertion because a stopped Ticker cannot be restarted. Fix: when the controller requests animation to resume after a stop, create a fresh Ticker via _initTicker() instead of calling start() on the existing one. Co-Authored-By: Claude Opus 4.6 --- .../animated_mesh_gradient.dart | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/lib/src/widgets/animated_mesh_gradient/animated_mesh_gradient.dart b/lib/src/widgets/animated_mesh_gradient/animated_mesh_gradient.dart index 0a4a42b..94efbce 100644 --- a/lib/src/widgets/animated_mesh_gradient/animated_mesh_gradient.dart +++ b/lib/src/widgets/animated_mesh_gradient/animated_mesh_gradient.dart @@ -55,6 +55,10 @@ class _AnimatedMeshGradientState extends State { Ticker? _ticker; + /// Stored so we can remove it in [dispose]. Avoids stale listener after + /// widget is disposed (e.g. when list item is replaced by overlay placeholder). + VoidCallback? _controllerListener; + /// The current time value used to control the animation phase. late double _delta = widget.seed ?? 0; @@ -101,36 +105,59 @@ class _AnimatedMeshGradientState extends State { } WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Define the ticker because we are certain it will be used next - _ticker = Ticker(_tickerCallback); + if (!mounted) return; + _initTicker(); + }); + } - // Start the animation to account for isAnimating already being true at init - if (widget.controller == null || widget.controller!.isAnimating.value) { - _ticker!.start(); - } + void _initTicker() { + final VoidCallback? oldListener = _controllerListener; + final AnimatedMeshGradientController? c = widget.controller; + if (oldListener != null && c != null) { + c.isAnimating.removeListener(oldListener); + _controllerListener = null; + } - // Make sure there is no listener added when controller is null - if (widget.controller == null) { - return; - } + _ticker?.dispose(); + _ticker = Ticker(_tickerCallback); - // Register a listener callback for controller.isAnimating changes - widget.controller!.isAnimating.addListener(() { - if (widget.controller!.isAnimating.value && !_ticker!.isActive) { - _ticker!.start(); - return; - } + // Start the animation to account for isAnimating already being true at init + if (c == null || c.isAnimating.value) { + _ticker!.start(); + } + + if (c == null) return; - if (!widget.controller!.isAnimating.value && _ticker!.isActive) { - _ticker!.stop(); + void onControllerChange() { + if (!mounted) return; + final Ticker? t = _ticker; + if (t == null) return; + if (widget.controller!.isAnimating.value) { + if (!t.isActive) { + // Flutter's Ticker cannot be restarted after stop(); create a new one. + _initTicker(); } - }); - }); + return; + } + if (t.isActive) { + t.stop(); + } + } + + _controllerListener = onControllerChange; + c.isAnimating.addListener(_controllerListener!); } @override void dispose() { + final VoidCallback? listener = _controllerListener; + final AnimatedMeshGradientController? c = widget.controller; + if (listener != null && c != null) { + c.isAnimating.removeListener(listener); + _controllerListener = null; + } _ticker?.dispose(); + _ticker = null; super.dispose(); }