mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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:
parent
14d4abbd66
commit
2b34d78c23
@ -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));
|
||||
});
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user