diff --git a/lib/src/animate_to_item.dart b/lib/src/animate_to_item.dart index 2c9bcb5..b86093c 100644 --- a/lib/src/animate_to_item.dart +++ b/lib/src/animate_to_item.dart @@ -13,6 +13,7 @@ class AnimateToItem { required this.position, required this.duration, required this.curve, + required this.whenCompleteOrCancel, }); final ExtentManager extentManager; @@ -22,10 +23,27 @@ class AnimateToItem { final ScrollPosition position; final Duration Function(double estimatedDistance) duration; final Curve Function(double estimatedDistance) curve; + final void Function() whenCompleteOrCancel; double lastPosition = 0.0; + late AnimationController _controller; + late CurvedAnimation _animation; + + bool _mustDispose = false; + + void dispose() { + if (_mustDispose) { + _mustDispose = false; + _controller.dispose(); + _animation.dispose(); + whenCompleteOrCancel(); + } + } + void animate() { + _mustDispose = true; + final index = this.index(); if (index == null) { return; @@ -38,25 +56,19 @@ class AnimateToItem { estimationOnly: true, ); final estimatedDistance = (estimatedTarget - start).abs(); - final controller = AnimationController( + _controller = AnimationController( vsync: position.context.vsync, duration: duration(estimatedDistance), ); - controller.addStatusListener((status) { - if (status == AnimationStatus.completed) { - controller.dispose(); - } - }); - final animation = CurvedAnimation( - parent: controller, + _animation = CurvedAnimation( + parent: _controller, curve: curve(estimatedDistance), ); - animation.addListener(() { - final value = animation.value; + _animation.addListener(() { + final value = _animation.value; final index = this.index(); if (index == null) { - controller.stop(); - controller.dispose(); + dispose(); return; } var targetPosition = extentManager.getOffsetToReveal( @@ -83,6 +95,6 @@ class AnimateToItem { } position.jumpTo(jumpPosition); }); - controller.forward(); + _controller.forward().whenCompleteOrCancel(dispose); } } diff --git a/lib/src/super_sliver_list.dart b/lib/src/super_sliver_list.dart index 84f9378..2ecfb30 100644 --- a/lib/src/super_sliver_list.dart +++ b/lib/src/super_sliver_list.dart @@ -134,7 +134,8 @@ class ListController extends ChangeNotifier { }) { assert(_delegate != null, "ListController is not attached."); for (final position in scrollController.positions) { - AnimateToItem( + late final AnimateToItem animation; + animation = AnimateToItem( extentManager: _delegate!, index: index, alignment: alignment, @@ -142,7 +143,11 @@ class ListController extends ChangeNotifier { position: position, curve: curve, duration: duration, - ).animate(); + // clean added handle if it's completed normally + whenCompleteOrCancel: () => _runningAnimations.remove(animation) + ); + _runningAnimations.add(animation); + animation.animate(); } } @@ -249,6 +254,11 @@ class ListController extends ChangeNotifier { super.dispose(); } + /// Keeps track of created [AnimateToItem] so we could later dispose + /// [AnimationController]s and animations in case list controller is suddenly + /// unattached. + final List _runningAnimations = []; + ExtentManager? _delegate; void setDelegate(ExtentManager delegate) { @@ -270,6 +280,12 @@ class ListController extends ChangeNotifier { if (_delegate == delegate) { _delegate?.removeListener(notifyListeners); _delegate = null; + // because list can be modified from callback that can be called from + // [AnimateToItem.dispose] we must iterate over copy + for (final controller in _runningAnimations.toList()) { + controller.dispose(); + } + _runningAnimations.clear(); onDetached?.call(); } } diff --git a/test/super_sliver_list_test.dart b/test/super_sliver_list_test.dart index 30e2742..aa613d3 100644 --- a/test/super_sliver_list_test.dart +++ b/test/super_sliver_list_test.dart @@ -1226,6 +1226,53 @@ void main() async { expect(detached, 2); expect(controller.isAttached, false); }); + testWidgets("remove widget mid animation", (tester) async { + final scrollController = ScrollController(); + + int attached = 0; + int detached = 0; + final controller = ListController( + onAttached: () { + ++attached; + }, + onDetached: () { + ++detached; + }, + ); + final configuration = SliverListConfiguration.generate( + slivers: 1, + itemsPerSliver: (_) => 20, + itemHeight: (_, __) => 300, + viewportHeight: 500, + addGlobalKey: true, + ); + await tester.pumpWidget(_buildSliverList( + configuration, + listController: controller, + preciseLayout: false, + controller: scrollController, + )); + await tester.pumpAndSettle(); + expect(attached, 1); + expect(detached, 0); + expect(controller.isAttached, isTrue); + + controller.animateToItem( + index: () => 10, + scrollController: scrollController, + alignment: 0.5, + duration: (estimatedDistance) => const Duration(milliseconds: 1000), + curve: (estimatedDistance) => Curves.linear, + ); + + await tester.pump(const Duration(milliseconds: 500)); + + // will throw if there are any leaked Tickers + await tester.pumpWidget(const SizedBox.shrink()); + expect(attached, 1); + expect(detached, 1); + expect(controller.isAttached, false); + }); testWidgets("replace controller", (tester) async { int attached1 = 0; int detached1 = 0;