Skip to content

Commit 083ac65

Browse files
authored
Fix TabBarView desynchronized after animation interruption (#132748)
1 parent e9beaea commit 083ac65

File tree

2 files changed

+68
-5
lines changed

2 files changed

+68
-5
lines changed

packages/flutter/lib/src/widgets/scroll_activity.dart

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ abstract class ScrollActivity {
6363
ScrollActivityDelegate get delegate => _delegate;
6464
ScrollActivityDelegate _delegate;
6565

66+
bool _isDisposed = false;
67+
6668
/// Updates the activity's link to the [ScrollActivityDelegate].
6769
///
6870
/// This should only be called when an activity is being moved from a defunct
@@ -134,7 +136,9 @@ abstract class ScrollActivity {
134136

135137
/// Called when the scroll view stops performing this activity.
136138
@mustCallSuper
137-
void dispose() { }
139+
void dispose() {
140+
_isDisposed = true;
141+
}
138142

139143
@override
140144
String toString() => describeIdentity(this);
@@ -535,7 +539,7 @@ class BallisticScrollActivity extends ScrollActivity {
535539
)
536540
..addListener(_tick)
537541
..animateWith(simulation)
538-
.whenComplete(_end); // won't trigger if we dispose _controller first
542+
.whenComplete(_end); // won't trigger if we dispose _controller before it completes.
539543
}
540544

541545
late AnimationController _controller;
@@ -569,7 +573,11 @@ class BallisticScrollActivity extends ScrollActivity {
569573
}
570574

571575
void _end() {
572-
delegate.goBallistic(0.0);
576+
// Check if the activity was disposed before going ballistic because _end might be called
577+
// if _controller is disposed just after completion.
578+
if (!_isDisposed) {
579+
delegate.goBallistic(0.0);
580+
}
573581
}
574582

575583
@override
@@ -628,7 +636,7 @@ class DrivenScrollActivity extends ScrollActivity {
628636
)
629637
..addListener(_tick)
630638
..animateTo(to, duration: duration, curve: curve)
631-
.whenComplete(_end); // won't trigger if we dispose _controller first
639+
.whenComplete(_end); // won't trigger if we dispose _controller before it completes.
632640
}
633641

634642
late final Completer<void> _completer;
@@ -648,7 +656,11 @@ class DrivenScrollActivity extends ScrollActivity {
648656
}
649657

650658
void _end() {
651-
delegate.goBallistic(velocity);
659+
// Check if the activity was disposed before going ballistic because _end might be called
660+
// if _controller is disposed just after completion.
661+
if (!_isDisposed) {
662+
delegate.goBallistic(velocity);
663+
}
652664
}
653665

654666
@override

packages/flutter/test/material/tabs_test.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,6 +2224,57 @@ void main() {
22242224
expect(tabController.index, 0);
22252225
});
22262226

2227+
testWidgets('On going TabBarView animation can be interrupted by a new animation', (WidgetTester tester) async {
2228+
// This is a regression test for https://github.com/flutter/flutter/issues/132293.
2229+
2230+
final List<String> tabs = <String>['A', 'B', 'C'];
2231+
final TabController tabController = TabController(
2232+
vsync: const TestVSync(),
2233+
length: tabs.length,
2234+
);
2235+
await tester.pumpWidget(boilerplate(
2236+
child: Column(
2237+
children: <Widget>[
2238+
TabBar(
2239+
tabs: tabs.map<Widget>((String tab) => Tab(text: tab)).toList(),
2240+
controller: tabController,
2241+
),
2242+
SizedBox(
2243+
width: 400.0,
2244+
height: 400.0,
2245+
child: TabBarView(
2246+
controller: tabController,
2247+
children: const <Widget>[
2248+
Center(child: Text('0')),
2249+
Center(child: Text('1')),
2250+
Center(child: Text('2')),
2251+
],
2252+
),
2253+
),
2254+
],
2255+
),
2256+
));
2257+
2258+
// First page is visible.
2259+
expect(tabController.index, 0);
2260+
expect(find.text('0'), findsOneWidget);
2261+
expect(find.text('1'), findsNothing);
2262+
2263+
// Animate to the second page.
2264+
tabController.animateTo(1);
2265+
await tester.pump();
2266+
await tester.pump(const Duration(milliseconds: 300));
2267+
2268+
// Animate back to the first page before the previous animation ends.
2269+
tabController.animateTo(0);
2270+
await tester.pumpAndSettle();
2271+
2272+
// First page should be visible.
2273+
expect(tabController.index, 0);
2274+
expect(find.text('0'), findsOneWidget);
2275+
expect(find.text('1'), findsNothing);
2276+
});
2277+
22272278
testWidgets('Can switch to non-neighboring tab in nested TabBarView without crashing', (WidgetTester tester) async {
22282279
// This is a regression test for https://github.com/flutter/flutter/issues/18756
22292280
final TabController mainTabController = _tabController(length: 4, vsync: const TestVSync());

0 commit comments

Comments
 (0)