Skip to content

Commit bba6ea9

Browse files
authored
Introduce TabBar.indicatorAnimation to customize tab indicator animation (flutter#151746)
fixes [Add ability to customize `TabBar` indicator animation](flutter#150508) Similar option exist on Android https://developer.android.com/reference/com/google/android/material/tabs/TabLayout#setTabIndicatorAnimationMode(int) ### Dartpad Example Preview <img width="874" alt="Screenshot 2024-07-12 at 17 36 08" src="https://github.com/user-attachments/assets/e349c5aa-ee5d-46ce-9e44-4f02346603bd"> ### Linear vs Elastic tab indicator animation https://github.com/user-attachments/assets/d7ae3ae4-ae52-4ccd-89b1-75908bf8a34d
1 parent 98c5e68 commit bba6ea9

File tree

6 files changed

+474
-9
lines changed

6 files changed

+474
-9
lines changed
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [TabBar.indicatorAnimation].
8+
9+
void main() => runApp(const IndicatorAnimationExampleApp());
10+
11+
class IndicatorAnimationExampleApp extends StatelessWidget {
12+
const IndicatorAnimationExampleApp({super.key});
13+
14+
@override
15+
Widget build(BuildContext context) {
16+
return const MaterialApp(
17+
home: IndicatorAnimationExample(),
18+
);
19+
}
20+
}
21+
22+
const List<(TabIndicatorAnimation, String)> indicatorAnimationSegments = <(TabIndicatorAnimation, String)>[
23+
(TabIndicatorAnimation.linear, 'Linear'),
24+
(TabIndicatorAnimation.elastic, 'Elastic'),
25+
];
26+
27+
class IndicatorAnimationExample extends StatefulWidget {
28+
const IndicatorAnimationExample({super.key});
29+
30+
@override
31+
State<IndicatorAnimationExample> createState() => _IndicatorAnimationExampleState();
32+
}
33+
34+
class _IndicatorAnimationExampleState extends State<IndicatorAnimationExample> {
35+
Set<TabIndicatorAnimation> _animationStyleSelection = <TabIndicatorAnimation>{TabIndicatorAnimation.linear};
36+
TabIndicatorAnimation _tabIndicatorAnimation = TabIndicatorAnimation.linear;
37+
38+
@override
39+
Widget build(BuildContext context) {
40+
return DefaultTabController(
41+
length: 6,
42+
child: Scaffold(
43+
appBar: AppBar(
44+
title: const Text('Indicator Animation Example'),
45+
bottom: TabBar(
46+
indicatorAnimation: _tabIndicatorAnimation,
47+
isScrollable: true,
48+
tabAlignment: TabAlignment.start,
49+
tabs: const <Widget>[
50+
Tab(text: 'Short Tab'),
51+
Tab(text: 'Very Very Very Long Tab'),
52+
Tab(text: 'Short Tab'),
53+
Tab(text: 'Very Very Very Long Tab'),
54+
Tab(text: 'Short Tab'),
55+
Tab(text: 'Very Very Very Long Tab'),
56+
],
57+
),
58+
),
59+
body: Column(
60+
children: <Widget>[
61+
const SizedBox(height: 16),
62+
SegmentedButton<TabIndicatorAnimation>(
63+
selected: _animationStyleSelection,
64+
onSelectionChanged: (Set<TabIndicatorAnimation> styles) {
65+
setState(() {
66+
_animationStyleSelection = styles;
67+
_tabIndicatorAnimation = styles.first;
68+
});
69+
},
70+
segments: indicatorAnimationSegments
71+
.map<ButtonSegment<TabIndicatorAnimation>>(((TabIndicatorAnimation, String) shirt) {
72+
return ButtonSegment<TabIndicatorAnimation>(value: shirt.$1, label: Text(shirt.$2));
73+
})
74+
.toList(),
75+
),
76+
const SizedBox(height: 16),
77+
const Expanded(
78+
child: TabBarView(
79+
children: <Widget>[
80+
Center(
81+
child: Text('Short Tab Page'),
82+
),
83+
Center(
84+
child: Text('Very Very Very Long Tab Page'),
85+
),
86+
Center(
87+
child: Text('Short Tab Page'),
88+
),
89+
Center(
90+
child: Text('Very Very Very Long Tab Page'),
91+
),
92+
Center(
93+
child: Text('Short Tab Page'),
94+
),
95+
Center(
96+
child: Text('Very Very Very Long Tab Page'),
97+
),
98+
],
99+
),
100+
),
101+
],
102+
),
103+
),
104+
);
105+
}
106+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/material/tabs/tab_bar.indicator_animation.0.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('TabBar.indicatorAnimation can customize tab indicator animation', (WidgetTester tester) async {
11+
await tester.pumpWidget(
12+
const example.IndicatorAnimationExampleApp(),
13+
);
14+
15+
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
16+
17+
late RRect indicatorRRect;
18+
19+
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
20+
if (method != #drawRRect) {
21+
return false;
22+
}
23+
indicatorRRect = arguments[0] as RRect;
24+
return true;
25+
}));
26+
expect(indicatorRRect.left, equals(16.0));
27+
expect(indicatorRRect.top, equals(45.0));
28+
expect(indicatorRRect.right, closeTo(142.9, 0.1));
29+
expect(indicatorRRect.bottom, equals(48.0));
30+
31+
// Tap the long tab.
32+
await tester.tap(find.text('Very Very Very Long Tab').first);
33+
await tester.pump();
34+
await tester.pump(const Duration(milliseconds: 100));
35+
36+
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
37+
if (method != #drawRRect) {
38+
return false;
39+
}
40+
indicatorRRect = arguments[0] as RRect;
41+
return true;
42+
}));
43+
expect(indicatorRRect.left, closeTo(107.5, 0.1));
44+
expect(indicatorRRect.top, equals(45.0));
45+
expect(indicatorRRect.right, closeTo(348.2, 0.1));
46+
expect(indicatorRRect.bottom, equals(48.0));
47+
48+
// Tap to go to the first tab.
49+
await tester.tap(find.text('Short Tab').first);
50+
await tester.pumpAndSettle();
51+
52+
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
53+
if (method != #drawRRect) {
54+
return false;
55+
}
56+
indicatorRRect = arguments[0] as RRect;
57+
return true;
58+
}));
59+
expect(indicatorRRect.left, equals(16.0));
60+
expect(indicatorRRect.top, equals(45.0));
61+
expect(indicatorRRect.right, closeTo(142.9, 0.1));
62+
expect(indicatorRRect.bottom, equals(48.0));
63+
64+
// Select the elastic animation.
65+
await tester.tap(find.text('Elastic'));
66+
await tester.pumpAndSettle();
67+
68+
// Tap the long tab.
69+
await tester.tap(find.text('Very Very Very Long Tab').first);
70+
await tester.pump();
71+
await tester.pump(const Duration(milliseconds: 100));
72+
73+
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
74+
if (method != #drawRRect) {
75+
return false;
76+
}
77+
indicatorRRect = arguments[0] as RRect;
78+
return true;
79+
}));
80+
expect(indicatorRRect.left, closeTo(51.0, 0.1));
81+
expect(indicatorRRect.top, equals(45.0));
82+
expect(indicatorRRect.right, closeTo(221.4, 0.1));
83+
expect(indicatorRRect.bottom, equals(48.0));
84+
});
85+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class TabBarTheme with Diagnosticable {
4343
this.mouseCursor,
4444
this.tabAlignment,
4545
this.textScaler,
46+
this.indicatorAnimation,
4647
});
4748

