Skip to content

Commit 438d762

Browse files
TahaTesserpull[bot]
authored andcommitted
Fix TabBar tab indicator stretch effect (flutter#150868)
fixes [[Material3] TabBar indicator stretch effect behaving weirdly with long tabs](flutter#149662) This PR fixes the tab indicator stretch effect, it currently stretches from both sides. After the fix, tab indicator stretches depending on next size of the indicator and direction of the scroll. The fix is based on Android Components implementation of the elastic/stretch effect. https://github.com/material-components/material-components-android/blob/20f92dfb513916d0df6c38bfd630dfc41aff484e/lib/java/com/google/android/material/tabs/ElasticTabIndicatorInterpolator.java#L46-L78 ### Code sample <details> <summary>expand to view the code sample</summary> ```dart import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @OverRide Widget build(BuildContext context) { return MaterialApp( home: ScrollConfiguration( behavior: ScrollConfiguration.of(context) .copyWith(dragDevices: <PointerDeviceKind>{ PointerDeviceKind.touch, PointerDeviceKind.mouse, }), 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 https://github.com/flutter/flutter/assets/48603081/618d0ba9-40a5-458f-9fdc-5330505a6711 ### After https://github.com/flutter/flutter/assets/48603081/b7fa851e-e7a6-4b66-b77d-f88c7f4381da
1 parent a8a67b9 commit 438d762

File tree

2 files changed

+55
-51
lines changed

2 files changed

+55
-51
lines changed

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

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ class _IndicatorPainter extends CustomPainter {
554554
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
555555

556556
_currentRect = switch (indicatorSize) {
557-
TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!),
557+
TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!, fromRect),
558558
// Do nothing.
559559
TabBarIndicatorSize.tab => _currentRect,
560560
};
@@ -575,8 +575,18 @@ class _IndicatorPainter extends CustomPainter {
575575
_painter!.paint(canvas, _currentRect!.topLeft, configuration);
576576
}
577577

578+
// Ease out sine (decelerating).
579+
double decelerateInterpolation(double fraction) {
580+
return math.sin((fraction * math.pi) / 2.0);
581+
}
582+
583+
// Ease in sine (accelerating).
584+
double accelerateInterpolation(double fraction) {
585+
return 1.0 - math.cos((fraction * math.pi) / 2.0);
586+
}
587+
578588
/// Applies the stretch effect to the indicator.
579-
Rect _applyStretchEffect(Rect rect) {
589+
Rect _applyStretchEffect(Rect rect, Rect targetRect) {
580590
// If the tab animation is completed, there is no need to stretch the indicator
581591
// This only works for the tab change animation via tab index, not when
582592
// dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations.
@@ -586,57 +596,30 @@ class _IndicatorPainter extends CustomPainter {
586596

587597
final double index = controller.index.toDouble();
588598
final double value = controller.animation!.value;
589-
590-
// The progress of the animation from 0 to 1.
591-
late double tabChangeProgress;
592-
593-
// If we are changing tabs via index, we want to map the progress between 0 and 1.
594-
if (controller.indexIsChanging) {
595-
double progressLeft = (index - value).abs();
596-
final int tabsDelta = (controller.index - controller.previousIndex).abs();
597-
if (tabsDelta != 0) {
598-
progressLeft /= tabsDelta;
599-
}
600-
tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0);
601-
} else {
602-
// Otherwise, the progress is how close we are to the current tab.
603-
tabChangeProgress = (index - value).abs();
604-
}
599+
final double tabChangeProgress = (index - value).abs();
605600

606601
// If the animation has finished, there is no need to apply the stretch effect.
607602
if (tabChangeProgress == 1.0) {
608603
return rect;
609604
}
610605

611-
// The maximum amount of extra width to add to the indicator.
612-
final double stretchSize = rect.width;
606+
final double fraction = switch (rect.left < targetRect.left) {
607+
true => accelerateInterpolation(tabChangeProgress),
608+
false => decelerateInterpolation(tabChangeProgress),
609+
};
613610

614-
final double inflationPerSide = stretchSize * _stretchAnimation.transform(tabChangeProgress) / 2;
615-
final Rect stretchedRect = _inflateRectHorizontally(rect, inflationPerSide);
611+
final Rect stretchedRect = _inflateRectHorizontally(rect, targetRect, fraction);
616612
return stretchedRect;
617613
}
618614

619-
/// The animatable that stretches the indicator horizontally when changing tabs.
620-
/// Value range is from 0 to 1, so we can multiply it by an stretch factor.
621-
///
622-
/// Animation starts with no stretch, then quickly goes to the max stretch amount
623-
/// and then goes back to no stretch.
624-
late final Animatable<double> _stretchAnimation = TweenSequence<double>(
625-
<TweenSequenceItem<double>>[
626-
TweenSequenceItem<double>(
627-
tween: Tween<double>(begin: 0.0, end: 1.0),
628-
weight: 20,
629-
),
630-
TweenSequenceItem<double>(
631-
tween: Tween<double>(begin: 1.0, end: 0.0),
632-
weight: 80,
633-
),
634-
],
635-
);
636-
637615
/// Same as [Rect.inflate], but only inflates in the horizontal direction.
638-
Rect _inflateRectHorizontally(Rect r, double delta) {
639-
return Rect.fromLTRB(r.left - delta, r.top, r.right + delta, r.bottom);
616+
Rect _inflateRectHorizontally(Rect rect, Rect targetRect, double fraction) {
617+
return Rect.fromLTRB(
618+
lerpDouble(rect.left, targetRect.left, fraction)!,
619+
rect.top,
620+
lerpDouble(rect.right, targetRect.right, fraction)!,
621+
rect.bottom,
622+
);
640623
}
641624

642625
@override

packages/flutter/test/material/tabs_test.dart

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:math' as math;
6+
import 'dart:ui';
7+
58
import 'package:flutter/foundation.dart';
69
import 'package:flutter/gestures.dart';
710
import 'package:flutter/material.dart';
@@ -2454,7 +2457,7 @@ void main() {
24542457
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, moreOrLessEquals(tabRight));
24552458
});
24562459

2457-
testWidgets('Material3 - Indicator stretch animation', (WidgetTester tester) async {
2460+
testWidgets('Indicator elastic animation', (WidgetTester tester) async {
24582461
const double indicatorWidth = 50.0;
24592462
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
24602463
return Tab(
@@ -2483,15 +2486,26 @@ void main() {
24832486

24842487
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
24852488
expect(tabBarBox.size.height, 48.0);
2486-
final double tabWidth = tabBarBox.size.width / tabs.length;
24872489

2488-
void expectIndicatorAttrs(RenderBox tabBarBox, {required double offset, required double width}) {
2490+
// Ease in sine (accelerating).
2491+
double accelerateIntepolation(double fraction) {
2492+
return 1.0 - math.cos((fraction * math.pi) / 2.0);
2493+
}
2494+
2495+
void expectIndicatorAttrs(
2496+
RenderBox tabBarBox, {
2497+
required Rect rect,
2498+
required Rect targetRect,
2499+
}) {
24892500
const double indicatorWeight = 3.0;
2490-
final double centerX = offset * tabWidth + tabWidth / 2;
2501+
final double tabChangeProgress = (controller.index - controller.animation!.value).abs();
2502+
final double leftFraction = accelerateIntepolation(tabChangeProgress);
2503+
final double rightFraction = accelerateIntepolation(tabChangeProgress);
2504+
24912505
final RRect rrect = RRect.fromLTRBAndCorners(
2492-
centerX - width / 2,
2506+
lerpDouble(rect.left, targetRect.left, leftFraction)!,
24932507
tabBarBox.size.height - indicatorWeight,
2494-
centerX + width / 2,
2508+
lerpDouble(rect.right, targetRect.right, rightFraction)!,
24952509
tabBarBox.size.height,
24962510
topLeft: const Radius.circular(3.0),
24972511
topRight: const Radius.circular(3.0),
@@ -2500,18 +2514,25 @@ void main() {
25002514
expect(tabBarBox, paints..rrect(rrect: rrect));
25012515
}
25022516

2517+
Rect rect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
2518+
Rect targetRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
2519+
25032520
// Idle at tab 0.
2504-
expectIndicatorAttrs(tabBarBox, offset: 0.0, width: indicatorWidth);
2521+
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
25052522

25062523
// Peak stretch at 20%.
25072524
controller.offset = 0.2;
25082525
await tester.pump();
2509-
expectIndicatorAttrs(tabBarBox, offset: 0.2, width: indicatorWidth * 2);
2526+
rect = const Rect.fromLTRB(115.0, 0.0, 165.0, 48.0);
2527+
targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
2528+
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
25102529

25112530
// Idle at tab 1.
25122531
controller.offset = 1;
25132532
await tester.pump();
2514-
expectIndicatorAttrs(tabBarBox, offset: 1, width: indicatorWidth);
2533+
rect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
2534+
targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
2535+
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
25152536
});
25162537

25172538
testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async {

0 commit comments

Comments
 (0)