From b453fc90da7396907a96c94829a3af3976e12281 Mon Sep 17 00:00:00 2001 From: matasb-google Date: Thu, 3 Sep 2026 08:20:35 +0000 Subject: [PATCH 1/2] [material_ui] Ensure textScaler is taken into account for SnackBar action overflow Port of https://github.com/flutter/flutter/pull/181959 to flutter/packages as part of the Material decoupling effort (flutter/flutter#188444). - Ensure MediaQuery's textScaler is passed to actionTextPainter so that action button width calculation scales appropriately. - Remove SizedBox(width: snackBarWidth * 0.4) when action overflows, eliminating unintended blank space on the right of multi-line content. - Add unit tests verifying textScaler overflow calculation and layout. - Add pending changelog entry. --- packages/material_ui/lib/src/snack_bar.dart | 8 +- .../change_snack_bar_text_scaler.yaml | 3 + packages/material_ui/test/snack_bar_test.dart | 97 +++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 packages/material_ui/pending_changelogs/change_snack_bar_text_scaler.yaml diff --git a/packages/material_ui/lib/src/snack_bar.dart b/packages/material_ui/lib/src/snack_bar.dart index 9a7180d86c63..9ea49ecf52c2 100644 --- a/packages/material_ui/lib/src/snack_bar.dart +++ b/packages/material_ui/lib/src/snack_bar.dart @@ -721,9 +721,9 @@ class _SnackBarState extends State { icon: const Icon(Icons.close), iconSize: 24.0, color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, - onPressed: () => ScaffoldMessenger.of( - context, - ).hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), + onPressed: () => + ScaffoldMessenger.of(context) + .hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, ) : null; @@ -736,6 +736,7 @@ class _SnackBarState extends State { ), maxLines: 1, textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context), )..layout(); final double actionAndIconWidth = actionTextPainter.size.width + @@ -793,7 +794,6 @@ class _SnackBarState extends State { ), ), if (!willOverflowAction) ...maybeActionAndIcon, - if (willOverflowAction) SizedBox(width: snackBarWidth * 0.4), ], ), if (willOverflowAction) diff --git a/packages/material_ui/pending_changelogs/change_snack_bar_text_scaler.yaml b/packages/material_ui/pending_changelogs/change_snack_bar_text_scaler.yaml new file mode 100644 index 000000000000..6ab87e54149a --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_snack_bar_text_scaler.yaml @@ -0,0 +1,3 @@ +changelog: | + - Accounts for `MediaQuery`'s `TextScaler` when calculating `SnackBar` action overflow and removes unnecessary blank right spacing in multi-line layout. +version: patch diff --git a/packages/material_ui/test/snack_bar_test.dart b/packages/material_ui/test/snack_bar_test.dart index 6949ee8bee9d..870e2dfabb7e 100644 --- a/packages/material_ui/test/snack_bar_test.dart +++ b/packages/material_ui/test/snack_bar_test.dart @@ -4445,6 +4445,103 @@ Future _testSnackBarDismiss({ expect(find.text('bar2'), findsNothing); } } + + testWidgets('SnackBar action overflow calculation respects MediaQuery textScaler', ( + WidgetTester tester, + ) async { + Widget buildSnackBar({required TextScaler textScaler}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: const Size(400, 800), textScaler: textScaler), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Message'), + action: SnackBarAction(label: 'Action Label', onPressed: () {}), + ), + ); + }, + child: const Text('Show'), + ); + }, + ), + ), + ), + ); + } + + // With no scaling, the action label fits on the same row as content. + await tester.pumpWidget(buildSnackBar(textScaler: TextScaler.noScaling)); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + + final Offset actionTopLeft1 = tester.getTopLeft(find.text('Action Label')); + // Action and content are on the same line (action is not below content). + expect(actionTopLeft1.dy, lessThanOrEqualTo(tester.getBottomLeft(find.text('Message')).dy)); + + ScaffoldMessenger.of(tester.element(find.text('Show'))).clearSnackBars(); + await tester.pumpAndSettle(); + + // With a large text scaler, the action button width is scaled up and overflows + // to a separate row below the content. + await tester.pumpWidget(buildSnackBar(textScaler: const TextScaler.linear(3.0))); + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft2 = tester.getBottomLeft(find.text('Message')); + final Offset actionTopLeft2 = tester.getTopLeft(find.text('Action Label')); + // Action overflows and is positioned below the content text. + expect(actionTopLeft2.dy, greaterThanOrEqualTo(contentBottomLeft2.dy)); + }); + + testWidgets('SnackBar does not allocate 40% empty space on right when action overflows', ( + WidgetTester tester, + ) async { + const double screenWidth = 500.0; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(screenWidth, 800)), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Multi-line content that wraps'), + action: SnackBarAction(label: 'Overflow Action', onPressed: () {}), + actionOverflowThreshold: 0.1, + ), + ); + }, + child: const Text('Show'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + + // Verify there is no SizedBox with width equal to 40% of snackBarWidth. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && + widget.width != null && + (widget.width! - screenWidth * 0.4).abs() < 1.0, + ), + findsNothing, + ); + }); } /// Create drag gestures for DismissDirections. From 88772d61618e59639d8c30529a334ce9fde66b60 Mon Sep 17 00:00:00 2001 From: matasb-google Date: Wed, 9 Sep 2026 08:02:02 +0000 Subject: [PATCH 2/2] Address review comments: move tests to main, fix scale sizing, retain horizontalPadding --- packages/material_ui/lib/src/snack_bar.dart | 7 +- packages/material_ui/test/snack_bar_test.dart | 162 +++++++++--------- 2 files changed, 88 insertions(+), 81 deletions(-) diff --git a/packages/material_ui/lib/src/snack_bar.dart b/packages/material_ui/lib/src/snack_bar.dart index 9ea49ecf52c2..de455bc615d4 100644 --- a/packages/material_ui/lib/src/snack_bar.dart +++ b/packages/material_ui/lib/src/snack_bar.dart @@ -721,9 +721,9 @@ class _SnackBarState extends State { icon: const Icon(Icons.close), iconSize: 24.0, color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, - onPressed: () => - ScaffoldMessenger.of(context) - .hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), + onPressed: () => ScaffoldMessenger.of( + context, + ).hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, ) : null; @@ -794,6 +794,7 @@ class _SnackBarState extends State { ), ), if (!willOverflowAction) ...maybeActionAndIcon, + if (willOverflowAction) SizedBox(width: horizontalPadding), ], ), if (willOverflowAction) diff --git a/packages/material_ui/test/snack_bar_test.dart b/packages/material_ui/test/snack_bar_test.dart index 870e2dfabb7e..5ecabdce7e92 100644 --- a/packages/material_ui/test/snack_bar_test.dart +++ b/packages/material_ui/test/snack_bar_test.dart @@ -4397,54 +4397,6 @@ void main() { ); expect(tester.getSize(find.byType(SnackBarAction)), Size.zero); }); -} - -/// Start test for "SnackBar dismiss test". -Future _testSnackBarDismiss({ - required WidgetTester tester, - required Key tapTarget, - required double scaffoldWidth, - required ValueChanged onDismissDirectionChange, - required VoidCallback onDragGestureChange, -}) async { - final Map> dragGestures = _getDragGesturesOfDismissDirections( - scaffoldWidth, - ); - - for (final DismissDirection key in dragGestures.keys) { - onDismissDirectionChange(key); - - for (final Offset dragGesture in dragGestures[key]!) { - onDragGestureChange(); - - expect(find.text('bar1'), findsNothing); - expect(find.text('bar2'), findsNothing); - await tester.tap(find.byKey(tapTarget)); // queue bar1 - await tester.tap(find.byKey(tapTarget)); // queue bar2 - expect(find.text('bar1'), findsNothing); - expect(find.text('bar2'), findsNothing); - await tester.pump(); // schedule animation for bar1 - expect(find.text('bar1'), findsOneWidget); - expect(find.text('bar2'), findsNothing); - await tester.pump(); // begin animation - expect(find.text('bar1'), findsOneWidget); - expect(find.text('bar2'), findsNothing); - await tester.pump( - const Duration(milliseconds: 750), - ); // 0.75s // animation last frame; two second timer starts here - await tester.drag(find.text('bar1'), dragGesture); - await tester.pump(); // bar1 dismissed, bar2 begins animating - expect(find.text('bar1'), findsNothing); - expect(find.text('bar2'), findsOneWidget); - await tester.pump( - const Duration(milliseconds: 750), - ); // 0.75s // animation last frame; two second timer starts here - await tester.drag(find.text('bar2'), dragGesture); - await tester.pump(); // bar2 dismissed - expect(find.text('bar1'), findsNothing); - expect(find.text('bar2'), findsNothing); - } - } testWidgets('SnackBar action overflow calculation respects MediaQuery textScaler', ( WidgetTester tester, @@ -4452,7 +4404,7 @@ Future _testSnackBarDismiss({ Widget buildSnackBar({required TextScaler textScaler}) { return MaterialApp( home: MediaQuery( - data: MediaQueryData(size: const Size(400, 800), textScaler: textScaler), + data: MediaQueryData(size: const Size(800, 600), textScaler: textScaler), child: Scaffold( body: Builder( builder: (BuildContext context) { @@ -4502,27 +4454,28 @@ Future _testSnackBarDismiss({ WidgetTester tester, ) async { const double screenWidth = 500.0; + tester.view.physicalSize = const Size(screenWidth, 800.0); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget( MaterialApp( - home: MediaQuery( - data: const MediaQueryData(size: Size(screenWidth, 800)), - child: Scaffold( - body: Builder( - builder: (BuildContext context) { - return GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Multi-line content that wraps'), - action: SnackBarAction(label: 'Overflow Action', onPressed: () {}), - actionOverflowThreshold: 0.1, - ), - ); - }, - child: const Text('Show'), - ); - }, - ), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const SizedBox(key: Key('content'), height: 20), + action: SnackBarAction(label: 'Overflow Action', onPressed: () {}), + actionOverflowThreshold: 0.1, + ), + ); + }, + child: const Text('Show'), + ); + }, ), ), ), @@ -4531,19 +4484,72 @@ Future _testSnackBarDismiss({ await tester.tap(find.text('Show')); await tester.pumpAndSettle(); - // Verify there is no SizedBox with width equal to 40% of snackBarWidth. - expect( - find.byWidgetPredicate( - (Widget widget) => - widget is SizedBox && - widget.width != null && - (widget.width! - screenWidth * 0.4).abs() < 1.0, - ), - findsNothing, - ); + // In unfixed code, when the action overflowed to a second row, a SizedBox(width: snackBarWidth * 0.4) + // was placed next to the content Expanded widget in the first row, reserving 40% empty space on the right (188px). + // The content is now given standard horizontalPadding on the right (24px) instead of 40% empty space. + // For screenWidth = 500, horizontalPadding = 24 on start and end, the content width is 452px (500 - 24 - 24). + expect(tester.getSize(find.byKey(const Key('content'))).width, 452.0); + expect(tester.getTopRight(find.byKey(const Key('content'))).dx, screenWidth - 24.0); + + // Verify the spacer SizedBox has width equal to horizontalPadding (24.0), not 40% of snackBarWidth (188.0). + final Row contentRow = tester.widget( + find.descendant(of: find.byType(SnackBar), matching: find.byType(Row)).first, + ); + expect(contentRow.children.length, 2); + expect(contentRow.children.first, isA()); + expect(contentRow.children.last, isA()); + expect((contentRow.children.last as SizedBox).width, 24.0); }); } +/// Start test for "SnackBar dismiss test". +Future _testSnackBarDismiss({ + required WidgetTester tester, + required Key tapTarget, + required double scaffoldWidth, + required ValueChanged onDismissDirectionChange, + required VoidCallback onDragGestureChange, +}) async { + final Map> dragGestures = _getDragGesturesOfDismissDirections( + scaffoldWidth, + ); + + for (final DismissDirection key in dragGestures.keys) { + onDismissDirectionChange(key); + + for (final Offset dragGesture in dragGestures[key]!) { + onDragGestureChange(); + + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.tap(find.byKey(tapTarget)); // queue bar1 + await tester.tap(find.byKey(tapTarget)); // queue bar2 + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // schedule animation for bar1 + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('bar1'), findsOneWidget); + expect(find.text('bar2'), findsNothing); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + await tester.drag(find.text('bar1'), dragGesture); + await tester.pump(); // bar1 dismissed, bar2 begins animating + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsOneWidget); + await tester.pump( + const Duration(milliseconds: 750), + ); // 0.75s // animation last frame; two second timer starts here + await tester.drag(find.text('bar2'), dragGesture); + await tester.pump(); // bar2 dismissed + expect(find.text('bar1'), findsNothing); + expect(find.text('bar2'), findsNothing); + } + } +} + /// Create drag gestures for DismissDirections. Map> _getDragGesturesOfDismissDirections(double scaffoldWidth) { final dragGestures = >{};