mirror of
https://github.com/flutter/flutter
synced 2024-08-25 02:46:31 +00:00
Material 3 - Tab indicator stretch animation (#141954)
Fixes #128696 (Motion checkbox) This PR updates the Material 3 tab indicator animation, so that it stretches, as it can be seen in the showcase videos in the specification https://m3.material.io/components/tabs/accessibility#13ed756b-fb35-4bb3-ac8c-1157e49031d8 One thing to note is that the Material 3 videos have a tab transition duration of 700 ms, whereas currently in Flutter the duration is 300 ms. I recorded 4 comparison videos to see the difference better (current animation vs stretch animation and 300 ms vs 700 ms) @Piinks You mentioned the other day that the default tab size could be updated in the future to better reflect the new size in M3. Maybe the `kTabScrollDuration` constant is another one that could end up being updated, as 300 ms for this animation feels too fast. Here are the comparison videos (Material 3 spec showcase on the left and Flutter on the right) ## Original animation - 300 ms https://github.com/flutter/flutter/assets/22084723/d5b594fd-52ea-4328-b8e2-ddb597c81f69 ## New animation - 300 ms https://github.com/flutter/flutter/assets/22084723/c822f7ab-3fc4-4403-a53b-872d047f6227 --- ## Original animation - 700 ms https://github.com/flutter/flutter/assets/22084723/fe39a32d-3d10-4c0d-98df-bd5e1c9336d0 ## New animation - 700 ms https://github.com/flutter/flutter/assets/22084723/8d4b0628-6312-40c2-bd99-b4bcb8e23ba9 --- ## Code sample ```dart void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp( debugShowCheckedModeBanner: false, home: TabExample(), ); } } class TabExample extends StatelessWidget { const TabExample({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( initialIndex: 1, length: 3, child: Scaffold( appBar: AppBar( title: const Text('My saved media'), bottom: const TabBar( tabs: <Widget>[ Tab( icon: Icon(Icons.videocam_outlined), text: "Video", ), Tab( icon: Icon(Icons.photo_outlined), text: "Photos", ), Tab( icon: Icon(Icons.audiotrack), text: "Audio", ), ], ), ), body: const TabBarView( children: <Widget>[ Center( child: Text("Tab 1"), ), Center( child: Text("Tab 2"), ), Center( child: Text("Tab 3"), ), ], ), ), ); } } ``` *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
This commit is contained in:
parent
0cc381da19
commit
37fd173e03
|
@ -459,7 +459,7 @@ class _IndicatorPainter extends CustomPainter {
|
|||
|
||||
final TabController controller;
|
||||
final Decoration indicator;
|
||||
final TabBarIndicatorSize? indicatorSize;
|
||||
final TabBarIndicatorSize indicatorSize;
|
||||
final EdgeInsetsGeometry indicatorPadding;
|
||||
final List<GlobalKey> tabKeys;
|
||||
final List<EdgeInsetsGeometry> labelPaddings;
|
||||
|
@ -554,6 +554,13 @@ class _IndicatorPainter extends CustomPainter {
|
|||
final Rect fromRect = indicatorRect(size, from);
|
||||
final Rect toRect = indicatorRect(size, to);
|
||||
_currentRect = Rect.lerp(fromRect, toRect, (value - from).abs());
|
||||
|
||||
_currentRect = switch (indicatorSize) {
|
||||
TabBarIndicatorSize.label => _applyStretchEffect(_currentRect!),
|
||||
// Do nothing.
|
||||
TabBarIndicatorSize.tab => _currentRect,
|
||||
};
|
||||
|
||||
assert(_currentRect != null);
|
||||
|
||||
final ImageConfiguration configuration = ImageConfiguration(
|
||||
|
@ -569,6 +576,70 @@ class _IndicatorPainter extends CustomPainter {
|
|||
_painter!.paint(canvas, _currentRect!.topLeft, configuration);
|
||||
}
|
||||
|
||||
/// Applies the stretch effect to the indicator.
|
||||
Rect _applyStretchEffect(Rect rect) {
|
||||
// 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!.status == AnimationStatus.completed) {
|
||||
return rect;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 inflationPerSide = stretchSize * _stretchAnimation.transform(tabChangeProgress) / 2;
|
||||
final Rect stretchedRect = _inflateRectHorizontally(rect, inflationPerSide);
|
||||
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<double> _stretchAnimation = TweenSequence<double>(
|
||||
<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
weight: 20,
|
||||
),
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(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);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_IndicatorPainter old) {
|
||||
return _needsPaint
|
||||
|
@ -1338,7 +1409,7 @@ class _TabBarState extends State<TabBar> {
|
|||
_indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
|
||||
controller: _controller!,
|
||||
indicator: _getIndicator(indicatorSize),
|
||||
indicatorSize: widget.indicatorSize ?? tabBarTheme.indicatorSize ?? _defaults.indicatorSize!,
|
||||
indicatorSize: indicatorSize,
|
||||
indicatorPadding: widget.indicatorPadding,
|
||||
tabKeys: _tabKeys,
|
||||
old: _indicatorPainter,
|
||||
|
|
|
@ -2557,6 +2557,66 @@ void main() {
|
|||
expect(tester.getTopRight(find.widgetWithText(Tab, 'TAB #19')).dx, moreOrLessEquals(tabRight));
|
||||
});
|
||||
|
||||
testWidgets('Material3 - Indicator stretch animation', (WidgetTester tester) async {
|
||||
const double indicatorWidth = 50.0;
|
||||
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
|
||||
return Tab(
|
||||
key: ValueKey<int>(index),
|
||||
child: const SizedBox(width: indicatorWidth));
|
||||
});
|
||||
|
||||
final TabController controller = _tabController(
|
||||
vsync: const TestVSync(),
|
||||
length: tabs.length,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: boilerplate(
|
||||
child: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: TabBar(
|
||||
controller: controller,
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(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}) {
|
||||
const double indicatorWeight = 3.0;
|
||||
final double centerX = offset * tabWidth + tabWidth / 2;
|
||||
final RRect rrect = RRect.fromLTRBAndCorners(
|
||||
centerX - width / 2,
|
||||
tabBarBox.size.height - indicatorWeight,
|
||||
centerX + width / 2,
|
||||
tabBarBox.size.height,
|
||||
topLeft: const Radius.circular(3.0),
|
||||
topRight: const Radius.circular(3.0),
|
||||
);
|
||||
|
||||
expect(tabBarBox, paints..rrect(rrect: rrect));
|
||||
}
|
||||
|
||||
// Idle at tab 0.
|
||||
expectIndicatorAttrs(tabBarBox, offset: 0.0, width: indicatorWidth);
|
||||
|
||||
// Peak stretch at 20%.
|
||||
controller.offset = 0.2;
|
||||
await tester.pump();
|
||||
expectIndicatorAttrs(tabBarBox, offset: 0.2, width: indicatorWidth * 2);
|
||||
|
||||
// Idle at tab 1.
|
||||
controller.offset = 1;
|
||||
await tester.pump();
|
||||
expectIndicatorAttrs(tabBarBox, offset: 1, width: indicatorWidth);
|
||||
});
|
||||
|
||||
testWidgets('TabBar with indicatorWeight, indicatorPadding (LTR)', (WidgetTester tester) async {
|
||||
const Color indicatorColor = Color(0xFF00FF00);
|
||||
const double indicatorWeight = 8.0;
|
||||
|
|
Loading…
Reference in a new issue