4849
/// Overrides the default value for [TabBar.indicator].
@@ -102,6 +103,9 @@ class TabBarTheme with Diagnosticable {
102103
/// Overrides the default value for [TabBar.textScaler].
103104
final TextScaler? textScaler;
104105

106+
/// Overrides the default value for [TabBar.indicatorAnimation].
107+
final TabIndicatorAnimation? indicatorAnimation;
108+
105109
/// Creates a copy of this object but with the given fields replaced with the
106110
/// new values.
107111
TabBarTheme copyWith({
@@ -120,6 +124,7 @@ class TabBarTheme with Diagnosticable {
120124
MaterialStateProperty<MouseCursor?>? mouseCursor,
121125
TabAlignment? tabAlignment,
122126
TextScaler? textScaler,
127+
TabIndicatorAnimation? indicatorAnimation,
123128
}) {
124129
return TabBarTheme(
125130
indicator: indicator ?? this.indicator,
@@ -137,6 +142,7 @@ class TabBarTheme with Diagnosticable {
137142
mouseCursor: mouseCursor ?? this.mouseCursor,
138143
tabAlignment: tabAlignment ?? this.tabAlignment,
139144
textScaler: textScaler ?? this.textScaler,
145+
indicatorAnimation: indicatorAnimation ?? this.indicatorAnimation,
140146
);
141147
}
142148

@@ -168,6 +174,7 @@ class TabBarTheme with Diagnosticable {
168174
mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor,
169175
tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment,
170176
textScaler: t < 0.5 ? a.textScaler : b.textScaler,
177+
indicatorAnimation: t < 0.5 ? a.indicatorAnimation : b.indicatorAnimation,
171178
);
172179
}
173180

@@ -188,6 +195,7 @@ class TabBarTheme with Diagnosticable {
188195
mouseCursor,
189196
tabAlignment,
190197
textScaler,
198+
indicatorAnimation,
191199
);
192200

193201
@override
@@ -213,6 +221,7 @@ class TabBarTheme with Diagnosticable {
213221
&& other.splashFactory == splashFactory
214222
&& other.mouseCursor == mouseCursor
215223
&& other.tabAlignment == tabAlignment
216-
&& other.textScaler == textScaler;
224+
&& other.textScaler == textScaler
225+
&& other.indicatorAnimation == indicatorAnimation;
217226
}
218227
}

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

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ enum TabAlignment {
8888
center,
8989
}
9090

91+
/// Defines how the tab indicator animates when the selected tab changes.
92+
///
93+
/// See also:
94+
/// * [TabBar], which displays a row of tabs.
95+
/// * [TabBarTheme], which can be used to configure the appearance of the tab
96+
/// indicator.
97+
enum TabIndicatorAnimation {
98+
/// The tab indicator animates linearly.
99+
linear,
100+
101+
/// The tab indicator animates with an elastic effect.
102+
elastic,
103+
}
104+
91105
/// A Material Design [TabBar] tab.
92106
///
93107
/// If both [icon] and [text] are provided, the text is displayed below
@@ -446,6 +460,7 @@ class _IndicatorPainter extends CustomPainter {
446460
this.dividerHeight,
447461
required this.showDivider,
448462
this.devicePixelRatio,
463+
required this.indicatorAnimation,
449464
}) : super(repaint: controller.animation) {
450465
// TODO(polina-c): stop duplicating code across disposables
451466
// https://github.com/flutter/flutter/issues/137435
@@ -471,6 +486,7 @@ class _IndicatorPainter extends CustomPainter {
471486
final double? dividerHeight;
472487
final bool showDivider;
473488
final double? devicePixelRatio;
489+
final TabIndicatorAnimation indicatorAnimation;
474490

475491
// _currentTabOffsets and _currentTextDirection are set each time TabBar
476492
// layout is completed. These values can be null when TabBar contains no
@@ -556,10 +572,9 @@ class _IndicatorPainter extends CustomPainter {
556572
final Rect toRect = indicatorRect(size, to);
557573
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
558574

559-
_currentRect = switch (indicatorSize) {
560-
TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!, fromRect),
561-
// Do nothing.
562-
TabBarIndicatorSize.tab => _currentRect,
575+
_currentRect = switch (indicatorAnimation) {
576+
TabIndicatorAnimation.linear => _currentRect,
577+
TabIndicatorAnimation.elastic => _applyElasticEffect(_currentRect!, fromRect),
563578
};
564579

565580
assert(_currentRect != null);
@@ -588,8 +603,8 @@ class _IndicatorPainter extends CustomPainter {
588603
return 1.0 - math.cos((fraction * math.pi) / 2.0);
589604
}
590605

591-
/// Applies the stretch effect to the indicator.
592-
Rect _applyStretchEffect(Rect rect, Rect targetRect) {
606+
/// Applies the elastic effect to the indicator.
607+
Rect _applyElasticEffect(Rect rect, Rect targetRect) {
593608
// If the tab animation is completed, there is no need to stretch the indicator
594609
// This only works for the tab change animation via tab index, not when
595610
// dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations.
@@ -851,6 +866,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
851866
this.splashBorderRadius,
852867
this.tabAlignment,
853868
this.textScaler,
869+
this.indicatorAnimation,
854870
}) : _isPrimary = true,
855871
assert(indicator != null || (indicatorWeight > 0.0));
856872

@@ -903,6 +919,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
903919
this.splashBorderRadius,
904920
this.tabAlignment,
905921
this.textScaler,
922+
this.indicatorAnimation,
906923
}) : _isPrimary = false,
907924
assert(indicator != null || (indicatorWeight > 0.0));
908925

@@ -1248,6 +1265,25 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
12481265
/// * [TextScaler], which is used to scale text based on the device's text scale factor.
12491266
final TextScaler? textScaler;
12501267

1268+
/// Specifies the animation behavior of the tab indicator.
1269+
///
1270+
/// If this is null, then the value of [TabBarTheme.indicatorAnimation] is used.
1271+
/// If that is also null, then the tab indicator will animate linearly if the
1272+
/// [indicatorSize] is [TabBarIndicatorSize.tab], otherwise it will animate
1273+
/// with an elastic effect if the [indicatorSize] is [TabBarIndicatorSize.label].
1274+
///
1275+
/// {@tool dartpad}
1276+
/// This sample shows how to customize the animation behavior of the tab indicator
1277+
/// by using the [indicatorAnimation] property.
1278+
///
1279+
/// ** See code in examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart **
1280+
/// {@end-tool}
1281+
///
1282+
/// See also:
1283+
///
1284+
/// * [TabIndicatorAnimation], which specifies the animation behavior of the tab indicator.
1285+
final TabIndicatorAnimation? indicatorAnimation;
1286+
12511287
/// A size whose height depends on if the tabs have both icons and text.
12521288
///
12531289
/// [AppBar] uses this size to compute its own preferred size.
@@ -1426,6 +1462,11 @@ class _TabBarState extends State<TabBar> {
14261462

14271463
final _IndicatorPainter? oldPainter = _indicatorPainter;
14281464

1465+
final TabIndicatorAnimation defaultTabIndicatorAnimation = switch (indicatorSize) {
1466+
TabBarIndicatorSize.label => TabIndicatorAnimation.elastic,
1467+
TabBarIndicatorSize.tab => TabIndicatorAnimation.linear,
1468+
};
1469+
14291470
_indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
14301471
controller: _controller!,
14311472
indicator: _getIndicator(indicatorSize),
@@ -1439,6 +1480,7 @@ class _TabBarState extends State<TabBar> {
14391480
dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight,
14401481
showDivider: theme.useMaterial3 && !widget.isScrollable,
14411482
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
1483+
indicatorAnimation: widget.indicatorAnimation ?? tabBarTheme.indicatorAnimation ?? defaultTabIndicatorAnimation,
14421484
);
14431485

14441486
oldPainter?.dispose();
@@ -1471,7 +1513,8 @@ class _TabBarState extends State<TabBar> {
14711513
widget.indicatorPadding != oldWidget.indicatorPadding ||
14721514
widget.indicator != oldWidget.indicator ||
14731515
widget.dividerColor != oldWidget.dividerColor ||
1474-
widget.dividerHeight != oldWidget.dividerHeight) {
1516+
widget.dividerHeight != oldWidget.dividerHeight||
1517+
widget.indicatorAnimation != oldWidget.indicatorAnimation) {
14751518
_initIndicatorPainter();
14761519
}
14771520

0 commit comments

Comments
 (0)