From 2b34d78c23314d8f59d7d74f5891eea4fe9a2546 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Wed, 15 Jan 2025 13:23:41 +0200 Subject: [PATCH] Fix `TabBar` glitchy elastic `Tab` animation (#161514) Fixes [M3 TabBar indicator animation broken both when swiping or tapping](https://github.com/flutter/flutter/issues/160631) ### Description This refactors the elastic `Tab` animation. Added additional tests that follows the elastic animation frame by frame and generates a golden file. ### Code Sample
expand to view the code sample ```dart import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { // timeDilation = 10; return MaterialApp( home: ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(dragDevices: { PointerDeviceKind.touch, PointerDeviceKind.mouse, }), child: Directionality( textDirection: TextDirection.ltr, child: DefaultTabController( length: 8, child: Scaffold( appBar: AppBar( bottom: const TabBar( isScrollable: true, tabAlignment: TabAlignment.start, tabs: [ 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: [ 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')), ], ), ), ), ), ), ); } } ```
### Before (`timeDilation = 10`) https://github.com/user-attachments/assets/4f69f94b-0bcf-4813-b49f-06ff411435ca ### After (`timeDilation = 10`) https://github.com/user-attachments/assets/65801c1c-d28f-4b42-870a-7140d5d3c4c3 | Before Test Results | After Test Results | | --------------- | --------------- | | | | ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../tab_bar.indicator_animation.0_test.dart | 4 +- packages/flutter/lib/src/material/tabs.dart | 90 ++-- .../test/material/tab_bar_theme_test.dart | 48 +-- packages/flutter/test/material/tabs_test.dart | 388 ++++++++++++++---- .../flutter/test/material/tabs_utils.dart | 35 ++ 5 files changed, 435 insertions(+), 130 deletions(-) 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 index 4cf5e41f72..ed2860953e 100644 --- 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 @@ -89,9 +89,9 @@ void main() { return true; }), ); - expect(indicatorRRect.left, closeTo(51.0, 0.1)); + expect(indicatorRRect.left, closeTo(76.7, 0.1)); expect(indicatorRRect.top, equals(45.0)); - expect(indicatorRRect.right, closeTo(221.4, 0.1)); + expect(indicatorRRect.right, closeTo(423.1, 0.1)); expect(indicatorRRect.bottom, equals(48.0)); }); } diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index d200680a40..77d66ba977 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -481,6 +481,7 @@ class _IndicatorPainter extends CustomPainter { required this.showDivider, this.devicePixelRatio, required this.indicatorAnimation, + required this.textDirection, }) : super(repaint: controller.animation) { // TODO(polina-c): stop duplicating code across disposables // https://github.com/flutter/flutter/issues/137435 @@ -507,6 +508,7 @@ class _IndicatorPainter extends CustomPainter { final bool showDivider; final double? devicePixelRatio; final TabIndicatorAnimation indicatorAnimation; + final TextDirection textDirection; // _currentTabOffsets and _currentTextDirection are set each time TabBar // layout is completed. These values can be null when TabBar contains no @@ -583,18 +585,28 @@ class _IndicatorPainter extends CustomPainter { _needsPaint = false; _painter ??= indicator.createBoxPainter(markNeedsPaint); - final double index = controller.index.toDouble(); final double value = controller.animation!.value; - final bool ltr = index > value; - final int from = (ltr ? value.floor() : value.ceil()).clamp(0, maxTabIndex); - final int to = (ltr ? from + 1 : from - 1).clamp(0, maxTabIndex); - final Rect fromRect = indicatorRect(size, from); + final int to = + controller.indexIsChanging + ? controller.index + : switch (textDirection) { + TextDirection.ltr => value.ceil(), + TextDirection.rtl => value.floor(), + }.clamp(0, maxTabIndex); + final int from = + controller.indexIsChanging + ? controller.previousIndex + : switch (textDirection) { + TextDirection.ltr => (to - 1), + TextDirection.rtl => (to + 1), + }.clamp(0, maxTabIndex); final Rect toRect = indicatorRect(size, to); + final Rect fromRect = indicatorRect(size, from); _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs()); _currentRect = switch (indicatorAnimation) { TabIndicatorAnimation.linear => _currentRect, - TabIndicatorAnimation.elastic => _applyElasticEffect(_currentRect!, fromRect), + TabIndicatorAnimation.elastic => _applyElasticEffect(fromRect, toRect, _currentRect!), }; assert(_currentRect != null); @@ -627,40 +639,69 @@ class _IndicatorPainter extends CustomPainter { } /// Applies the elastic effect to the indicator. - Rect _applyElasticEffect(Rect rect, Rect targetRect) { + Rect _applyElasticEffect(Rect fromRect, Rect toRect, Rect currentRect) { // 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. if (controller.animation!.isCompleted) { - return rect; + return currentRect; } final double index = controller.index.toDouble(); final double value = controller.animation!.value; - final double tabChangeProgress = (index - value).abs(); + final double tabChangeProgress; + + if (controller.indexIsChanging) { + double progressLeft = (index - value).abs(); + final int tabsDelta = (controller.index - controller.previousIndex).abs(); + if (tabsDelta != 0) { + progressLeft /= tabsDelta; + } + tabChangeProgress = 1 - clampDouble(progressLeft, 0.0, 1.0); + } else { + tabChangeProgress = (index - value).abs(); + } // If the animation has finished, there is no need to apply the stretch effect. if (tabChangeProgress == 1.0) { - return rect; + return currentRect; } - final double fraction = switch (rect.left < targetRect.left) { - true => accelerateInterpolation(tabChangeProgress), - false => decelerateInterpolation(tabChangeProgress), + final double leftFraction; + final double rightFraction; + final bool isMovingRight = switch (textDirection) { + TextDirection.ltr => controller.indexIsChanging ? index > value : value > index, + TextDirection.rtl => controller.indexIsChanging ? value > index : index > value, }; + if (isMovingRight) { + leftFraction = accelerateInterpolation(tabChangeProgress); + rightFraction = decelerateInterpolation(tabChangeProgress); + } else { + leftFraction = decelerateInterpolation(tabChangeProgress); + rightFraction = accelerateInterpolation(tabChangeProgress); + } - final Rect stretchedRect = _inflateRectHorizontally(rect, targetRect, fraction); - return stretchedRect; - } + final double lerpRectLeft; + final double lerpRectRight; - /// Same as [Rect.inflate], but only inflates in the horizontal direction. - Rect _inflateRectHorizontally(Rect rect, Rect targetRect, double fraction) { - return Rect.fromLTRB( - lerpDouble(rect.left, targetRect.left, fraction)!, - rect.top, - lerpDouble(rect.right, targetRect.right, fraction)!, - rect.bottom, - ); + // The controller.indexIsChanging is true when the Tab is pressed, instead of swipe to change tabs. + // If the tab is pressed then only lerp between fromRect and toRect. + if (controller.indexIsChanging) { + lerpRectLeft = lerpDouble(fromRect.left, toRect.left, leftFraction)!; + lerpRectRight = lerpDouble(fromRect.right, toRect.right, rightFraction)!; + } else { + // Switch the Rect left and right lerp order based on swipe direction. + lerpRectLeft = switch (isMovingRight) { + true => lerpDouble(fromRect.left, toRect.left, leftFraction)!, + false => lerpDouble(toRect.left, fromRect.left, leftFraction)!, + }; + lerpRectRight = switch (isMovingRight) { + true => lerpDouble(fromRect.right, toRect.right, rightFraction)!, + false => lerpDouble(toRect.right, fromRect.right, rightFraction)!, + }; + } + + return Rect.fromLTRB(lerpRectLeft, currentRect.top, lerpRectRight, currentRect.bottom); } @override @@ -1517,6 +1558,7 @@ class _TabBarState extends State { widget.indicatorAnimation ?? tabBarTheme.indicatorAnimation ?? defaultTabIndicatorAnimation, + textDirection: Directionality.of(context), ); oldPainter?.dispose(); diff --git a/packages/flutter/test/material/tab_bar_theme_test.dart b/packages/flutter/test/material/tab_bar_theme_test.dart index 9ac21d094d..53b559b056 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -7,7 +7,6 @@ @Tags(['reduced-test-set']) library; -import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -1700,42 +1699,27 @@ void main() { await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic)); await tester.pumpAndSettle(); - // Ease in sine (accelerating). - double accelerateInterpolation(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 = accelerateInterpolation(tabChangeProgress); - final double rightFraction = accelerateInterpolation(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); + const Rect currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const Rect fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + Rect toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0), + ), + ); // 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); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); }); testWidgets('TabBar inherits splashBorderRadius from theme', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index 80051bad69..2ef8ebd082 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -2724,48 +2728,44 @@ void main() { final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect(tabBarBox.size.height, 48.0); - // Ease in sine (accelerating). - double accelerateInterpolation(double fraction) { - return 1.0 - math.cos((fraction * math.pi) / 2.0); - } + const Rect currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const Rect fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + Rect toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.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 = accelerateInterpolation(tabChangeProgress); - final double rightFraction = accelerateInterpolation(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); - - // Peak stretch at 20%. 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); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); + + controller.offset = 0.5; + await tester.pump(); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.5), + ), + ); - // Idle at tab 1. controller.offset = 1; await tester.pump(); - rect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); - targetRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); - expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect); + // When the animation is completed, no stretch is applied. + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 1.0), + ), + ); }); testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async { @@ -3753,14 +3753,16 @@ void main() { tabBarBox, paints..line( strokeWidth: indicatorWeight, - p1: const Offset(4951.0, indicatorY), - p2: const Offset(5049.0, indicatorY), + // In RTL, the elastic tab animation expands the width of the tab with a negative offset + // when jumping from the first tab to the last tab in a scrollable tab bar. + p1: const Offset(-480149, indicatorY), + p2: const Offset(-480051, indicatorY), ), ); await tester.pump(const Duration(milliseconds: 501)); - // Tab 99 out of 100 selected, appears on the far left because RTL + // Tab 99 out of 100 selected, appears on the far left because RTL. indicatorLeft = indicatorWeight / 2.0; indicatorRight = 100.0 - indicatorWeight / 2.0; expect( @@ -7749,42 +7751,26 @@ void main() { await tester.pumpWidget(buildTab(indicatorAnimation: TabIndicatorAnimation.elastic)); await tester.pumpAndSettle(); - // Ease in sine (accelerating). - double accelerateInterpolation(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 = accelerateInterpolation(tabChangeProgress); - final double rightFraction = accelerateInterpolation(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); + const Rect currentRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + const Rect fromRect = Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + Rect toRect = const Rect.fromLTRB(75.0, 0.0, 125.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.0), + ), + ); - // 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); + toRect = const Rect.fromLTRB(275.0, 0.0, 325.0, 48.0); + expect( + tabBarBox, + paints..rrect( + rrect: tabIndicatorRRectElasticAnimation(tabBarBox, currentRect, fromRect, toRect, 0.2), + ), + ); }); // Regression test for https://github.com/flutter/flutter/issues/155518. @@ -7831,4 +7817,262 @@ void main() { Size(theme.iconTheme.size!, theme.iconTheme.size!), ); }); + + testWidgets('Elastic Tab animation with various size tabs - LTR', (WidgetTester tester) async { + final AnimationSheetBuilder animationSheet = AnimationSheetBuilder( + frameSize: const Size(800, 100), + ); + addTearDown(animationSheet.dispose); + + final List tabs = [ + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget target() { + return animationSheet.record( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + await tester.pumpFrames(target(), const Duration(milliseconds: 50)); + + await tester.tap(find.text('Extremely Very Long Label')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await tester.tap(find.text('C')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await expectLater( + animationSheet.collate(1), + matchesGoldenFile('tab_indicator.elastic_animation.various_size_tabs.ltr.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Elastic Tab animation with various size tabs in a scrollable tab bar - LTR', ( + WidgetTester tester, + ) async { + final AnimationSheetBuilder animationSheet = AnimationSheetBuilder( + frameSize: const Size(800, 100), + ); + addTearDown(animationSheet.dispose); + + final List tabs = [ + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget target() { + return animationSheet.record( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + await tester.pumpFrames(target(), const Duration(milliseconds: 50)); + + controller.animateTo(tabs.length - 1); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + controller.animateTo(0); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await expectLater( + animationSheet.collate(1), + matchesGoldenFile('tab_indicator.elastic_animation.various_size_tabs.scrollable.ltr.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Elastic Tab animation with various size tabs - RTL', (WidgetTester tester) async { + final AnimationSheetBuilder animationSheet = AnimationSheetBuilder( + frameSize: const Size(800, 100), + ); + addTearDown(animationSheet.dispose); + + final List tabs = [ + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget target() { + return animationSheet.record( + boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + await tester.pumpFrames(target(), const Duration(milliseconds: 50)); + + await tester.tap(find.text('Extremely Very Long Label')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await tester.tap(find.text('C')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await expectLater( + animationSheet.collate(1), + matchesGoldenFile('tab_indicator.elastic_animation.various_size_tabs.rtl.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + testWidgets('Elastic Tab animation with various size tabs in a scrollable tab bar - RTL', ( + WidgetTester tester, + ) async { + final AnimationSheetBuilder animationSheet = AnimationSheetBuilder( + frameSize: const Size(800, 100), + ); + addTearDown(animationSheet.dispose); + + final List tabs = [ + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget target() { + return animationSheet.record( + boilerplate( + textDirection: TextDirection.rtl, + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + await tester.pumpFrames(target(), const Duration(milliseconds: 50)); + + controller.animateTo(tabs.length - 1); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + controller.animateTo(0); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await expectLater( + animationSheet.collate(1), + matchesGoldenFile('tab_indicator.elastic_animation.various_size_tabs.scrollable.rtl.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 + + // Regression test for https://github.com/flutter/flutter/issues/160631 + testWidgets('Elastic Tab animation when skipping tabs', (WidgetTester tester) async { + final AnimationSheetBuilder animationSheet = AnimationSheetBuilder( + frameSize: const Size(800, 100), + ); + addTearDown(animationSheet.dispose); + + final List tabs = [ + const Tab(text: 'Medium'), + const Tab(text: 'Extremely Very Long Label'), + const Tab(text: 'C'), + const Tab(text: 'Short'), + ]; + + final TabController controller = createTabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + Widget target() { + return animationSheet.record( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + indicatorAnimation: TabIndicatorAnimation.elastic, + controller: controller, + tabs: tabs, + ), + ), + ), + ); + } + + await tester.pumpFrames(target(), const Duration(milliseconds: 50)); + + await tester.tap(find.text('C')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await tester.tap(find.text('Medium')); + await tester.pumpFrames(target(), const Duration(milliseconds: 500)); + + await expectLater( + animationSheet.collate(1), + matchesGoldenFile('tab_indicator.elastic_animation.skipping_tabs.png'), + ); + }, skip: isBrowser); // https://github.com/flutter/flutter/issues/56001 } diff --git a/packages/flutter/test/material/tabs_utils.dart b/packages/flutter/test/material/tabs_utils.dart index 1928701f65..743b476273 100644 --- a/packages/flutter/test/material/tabs_utils.dart +++ b/packages/flutter/test/material/tabs_utils.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -247,3 +250,35 @@ class TestIndicatorBoxPainter extends BoxPainter { lastConfiguration = configuration; } } + +// Ease out sine (decelerating). +double _decelerateInterpolation(double fraction) { + return math.sin((fraction * math.pi) / 2.0); +} + +// Ease in sine (accelerating). +double _accelerateInterpolation(double fraction) { + return 1.0 - math.cos((fraction * math.pi) / 2.0); +} + +// Returns Tab indicator RRect with elastic animation. +RRect tabIndicatorRRectElasticAnimation( + RenderBox tabBarBox, + Rect currentRect, + Rect fromRect, + Rect toRect, + double progress, +) { + const double indicatorWeight = 3.0; + final double leftFraction = _accelerateInterpolation(progress); + final double rightFraction = _decelerateInterpolation(progress); + + return RRect.fromLTRBAndCorners( + lerpDouble(fromRect.left, toRect.left, leftFraction)!, + tabBarBox.size.height - indicatorWeight, + lerpDouble(fromRect.right, toRect.right, rightFraction)!, + tabBarBox.size.height, + topLeft: const Radius.circular(indicatorWeight), + topRight: const Radius.circular(indicatorWeight), + ); +}