diff --git a/examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart b/examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart new file mode 100644 index 0000000000..a7365bf142 --- /dev/null +++ b/examples/api/lib/material/tabs/tab_bar.indicator_animation.0.dart @@ -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 createState() => _IndicatorAnimationExampleState(); +} + +class _IndicatorAnimationExampleState extends State { + Set _animationStyleSelection = {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 [ + 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: [ + const SizedBox(height: 16), + SegmentedButton( + selected: _animationStyleSelection, + onSelectionChanged: (Set styles) { + setState(() { + _animationStyleSelection = styles; + _tabIndicatorAnimation = styles.first; + }); + }, + segments: indicatorAnimationSegments + .map>(((TabIndicatorAnimation, String) shirt) { + return ButtonSegment(value: shirt.$1, label: Text(shirt.$2)); + }) + .toList(), + ), + const SizedBox(height: 16), + const Expanded( + child: TabBarView( + children: [ + 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'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/material/tabs/tab_bar.indicator_animation.0_test.dart b/examples/api/test/material/tabs/tab_bar.indicator_animation.0_test.dart new file mode 100644 index 0000000000..acae7fbd73 --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.indicator_animation.0_test.dart @@ -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(find.byType(TabBar)); + + late RRect indicatorRRect; + + expect(tabBarBox, paints..something((Symbol method, List 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 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 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 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)); + }); +} diff --git a/packages/flutter/lib/src/material/tab_bar_theme.dart b/packages/flutter/lib/src/material/tab_bar_theme.dart index f04e500142..64f95bf4fc 100644 --- a/packages/flutter/lib/src/material/tab_bar_theme.dart +++ b/packages/flutter/lib/src/material/tab_bar_theme.dart @@ -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, 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; } } diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index c1c3226ce9..64c05339d6 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -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 { 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 { 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 { 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(); } diff --git a/packages/flutter/test/material/tab_bar_theme_test.dart b/packages/flutter/test/material/tab_bar_theme_test.dart index 2fda5436dc..47c420b6df 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -7,12 +7,16 @@ @Tags(['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 tabs = List.generate(4, (int index) { + return Tab( + key: ValueKey(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(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); + }); } diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index a829cac2ce..204f47428f 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -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 tabs = List.generate(4, (int index) { + return Tab( + key: ValueKey(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(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); + }); }