Introduce TabBar.indicatorAnimation to customize tab indicator animation (#151746)

fixes [Add ability to customize `TabBar` indicator animation](https://github.com/flutter/flutter/issues/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
This commit is contained in:
Taha Tesser
2024-07-26 14:54:28 +03:00
committed by GitHub
parent 98c5e683fd
commit bba6ea9a2d
6 changed files with 474 additions and 9 deletions

View File

@@ -0,0 +1,106 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
/// Flutter code sample for [TabBar.indicatorAnimation].
void main() => runApp(const IndicatorAnimationExampleApp());
class IndicatorAnimationExampleApp extends StatelessWidget {
const IndicatorAnimationExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: IndicatorAnimationExample(),
);
}
}
const List<(TabIndicatorAnimation, String)> indicatorAnimationSegments = <(TabIndicatorAnimation, String)>[
(TabIndicatorAnimation.linear, 'Linear'),
(TabIndicatorAnimation.elastic, 'Elastic'),
];
class IndicatorAnimationExample extends StatefulWidget {
const IndicatorAnimationExample({super.key});
@override
State<IndicatorAnimationExample> createState() => _IndicatorAnimationExampleState();
}
class _IndicatorAnimationExampleState extends State<IndicatorAnimationExample> {
Set<TabIndicatorAnimation> _animationStyleSelection = <TabIndicatorAnimation>{TabIndicatorAnimation.linear};
TabIndicatorAnimation _tabIndicatorAnimation = TabIndicatorAnimation.linear;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 6,
child: Scaffold(
appBar: AppBar(
title: const Text('Indicator Animation Example'),
bottom: TabBar(
indicatorAnimation: _tabIndicatorAnimation,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: const <Widget>[
Tab(text: 'Short Tab'),
Tab(text: 'Very Very Very Long Tab'),
Tab(text: 'Short Tab'),
Tab(text: 'Very Very Very Long Tab'),
Tab(text: 'Short Tab'),
Tab(text: 'Very Very Very Long Tab'),
],
),
),
body: Column(
children: <Widget>[
const SizedBox(height: 16),
SegmentedButton<TabIndicatorAnimation>(
selected: _animationStyleSelection,
onSelectionChanged: (Set<TabIndicatorAnimation> styles) {
setState(() {
_animationStyleSelection = styles;
_tabIndicatorAnimation = styles.first;
});
},
segments: indicatorAnimationSegments
.map<ButtonSegment<TabIndicatorAnimation>>(((TabIndicatorAnimation, String) shirt) {
return ButtonSegment<TabIndicatorAnimation>(value: shirt.$1, label: Text(shirt.$2));
})
.toList(),
),
const SizedBox(height: 16),
const Expanded(
child: TabBarView(
children: <Widget>[
Center(
child: Text('Short Tab Page'),
),
Center(
child: Text('Very Very Very Long Tab Page'),
),
Center(
child: Text('Short Tab Page'),
),
Center(
child: Text('Very Very Very Long Tab Page'),
),
Center(
child: Text('Short Tab Page'),
),
Center(
child: Text('Very Very Very Long Tab Page'),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/tabs/tab_bar.indicator_animation.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('TabBar.indicatorAnimation can customize tab indicator animation', (WidgetTester tester) async {
await tester.pumpWidget(
const example.IndicatorAnimationExampleApp(),
);
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
late RRect indicatorRRect;
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawRRect) {
return false;
}
indicatorRRect = arguments[0] as RRect;
return true;
}));
expect(indicatorRRect.left, equals(16.0));
expect(indicatorRRect.top, equals(45.0));
expect(indicatorRRect.right, closeTo(142.9, 0.1));
expect(indicatorRRect.bottom, equals(48.0));
// Tap the long tab.
await tester.tap(find.text('Very Very Very Long Tab').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawRRect) {
return false;
}
indicatorRRect = arguments[0] as RRect;
return true;
}));
expect(indicatorRRect.left, closeTo(107.5, 0.1));
expect(indicatorRRect.top, equals(45.0));
expect(indicatorRRect.right, closeTo(348.2, 0.1));
expect(indicatorRRect.bottom, equals(48.0));
// Tap to go to the first tab.
await tester.tap(find.text('Short Tab').first);
await tester.pumpAndSettle();
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawRRect) {
return false;
}
indicatorRRect = arguments[0] as RRect;
return true;
}));
expect(indicatorRRect.left, equals(16.0));
expect(indicatorRRect.top, equals(45.0));
expect(indicatorRRect.right, closeTo(142.9, 0.1));
expect(indicatorRRect.bottom, equals(48.0));
// Select the elastic animation.
await tester.tap(find.text('Elastic'));
await tester.pumpAndSettle();
// Tap the long tab.
await tester.tap(find.text('Very Very Very Long Tab').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(tabBarBox, paints..something((Symbol method, List<dynamic> arguments) {
if (method != #drawRRect) {
return false;
}
indicatorRRect = arguments[0] as RRect;
return true;
}));
expect(indicatorRRect.left, closeTo(51.0, 0.1));
expect(indicatorRRect.top, equals(45.0));
expect(indicatorRRect.right, closeTo(221.4, 0.1));
expect(indicatorRRect.bottom, equals(48.0));
});
}

