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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user