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:
David Martos 2024-02-06 20:01:49 +01:00 committed by GitHub
parent 0cc381da19
commit 37fd173e03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 2 deletions

View file

@ -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,

View file

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