View File

@@ -43,6 +43,7 @@ class TabBarTheme with Diagnosticable {
this.mouseCursor,
this.tabAlignment,
this.textScaler,
this.indicatorAnimation,
});
/// Overrides the default value for [TabBar.indicator].
@@ -102,6 +103,9 @@ class TabBarTheme with Diagnosticable {
/// Overrides the default value for [TabBar.textScaler].
final TextScaler? textScaler;
/// Overrides the default value for [TabBar.indicatorAnimation].
final TabIndicatorAnimation? indicatorAnimation;
/// Creates a copy of this object but with the given fields replaced with the
/// new values.
TabBarTheme copyWith({
@@ -120,6 +124,7 @@ class TabBarTheme with Diagnosticable {
MaterialStateProperty<MouseCursor?>? mouseCursor,
TabAlignment? tabAlignment,
TextScaler? textScaler,
TabIndicatorAnimation? indicatorAnimation,
}) {
return TabBarTheme(
indicator: indicator ?? this.indicator,
@@ -137,6 +142,7 @@ class TabBarTheme with Diagnosticable {
mouseCursor: mouseCursor ?? this.mouseCursor,
tabAlignment: tabAlignment ?? this.tabAlignment,
textScaler: textScaler ?? this.textScaler,
indicatorAnimation: indicatorAnimation ?? this.indicatorAnimation,
);
}
@@ -168,6 +174,7 @@ class TabBarTheme with Diagnosticable {
mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor,
tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment,
textScaler: t < 0.5 ? a.textScaler : b.textScaler,
indicatorAnimation: t < 0.5 ? a.indicatorAnimation : b.indicatorAnimation,
);
}
@@ -188,6 +195,7 @@ class TabBarTheme with Diagnosticable {
mouseCursor,
tabAlignment,
textScaler,
indicatorAnimation,
);
@override
@@ -213,6 +221,7 @@ class TabBarTheme with Diagnosticable {
&& other.splashFactory == splashFactory
&& other.mouseCursor == mouseCursor
&& other.tabAlignment == tabAlignment
&& other.textScaler == textScaler;
&& other.textScaler == textScaler
&& other.indicatorAnimation == indicatorAnimation;
}
}

View File

@@ -88,6 +88,20 @@ enum TabAlignment {
center,
}
/// Defines how the tab indicator animates when the selected tab changes.
///
/// See also:
/// * [TabBar], which displays a row of tabs.
/// * [TabBarTheme], which can be used to configure the appearance of the tab
/// indicator.
enum TabIndicatorAnimation {
/// The tab indicator animates linearly.
linear,
/// The tab indicator animates with an elastic effect.
elastic,
}
/// A Material Design [TabBar] tab.
///
/// If both [icon] and [text] are provided, the text is displayed below
@@ -446,6 +460,7 @@ class _IndicatorPainter extends CustomPainter {
this.dividerHeight,
required this.showDivider,
this.devicePixelRatio,
required this.indicatorAnimation,
}) : super(repaint: controller.animation) {
// TODO(polina-c): stop duplicating code across disposables
// https://github.com/flutter/flutter/issues/137435
@@ -471,6 +486,7 @@ class _IndicatorPainter extends CustomPainter {
final double? dividerHeight;
final bool showDivider;
final double? devicePixelRatio;
final TabIndicatorAnimation indicatorAnimation;
// _currentTabOffsets and _currentTextDirection are set each time TabBar
// layout is completed. These values can be null when TabBar contains no
@@ -556,10 +572,9 @@ class _IndicatorPainter extends CustomPainter {
final Rect toRect = indicatorRect(size, to);
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
_currentRect = switch (indicatorSize) {
TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!, fromRect),
// Do nothing.
TabBarIndicatorSize.tab => _currentRect,
_currentRect = switch (indicatorAnimation) {
TabIndicatorAnimation.linear => _currentRect,
TabIndicatorAnimation.elastic => _applyElasticEffect(_currentRect!, fromRect),
};
assert(_currentRect != null);
@@ -588,8 +603,8 @@ class _IndicatorPainter extends CustomPainter {
return 1.0 - math.cos((fraction * math.pi) / 2.0);
}
/// Applies the stretch effect to the indicator.
Rect _applyStretchEffect(Rect rect, Rect targetRect) {
/// Applies the elastic effect to the indicator.
Rect _applyElasticEffect(Rect rect, Rect targetRect) {
// If the tab animation is completed, there is no need to stretch the indicator
// This only works for the tab change animation via tab index, not when
// dragging a [TabBarView], but it's still ok, to avoid unnecessary calculations.
@@ -851,6 +866,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
this.splashBorderRadius,
this.tabAlignment,
this.textScaler,
this.indicatorAnimation,
}) : _isPrimary = true,
assert(indicator != null || (indicatorWeight > 0.0));
@@ -903,6 +919,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
this.splashBorderRadius,
this.tabAlignment,
this.textScaler,
this.indicatorAnimation,
}) : _isPrimary = false,
assert(indicator != null || (indicatorWeight > 0.0));
@@ -1248,6 +1265,25 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// * [TextScaler], which is used to scale text based on the device's text scale factor.
final TextScaler? textScaler;
/// Specifies the animation behavior of the tab indicator.
///
/// If this is null, then the value of [TabBarTheme.indicatorAnimation] is used.
/// If that is also null, then the tab indicator will animate linearly if the
/// [indicatorSize] is [TabBarIndicatorSize.tab], otherwise it will animate
/// with an elastic effect if the [indicatorSize] is [TabBarIndicatorSize.label].
///
/// {@tool dartpad}
/// This sample shows how to customize the animation behavior of the tab indicator
/// by using the [indicatorAnimation] property.
///
/// ** See code in examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [TabIndicatorAnimation], which specifies the animation behavior of the tab indicator.
final TabIndicatorAnimation? indicatorAnimation;
/// A size whose height depends on if the tabs have both icons and text.
///
/// [AppBar] uses this size to compute its own preferred size.
@@ -1426,6 +1462,11 @@ class _TabBarState extends State<TabBar> {
final _IndicatorPainter? oldPainter = _indicatorPainter;
final TabIndicatorAnimation defaultTabIndicatorAnimation = switch (indicatorSize) {
TabBarIndicatorSize.label => TabIndicatorAnimation.elastic,
TabBarIndicatorSize.tab => TabIndicatorAnimation.linear,
};
_indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
controller: _controller!,
indicator: _getIndicator(indicatorSize),
@@ -1439,6 +1480,7 @@ class _TabBarState extends State<TabBar> {
dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight,
showDivider: theme.useMaterial3 && !widget.isScrollable,
devicePixelRatio: MediaQuery.devicePixelRatioOf(context),
indicatorAnimation: widget.indicatorAnimation ?? tabBarTheme.indicatorAnimation ?? defaultTabIndicatorAnimation,
);
oldPainter?.dispose();
@@ -1471,7 +1513,8 @@ class _TabBarState extends State<TabBar> {
widget.indicatorPadding != oldWidget.indicatorPadding ||
widget.indicator != oldWidget.indicator ||
widget.dividerColor != oldWidget.dividerColor ||
widget.dividerHeight != oldWidget.dividerHeight) {
widget.dividerHeight != oldWidget.dividerHeight||
widget.indicatorAnimation != oldWidget.indicatorAnimation) {
_initIndicatorPainter();
}

View File

@@ -7,12 +7,16 @@
@Tags(<String>['reduced-test-set'])
library;
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'tabs_utils.dart';
const String _tab1Text = 'tab 1';
const String _tab2Text = 'tab 2';
const String _tab3Text = 'tab 3';
@@ -103,6 +107,8 @@ void main() {
expect(const TabBarTheme().splashFactory, null);
expect(const TabBarTheme().mouseCursor, null);
expect(const TabBarTheme().tabAlignment, null);
expect(const TabBarTheme().textScaler, null);
expect(const TabBarTheme().indicatorAnimation, null);
});
test('TabBarTheme lerp special cases', () {
@@ -1584,4 +1590,114 @@ void main() {
labelSize = tester.getSize(find.text('Tab 1'));
expect(labelSize, equals(const Size(140.5, 40.0)));
}, skip: isBrowser && !isSkiaWeb); // https://github.com/flutter/flutter/issues/87543
testWidgets('TabBarTheme indicatorAnimation can customize tab indicator animation', (WidgetTester tester) async {
const double indicatorWidth = 50.0;
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(
key: ValueKey<int>(index),
child: const SizedBox(width: indicatorWidth),
);
});
final TabController controller = createTabController(
vsync: const TestVSync(),
length: tabs.length,
);
Widget buildTab({ TabIndicatorAnimation? indicatorAnimation }) {
return MaterialApp(
theme: ThemeData(
tabBarTheme: TabBarTheme(
indicatorAnimation: indicatorAnimation,
),
),
home: Material(
child: Container(
alignment: Alignment.topLeft,
child: TabBar(
controller: controller,
tabs: tabs,
),
),
),
);
}
// Test tab indicator animation with TabIndicatorAnimation.linear.
await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.linear));
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
// Idle at tab 0.
expect(
tabBarBox,
paints..rrect(rrect: RRect.fromLTRBAndCorners(
75.0, 45.0, 125.0, 48.0,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
),
));
// Start moving tab indicator.
controller.offset = 0.2;
await tester.pump();
expect(
tabBarBox,
paints..rrect(rrect: RRect.fromLTRBAndCorners(
115.0, 45.0, 165.0, 48.0,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
),
));
// Reset tab controller offset.
controller.offset = 0.0;
// Test tab indicator animation with TabIndicatorAnimation.elastic.
await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic));
await tester.pumpAndSettle();
// Ease in sine (accelerating).
double accelerateIntepolation(double fraction) {
return 1.0 - math.cos((fraction * math.pi) / 2.0);
}
void expectIndicatorAttrs(
RenderBox tabBarBox, {
required Rect rect,
required Rect targetRect,
}) {
const double indicatorWeight = 3.0;
final double tabChangeProgress = (controller.index - controller.animation!.value).abs();
final double leftFraction = accelerateIntepolation(tabChangeProgress);
final double rightFraction = accelerateIntepolation(tabChangeProgress);
final RRect rrect = RRect.fromLTRBAndCorners(
lerpDouble(rect.left, targetRect.left, leftFraction)!,
tabBarBox.size.height - indicatorWeight,
lerpDouble(rect.right, targetRect.right, rightFraction)!,
tabBarBox.size.height,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
);
expect(tabBarBox, paints..rrect(rrect: rrect));
}
Rect rect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
Rect targetRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
// Idle at tab 0.
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
// Start moving tab indicator.
controller.offset = 0.2;
await tester.pump();
rect = const Rect.fromLTRB(115.0, 0.0, 165.0, 48.0);
targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
});
}

