From 7b6af17f6e060244e390826f8c25aaa646661705 Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Sat, 29 Jul 2023 00:00:20 +0200 Subject: [PATCH] Reland - Fix floating SnackBar throws when FAB is on the top (#131475) ## Description This PR is a reland of https://github.com/flutter/flutter/pull/129274 with a fix and new test related to the revert (https://github.com/flutter/flutter/pull/131303). It updates how a floating snack bar is positionned when a `Scaffold` defines a FAB with `Scaffold.floatingActionButtonLocation` sets to one of the top locations. **Before this PR:** - When a FAB location is set to the top of the `Scaffold`, a floating `SnackBar` can't be displayed and an assert throws in debug mode. **After this PR:** - When a FAB location is set to the top of the `Scaffold`, a floating `SnackBar` will be displayed at the bottom of the screen, above a `NavigationBar` for instance (the top FAB is ignored when computing the floating snack bar position). ![image](https://github.com/flutter/flutter/assets/840911/08fcee6c-b286-4749-ad0b-ba09e653bd94) ## Motivation This is a edge case related to a discrepancy between the Material spec and the Flutter `Scaffold` customizability: - Material spec states that a floating `SnackBar` should be displayed above a FAB. But, in Material spec, FABs are expected to be on the bottom. - Since https://github.com/flutter/flutter/issues/51465, Flutter `Scaffold` makes it valid to show a FAB on the top of the `Scaffold`. ## Related Issue fixes https://github.com/flutter/flutter/issues/128150 ## Tests Adds 2 tests. --- .../flutter/lib/src/material/scaffold.dart | 24 +++- .../lib/src/material/snack_bar_theme.dart | 13 +- .../flutter/test/material/snack_bar_test.dart | 126 ++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 110cbbbfb1..f1e820b3bc 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -1142,7 +1142,29 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { } final double snackBarYOffsetBase; - if (floatingActionButtonRect.size != Size.zero && isSnackBarFloating) { + final bool showAboveFab = switch (currentFloatingActionButtonLocation) { + FloatingActionButtonLocation.startTop + || FloatingActionButtonLocation.centerTop + || FloatingActionButtonLocation.endTop + || FloatingActionButtonLocation.miniStartTop + || FloatingActionButtonLocation.miniCenterTop + || FloatingActionButtonLocation.miniEndTop => false, + FloatingActionButtonLocation.startDocked + || FloatingActionButtonLocation.startFloat + || FloatingActionButtonLocation.centerDocked + || FloatingActionButtonLocation.centerFloat + || FloatingActionButtonLocation.endContained + || FloatingActionButtonLocation.endDocked + || FloatingActionButtonLocation.endFloat + || FloatingActionButtonLocation.miniStartDocked + || FloatingActionButtonLocation.miniStartFloat + || FloatingActionButtonLocation.miniCenterDocked + || FloatingActionButtonLocation.miniCenterFloat + || FloatingActionButtonLocation.miniEndDocked + || FloatingActionButtonLocation.miniEndFloat => true, + FloatingActionButtonLocation() => true, + }; + if (floatingActionButtonRect.size != Size.zero && isSnackBarFloating && showAboveFab) { snackBarYOffsetBase = floatingActionButtonRect.top; } else { // SnackBarBehavior.fixed applies a SafeArea automatically. diff --git a/packages/flutter/lib/src/material/snack_bar_theme.dart b/packages/flutter/lib/src/material/snack_bar_theme.dart index 5f976148d7..faf8ea72fe 100644 --- a/packages/flutter/lib/src/material/snack_bar_theme.dart +++ b/packages/flutter/lib/src/material/snack_bar_theme.dart @@ -17,14 +17,17 @@ enum SnackBarBehavior { /// Fixes the [SnackBar] at the bottom of the [Scaffold]. /// /// The exception is that the [SnackBar] will be shown above a - /// [BottomNavigationBar]. Additionally, the [SnackBar] will cause other - /// non-fixed widgets inside [Scaffold] to be pushed above (for example, the - /// [FloatingActionButton]). + /// [BottomNavigationBar] or a [NavigationBar]. Additionally, the [SnackBar] + /// will cause other non-fixed widgets inside [Scaffold] to be pushed above + /// (for example, the [FloatingActionButton]). fixed, /// This behavior will cause [SnackBar] to be shown above other widgets in the - /// [Scaffold]. This includes being displayed above a [BottomNavigationBar] - /// and a [FloatingActionButton]. + /// [Scaffold]. This includes being displayed above a [BottomNavigationBar] or + /// a [NavigationBar], and a [FloatingActionButton] when its location is on the + /// bottom. When the floating action button location is on the top, this behavior + /// will cause the [SnackBar] to be shown above other widgets in the [Scaffold] + /// except the floating action button. /// /// See for more details. floating, diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index 9436f743b4..9720578de9 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -2071,6 +2071,127 @@ void main() { }, ); + testWidgets( + '${SnackBarBehavior.floating} should not align SnackBar with the top of FloatingActionButton ' + 'when Scaffold has a FloatingActionButton and floatingActionButtonLocation is set to a top position', + (WidgetTester tester) async { + Future pumpApp({required FloatingActionButtonLocation fabLocation}) async { + return tester.pumpWidget(MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + floatingActionButtonLocation: fabLocation, + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + )); + }, + child: const Text('X'), + ); + }, + ), + ), + )); + } + + const List topLocations = [ + FloatingActionButtonLocation.startTop, + FloatingActionButtonLocation.centerTop, + FloatingActionButtonLocation.endTop, + FloatingActionButtonLocation.miniStartTop, + FloatingActionButtonLocation.miniCenterTop, + FloatingActionButtonLocation.miniEndTop, + ]; + + for (final FloatingActionButtonLocation location in topLocations) { + await pumpApp(fabLocation: location); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + + expect(snackBarBottomLeft.dy, 600); // Device height is 600. + } + }, + ); + + testWidgets( + '${SnackBarBehavior.floating} should align SnackBar with the top of FloatingActionButton ' + 'when Scaffold has a FloatingActionButton and floatingActionButtonLocation is not set to a top position', + (WidgetTester tester) async { + Future pumpApp({required FloatingActionButtonLocation fabLocation}) async { + return tester.pumpWidget(MaterialApp( + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + floatingActionButtonLocation: fabLocation, + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + )); + }, + child: const Text('X'), + ); + }, + ), + ), + )); + } + + const List nonTopLocations = [ + FloatingActionButtonLocation.startDocked, + FloatingActionButtonLocation.startFloat, + FloatingActionButtonLocation.centerDocked, + FloatingActionButtonLocation.centerFloat, + FloatingActionButtonLocation.endContained, + FloatingActionButtonLocation.endDocked, + FloatingActionButtonLocation.endFloat, + FloatingActionButtonLocation.miniStartDocked, + FloatingActionButtonLocation.miniStartFloat, + FloatingActionButtonLocation.miniCenterDocked, + FloatingActionButtonLocation.miniCenterFloat, + FloatingActionButtonLocation.miniEndDocked, + FloatingActionButtonLocation.miniEndFloat, + // Regression test related to https://github.com/flutter/flutter/pull/131303. + _CustomFloatingActionButtonLocation(), + ]; + + + for (final FloatingActionButtonLocation location in nonTopLocations) { + await pumpApp(fabLocation: location); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset floatingActionButtonTopLeft = tester.getTopLeft( + find.byType(FloatingActionButton), + ); + + // Since padding between the SnackBar and the FAB is created by the SnackBar, + // the bottom offset of the SnackBar should be equal to the top offset of the FAB + expect(snackBarBottomLeft.dy, floatingActionButtonTopLeft.dy); + } + }, + ); + testWidgets( '${SnackBarBehavior.fixed} should align SnackBar with the top of BottomNavigationBar ' 'when Scaffold has a BottomNavigationBar and FloatingActionButton', @@ -3749,3 +3870,8 @@ class _TestMaterialStateColor extends MaterialStateColor { return const Color(_colorRed); } } + +class _CustomFloatingActionButtonLocation extends StandardFabLocation + with FabEndOffsetX, FabFloatOffsetY { + const _CustomFloatingActionButtonLocation(); +}