Skip to content

Commit 2b34d78

Browse files
authored
Fix TabBar glitchy elastic Tab animation (#161514)
Fixes [M3 TabBar indicator animation broken both when swiping or tapping](flutter/flutter#160631) ### Description This refactors the elastic `Tab` animation. Added additional tests that follows the elastic animation frame by frame and generates a golden file. ### Code Sample <details> <summary>expand to view the code sample</summary> ```dart import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @OverRide Widget build(BuildContext context) { // timeDilation = 10; return MaterialApp( home: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(dragDevices: <PointerDeviceKind>{ PointerDeviceKind.touch, PointerDeviceKind.mouse, }), child: Directionality( textDirection: TextDirection.ltr, child: DefaultTabController( length: 8, child: Scaffold( appBar: AppBar( bottom: const TabBar( isScrollable: true, tabAlignment: TabAlignment.start, tabs: <Widget>[ Tab(text: 'Home'), Tab(text: 'Search'), Tab(text: 'Add'), Tab(text: 'Favorite'), Tab(text: 'The longest text...'), Tab(text: 'Short'), Tab(text: 'Longer text...'), Tab(text: 'Profile'), ], ), ), body: const TabBarView( children: <Widget>[ Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), Center(child: Text('Page')), ], ), ), ), ), ), ); } } ``` </details> ### Before (`timeDilation = 10`) https://github.com/user-attachments/assets/4f69f94b-0bcf-4813-b49f-06ff411435ca ### After (`timeDilation = 10`) https://github.com/user-attachments/assets/65801c1c-d28f-4b42-870a-7140d5d3c4c3 | Before Test Results | After Test Results | | --------------- | --------------- | | <img src="https://github.com/user-attachments/assets/72ae9fbe-fef9-44a0-9b86-5a4c31fd39cf" /> | <img src="https://github.com/user-attachments/assets/2545f35e-ac03-495d-a33b-72b9bc71299b" /> | ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 14d4abb commit 2b34d78

File tree

5 files changed

+435
-130
lines changed

5 files changed

+435
-130
lines changed

examples/api/test/material/tabs/tab_bar.indicator_animation.0_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ void main() {
8989
return true;
9090
}),
9191
);
92-
expect(indicatorRRect.left, closeTo(51.0, 0.1));
92+
expect(indicatorRRect.left, closeTo(76.7, 0.1));
9393
expect(indicatorRRect.top, equals(45.0));
94-
expect(indicatorRRect.right, closeTo(221.4, 0.1));
94+
expect(indicatorRRect.right, closeTo(423.1, 0.1));
9595
expect(indicatorRRect.bottom, equals(48.0));
9696
});
9797
}