View File

@@ -7294,4 +7294,110 @@ void main() {
));
expect(tester.getSize(find.byType(TabBar)).width, 800.0);
});
testWidgets('TabBar.indicatorAnimation can customize tab indicator animation', (WidgetTester tester) async {
const double indicatorWidth = 50.0;
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(
key: ValueKey<int>(index),
child: const SizedBox(width: indicatorWidth),
);
});
final TabController controller = createTabController(
vsync: const TestVSync(),
length: tabs.length,
);
Widget buildTab({ TabIndicatorAnimation? indicatorAnimation }) {
return MaterialApp(
home: boilerplate(
child: Container(
alignment: Alignment.topLeft,
child: TabBar(
indicatorAnimation: indicatorAnimation,
controller: controller,
tabs: tabs,
),
),
),
);
}
// Test tab indicator animation with TabIndicatorAnimation.linear.
await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.linear));
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
// Idle at tab 0.
expect(
tabBarBox,
paints..rrect(rrect: RRect.fromLTRBAndCorners(
75.0, 45.0, 125.0, 48.0,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
),
));
// Start moving tab indicator.
controller.offset = 0.2;
await tester.pump();
expect(
tabBarBox,
paints..rrect(rrect: RRect.fromLTRBAndCorners(
115.0, 45.0, 165.0, 48.0,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
),
));
// Reset tab controller offset.
controller.offset = 0.0;
// Test tab indicator animation with TabIndicatorAnimation.elastic.
await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic));
await tester.pumpAndSettle();
// Ease in sine (accelerating).
double accelerateIntepolation(double fraction) {
return 1.0 - math.cos((fraction * math.pi) / 2.0);
}
void expectIndicatorAttrs(
RenderBox tabBarBox, {
required Rect rect,
required Rect targetRect,
}) {
const double indicatorWeight = 3.0;
final double tabChangeProgress = (controller.index - controller.animation!.value).abs();
final double leftFraction = accelerateIntepolation(tabChangeProgress);
final double rightFraction = accelerateIntepolation(tabChangeProgress);
final RRect rrect = RRect.fromLTRBAndCorners(
lerpDouble(rect.left, targetRect.left, leftFraction)!,
tabBarBox.size.height - indicatorWeight,
lerpDouble(rect.right, targetRect.right, rightFraction)!,
tabBarBox.size.height,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
);
expect(tabBarBox, paints..rrect(rrect: rrect));
}
Rect rect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
Rect targetRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0);
// Idle at tab 0.
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
// Start moving tab indicator.
controller.offset = 0.2;
await tester.pump();
rect = const Rect.fromLTRB(115.0, 0.0, 165.0, 48.0);
targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0);
expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect);
});
}