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

<details>
<summary>expand to view the code sample</summary> 

```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>{
          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: <Widget>[
                    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: <Widget>[
                  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')),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

```

</details>

### 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 |
| --------------- | --------------- |
| <img
src="https://github.com/user-attachments/assets/72ae9fbe-fef9-44a0-9b86-5a4c31fd39cf"
/> | <img
src="https://github.com/user-attachments/assets/2545f35e-ac03-495d-a33b-72b9bc71299b"
/> |

## 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].

<!-- Links -->
[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
This commit is contained in:
Taha Tesser 2025-01-15 13:23:41 +02:00 committed by GitHub
parent 14d4abbd66
commit 2b34d78c23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 435 additions and 130 deletions

View File

@ -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));
});
}

View File

@ -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<TabBar> {
widget.indicatorAnimation ??
tabBarTheme.indicatorAnimation ??
defaultTabIndicatorAnimation,
textDirection: Directionality.of(context),
);
oldPainter?.dispose();

View File

@ -7,7 +7,6 @@
@Tags(<String>['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 {

View File

@ -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(<String>['reduced-test-set'])
library;
import 'dart:ui';
import 'package:flutter/foundation.dart';
@ -2724,48 +2728,44 @@ void main() {
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(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<Widget> tabs = <Widget>[
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<Widget> tabs = <Widget>[
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<Widget> tabs = <Widget>[
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<Widget> tabs = <Widget>[
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<Widget> tabs = <Widget>[
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
}

View File

@ -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),
);
}