packages/flutter/lib/src/material/tabs.dart

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ class _IndicatorPainter extends CustomPainter {
481481
required this.showDivider,
482482
this.devicePixelRatio,
483483
required this.indicatorAnimation,
484+
required this.textDirection,
484485
}) : super(repaint: controller.animation) {
485486
// TODO(polina-c): stop duplicating code across disposables
486487
// https://github.com/flutter/flutter/issues/137435
@@ -507,6 +508,7 @@ class _IndicatorPainter extends CustomPainter {
507508
final bool showDivider;
508509
final double? devicePixelRatio;
509510
final TabIndicatorAnimation indicatorAnimation;
511+
final TextDirection textDirection;
510512

511513
// _currentTabOffsets and _currentTextDirection are set each time TabBar
512514
// layout is completed. These values can be null when TabBar contains no
@@ -583,18 +585,28 @@ class _IndicatorPainter extends CustomPainter {
583585
_needsPaint = false;
584586
_painter ??= indicator.createBoxPainter(markNeedsPaint);
585587

586-
final double index = controller.index.toDouble();
587588
final double value = controller.animation!.value;
588-
final bool ltr = index > value;
589-
final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex);
590-
final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex);
591-
final Rect fromRect = indicatorRect(size, from);
589+
final int to =
590+
controller.indexIsChanging
591+
? controller.index
592+
: switch (textDirection) {
593+
TextDirection.ltr => value.ceil(),
594+
TextDirection.rtl => value.floor(),
595+
}.clamp(0, maxTabIndex);
596+
final int from =
597+
controller.indexIsChanging
598+
? controller.previousIndex
599+
: switch (textDirection) {
600+
TextDirection.ltr => (to - 1),
601+
TextDirection.rtl => (to + 1),
602+
}.clamp(0, maxTabIndex);
592603
final Rect toRect = indicatorRect(size, to);
604+
final Rect fromRect = indicatorRect(size, from);
593605
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
594606

595607
_currentRect = switch (indicatorAnimation) {
596608
TabIndicatorAnimation.linear => _currentRect,
597-
TabIndicatorAnimation.elastic => _applyElasticEffect(_currentRect!, fromRect),
609+
TabIndicatorAnimation.elastic => _applyElasticEffect(fromRect, toRect, _currentRect!),
598610
};
599611

600612
assert(_currentRect != null);
@@ -627,40 +639,69 @@ class _IndicatorPainter extends CustomPainter {
627639
}
628640

629641
/// Applies the elastic effect to the indicator.
630-
Rect _applyElasticEffect(Rect rect, Rect targetRect) {
642+
Rect _applyElasticEffect(Rect fromRect, Rect toRect, Rect currentRect) {
631643
// If the tab animation is completed, there is no need to stretch the indicator
632644
// This only works for the tab change animation via tab index, not when
633645
// dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations.
634646
if (controller.animation!.isCompleted) {
635-
return rect;
647+
return currentRect;
636648
}
637649

638650
final double index = controller.index.toDouble();
639651
final double value = controller.animation!.value;
640-
final double tabChangeProgress = (index - value).abs();
652+
final double tabChangeProgress;
653+
654+
if (controller.indexIsChanging) {
655+
double progressLeft = (index - value).abs();
656+
final int tabsDelta = (controller.index - controller.previousIndex).abs();
657+
if (tabsDelta != 0) {
658+
progressLeft /= tabsDelta;
659+
}
660+
tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0);
661+
} else {
662+
tabChangeProgress = (index - value).abs();
663+
}
641664

642665
// If the animation has finished, there is no need to apply the stretch effect.
643666
if (tabChangeProgress == 1.0) {
644-
return rect;
667+
return currentRect;
645668
}
646669

647-
final double fraction = switch (rect.left < targetRect.left) {
648-
true => accelerateInterpolation(tabChangeProgress),
649-
false => decelerateInterpolation(tabChangeProgress),
670+
final double leftFraction;
671+
final double rightFraction;
672+
final bool isMovingRight = switch (textDirection) {
673+
TextDirection.ltr => controller.indexIsChanging ? index > value : value > index,
674+
TextDirection.rtl => controller.indexIsChanging ? value > index : index > value,
650675
};
676+
if (isMovingRight) {
677+
leftFraction = accelerateInterpolation(tabChangeProgress);
678+
rightFraction = decelerateInterpolation(tabChangeProgress);
679+
} else {
680+
leftFraction = decelerateInterpolation(tabChangeProgress);
681+
rightFraction = accelerateInterpolation(tabChangeProgress);
682+
}
651683

652-
final Rect stretchedRect = _inflateRectHorizontally(rect, targetRect, fraction);
653-
return stretchedRect;
654-
}
684+
final double lerpRectLeft;
685+
final double lerpRectRight;
655686

656-
/// Same as [Rect.inflate], but only inflates in the horizontal direction.
657-
Rect _inflateRectHorizontally(Rect rect, Rect targetRect, double fraction) {
658-
return Rect.fromLTRB(
659-
lerpDouble(rect.left, targetRect.left, fraction)!,
660-
rect.top,
661-
lerpDouble(rect.right, targetRect.right, fraction)!,
662-
rect.bottom,
663-
);
687+
// The controller.indexIsChanging is true when the Tab is pressed, instead of swipe to change tabs.
688+
// If the tab is pressed then only lerp between fromRect and toRect.
689+
if (controller.indexIsChanging) {
690+
lerpRectLeft = lerpDouble(fromRect.left, toRect.left, leftFraction)!;
691+
lerpRectRight = lerpDouble(fromRect.right, toRect.right, rightFraction)!;
692+
} else {
693+
// Switch the Rect left and right lerp order based on swipe direction.
694+
lerpRectLeft = switch (isMovingRight) {
695+
true => lerpDouble(fromRect.left, toRect.left, leftFraction)!,
696+
false => lerpDouble(toRect.left, fromRect.left, leftFraction)!,
697+
};
698+
lerpRectRight = switch (isMovingRight) {
699+
true => lerpDouble(fromRect.right, toRect.right, rightFraction)!,
700+
false => lerpDouble(toRect.right, fromRect.right, rightFraction)!,
701+
};
702+
}
703+
704+
return Rect.fromLTRB(lerpRectLeft, currentRect.top, lerpRectRight, currentRect.bottom);
664705
}
665706

666707
@override
@@ -1517,6 +1558,7 @@ class _TabBarState extends State<TabBar> {
15171558
widget.indicatorAnimation ??
15181559
tabBarTheme.indicatorAnimation ??
15191560
defaultTabIndicatorAnimation,
1561+
textDirection: Directionality.of(context),
15201562
);
15211563

15221564
oldPainter?.dispose();

packages/flutter/test/material/tab_bar_theme_test.dart

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
@Tags(<String>['reduced-test-set'])
88
library;
99

10-
import 'dart:math' as math;
1110
import 'dart:ui';
1211

1312
import 'package:flutter/foundation.dart';
@@ -1700,42 +1699,27 @@ void main() {
17001699
await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic));
17011700
await tester.pumpAndSettle();
17021701

1703-
// Ease in sine (accelerating).
1704-
double accelerateInterpolation(double fraction) {
1705-
return 1.0 - math.cos((fraction * math.pi) / 2.0);
1706-
}
1707-
1708-
void expectIndicatorAttrs(RenderBox tabBarBox, {required Rect rect, required Rect targetRect}) {
1709-
const double indicatorWeight = 3.0;
1710-
final double tabChangeProgress = (controller.index - controller.animation!.value).abs();
1711-
final double leftFraction = accelerateInterpolation(tabChangeProgress);
1712-
final double rightFraction = accelerateInterpolation(tabChangeProgress);
1713-
1714-
final RRect rrect = RRect.fromLTRBAndCorners(
1715-
lerpDouble(rect.left, targetRect.left, leftFraction)!,
1716-
tabBarBox.size.height - indicatorWeight,
1717-
lerpDouble(rect.right, targetRect.right, rightFraction)!,
1718-
tabBarBox.size.height,
1719-
topLeft: const Radius.circular(3.0),
1720-
topRight: const Radius.circular(3.0),
1721-
);
1722-
1723-
expect(tabBarBox, paints..rrect(rrect: rrect));
1724-
}
1725-
1726-
Rect rect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
1727-
Rect targetRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
1728-
17291702
// Idle at tab 0.
1730-
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
1703+
const Rect currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
1704+
const Rect fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
1705+
Rect toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
1706+
expect(
1707+
tabBarBox,
1708+
paints..rrect(
1709+
rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0),
1710+
),
1711+
);
17311712

17321713
// Start moving tab indicator.
17331714
controller.offset = 0.2;
17341715
await tester.pump();
1735-
1736-
rect = const Rect.fromLTRB(115.0, 0.0, 165.0, 48.0);
1737-
targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
1738-
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
1716+
toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
1717+
expect(
1718+
tabBarBox,
1719+
paints..rrect(
1720+
rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2),
1721+
),
1722+
);
17391723
});
17401724

17411725
testWidgets('TabBar inherits splashBorderRadius from theme', (WidgetTester tester) async {

0 commit comments

Comments
 (0)