From de2dbf4eae2f53d1c6e7e3270e3537b66321d095 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Fri, 12 Jul 2024 12:37:29 +0300 Subject: [PATCH] Fix `TabBar` tab indicator stretch effect (#150868) fixes [[Material3] TabBar indicator stretch effect behaving weirdly with long tabs](https://github.com/flutter/flutter/issues/149662) This PR fixes the tab indicator stretch effect, it currently stretches from both sides. After the fix, tab indicator stretches depending on next size of the indicator and direction of the scroll. The fix is based on Android Components implementation of the elastic/stretch effect. https://github.com/material-components/material-components-android/blob/20f92dfb513916d0df6c38bfd630dfc41aff484e/lib/java/com/google/android/material/tabs/ElasticTabIndicatorInterpolator.java#L46-L78 ### Code sample
expand to view the code sample ```dart import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: ScrollConfiguration( behavior: ScrollConfiguration.of(context) .copyWith(dragDevices: { PointerDeviceKind.touch, PointerDeviceKind.mouse, }), 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 https://github.com/flutter/flutter/assets/48603081/618d0ba9-40a5-458f-9fdc-5330505a6711 ### After https://github.com/flutter/flutter/assets/48603081/b7fa851e-e7a6-4b66-b77d-f88c7f4381da --- packages/flutter/lib/src/material/tabs.dart | 67 +++++++------------ packages/flutter/test/material/tabs_test.dart | 39 ++++++++--- 2 files changed, 55 insertions(+), 51 deletions(-) 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 {