diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index cfd4989b5cd..1c38a84551a 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -554,7 +554,7 @@ class _IndicatorPainter extends CustomPainter { _currentRect = Rect.lerp(fromRect, toRect, (value - from).abs()); _currentRect = switch (indicatorSize) { - TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!), + TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!, fromRect), // Do nothing. TabBarIndicatorSize.tab => _currentRect, }; @@ -575,8 +575,18 @@ class _IndicatorPainter extends CustomPainter { _painter!.paint(canvas, _currentRect!.topLeft, 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); + } + /// Applies the stretch effect to the indicator. - Rect _applyStretchEffect(Rect rect) { + Rect _applyStretchEffect(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. @@ -586,57 +596,30 @@ class _IndicatorPainter extends CustomPainter { final double index = controller.index.toDouble(); final double value = controller.animation!.value; - - // The progress of the animation from 0 to 1. - late double tabChangeProgress; - - // If we are changing tabs via index, we want to map the progress between 0 and 1. - 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 { - // Otherwise, the progress is how close we are to the current tab. - tabChangeProgress = (index - value).abs(); - } + final double tabChangeProgress = (index - value).abs(); // If the animation has finished, there is no need to apply the stretch effect. if (tabChangeProgress == 1.0) { return rect; } - // The maximum amount of extra width to add to the indicator. - final double stretchSize = rect.width; + final double fraction = switch (rect.left < targetRect.left) { + true => accelerateInterpolation(tabChangeProgress), + false => decelerateInterpolation(tabChangeProgress), + }; - final double inflationPerSide = stretchSize * _stretchAnimation.transform(tabChangeProgress) / 2; - final Rect stretchedRect = _inflateRectHorizontally(rect, inflationPerSide); + final Rect stretchedRect = _inflateRectHorizontally(rect, targetRect, fraction); return stretchedRect; } - /// The animatable that stretches the indicator horizontally when changing tabs. - /// Value range is from 0 to 1, so we can multiply it by an stretch factor. - /// - /// Animation starts with no stretch, then quickly goes to the max stretch amount - /// and then goes back to no stretch. - late final Animatable _stretchAnimation = TweenSequence( - >[ - TweenSequenceItem( - tween: Tween(begin: 0.0, end: 1.0), - weight: 20, - ), - TweenSequenceItem( - tween: Tween(begin: 1.0, end: 0.0), - weight: 80, - ), - ], - ); - /// Same as [Rect.inflate], but only inflates in the horizontal direction. - Rect _inflateRectHorizontally(Rect r, double delta) { - return Rect.fromLTRB(r.left - delta, r.top, r.right + delta, r.bottom); + 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, + ); } @override diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index 74601f061b6..a829cac2cee 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.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/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -2454,7 +2457,7 @@ void main() { expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, moreOrLessEquals(tabRight)); }); - testWidgets('Material3 - Indicator stretch animation', (WidgetTester tester) async { + testWidgets('Indicator elastic animation', (WidgetTester tester) async { const double indicatorWidth = 50.0; final List tabs = List.generate(4, (int index) { return Tab( @@ -2483,15 +2486,26 @@ void main() { final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect(tabBarBox.size.height, 48.0); - final double tabWidth = tabBarBox.size.width / tabs.length; - void expectIndicatorAttrs(RenderBox tabBarBox, {required double offset, required double width}) { + // 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 centerX = offset * tabWidth + tabWidth / 2; + final double tabChangeProgress = (controller.index - controller.animation!.value).abs(); + final double leftFraction = accelerateIntepolation(tabChangeProgress); + final double rightFraction = accelerateIntepolation(tabChangeProgress); + final RRect rrect = RRect.fromLTRBAndCorners( - centerX - width / 2, + lerpDouble(rect.left, targetRect.left, leftFraction)!, tabBarBox.size.height - indicatorWeight, - centerX + width / 2, + lerpDouble(rect.right, targetRect.right, rightFraction)!, tabBarBox.size.height, topLeft: const Radius.circular(3.0), topRight: const Radius.circular(3.0), @@ -2500,18 +2514,25 @@ void main() { 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, offset: 0.0, width: indicatorWidth); + expectIndicatorAttrs(tabBarBox, rect: rect, targetRect: targetRect); // Peak stretch at 20%. controller.offset = 0.2; await tester.pump(); - expectIndicatorAttrs(tabBarBox, offset: 0.2, width: indicatorWidth * 2); + 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); // Idle at tab 1. controller.offset = 1; await tester.pump(); - expectIndicatorAttrs(tabBarBox, offset: 1, width: indicatorWidth); + 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); }); testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async {