From bc53e6202e70f23807881f4c5ba0463c9fae62d0 Mon Sep 17 00:00:00 2001 From: Bruno Leroux Date: Thu, 26 May 2022 14:15:07 +0200 Subject: [PATCH] Add error message and documentation when a `SnackBar` is off screen (#102073) --- ...fold_messenger_state.show_snack_bar.1.dart | 76 +++++++++++++++++++ ...messenger_state.show_snack_bar.1_test.dart | 36 +++++++++ .../flutter/lib/src/material/scaffold.dart | 43 +++++++++++ .../flutter/test/material/snack_bar_test.dart | 66 ++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart create mode 100644 examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart diff --git a/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart b/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart new file mode 100644 index 000000000000..0f25f1f5959a --- /dev/null +++ b/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart @@ -0,0 +1,76 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flutter code sample for SnackBar + +import 'package:flutter/material.dart'; + +void main() => runApp(const SnackBarApp()); + +class SnackBarApp extends StatelessWidget { + const SnackBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: SnackBarExample(), + ); + } +} + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State { + bool _largeLogo = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + ElevatedButton( + onPressed: () { + const SnackBar snackBar = SnackBar( + content: Text('A SnackBar has been shown.'), + behavior: SnackBarBehavior.floating, + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, + child: const Text('Show SnackBar'), + ), + const SizedBox(height: 8.0), + ElevatedButton( + onPressed: () { + setState(() => _largeLogo = !_largeLogo); + }, + child: Text(_largeLogo ? 'Shrink Logo' : 'Grow Logo'), + ), + ], + ), + ), + // A floating [SnackBar] is positioned above [Scaffold.floatingActionButton]. + // If the Widget provided to the floatingActionButton slot takes up too much space + // for the SnackBar to be visible, an error will be thrown. + floatingActionButton: Container( + constraints: BoxConstraints.tightFor( + width: 150, + height: _largeLogo ? double.infinity : 150, + ), + decoration: const BoxDecoration( + color: Colors.blueGrey, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + child: const FlutterLogo(), + ), + ); + } +} diff --git a/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart b/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart new file mode 100644 index 000000000000..27b967a4b362 --- /dev/null +++ b/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.1_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Floating SnackBar is visible', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SnackBarApp(), + ); + + final Finder buttonFinder = find.byType(ElevatedButton); + await tester.tap(buttonFinder.first); + // Have the SnackBar fully animate out. + await tester.pumpAndSettle(); + + final Finder snackBarFinder = find.byType(SnackBar); + expect(snackBarFinder, findsOneWidget); + + // Grow logo to send SnackBar off screen. + await tester.tap(buttonFinder.last); + await tester.pumpAndSettle(); + + final AssertionError exception = tester.takeException() as AssertionError; + const String message = 'Floating SnackBar presented off screen.\n' + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n' + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.'; + expect(exception.message, message); + }); +} diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 9d5c753bf9d6..98b2b6e2df85 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -264,6 +264,23 @@ class ScaffoldMessengerState extends State with TickerProvide /// /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.0.dart ** /// {@end-tool} + /// + /// ## Relative positioning of floating SnackBars + /// + /// A [SnackBar] with [SnackBar.behavior] set to [SnackBarBehavior.floating] is + /// positioned above the widgets provided to [Scaffold.floatingActionButton], + /// [Scaffold.persistentFooterButtons], and [Scaffold.bottomNavigationBar]. + /// If some or all of these widgets take up enough space such that the SnackBar + /// would not be visible when positioned above them, an error will be thrown. + /// In this case, consider constraining the size of these widgets to allow room for + /// the SnackBar to be visible. + /// + /// {@tool dartpad} + /// Here is an example showing that a floating [SnackBar] appears above [Scaffold.floatingActionButton]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart ** + /// {@end-tool} + /// ScaffoldFeatureController showSnackBar(SnackBar snackBar) { assert( _scaffolds.isNotEmpty, @@ -1118,6 +1135,32 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { final double xOffset = hasCustomWidth ? (size.width - snackBarWidth!) / 2 : 0.0; positionChild(_ScaffoldSlot.snackBar, Offset(xOffset, snackBarYOffsetBase - snackBarSize.height)); + + assert((){ + // Whether a floating SnackBar has been offsetted too high. + // + // To improve the developper experience, this assert is done after the call to positionChild. + // if we assert sooner the SnackBar is visible because its defaults position is (0,0) and + // it can cause confusion to the user as the error message states that the SnackBar is off screen. + if (isSnackBarFloating) { + final bool snackBarVisible = (snackBarYOffsetBase - snackBarSize.height) > 0; + if (!snackBarVisible) { + throw FlutterError.fromParts([ + ErrorSummary('Floating SnackBar presented off screen.'), + ErrorDescription( + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n' + ), + ErrorHint( + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.', + ), + ]); + } + } + return true; + }()); } if (hasChild(_ScaffoldSlot.statusBar)) { diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index 98faa2a9cd05..70bbd0dc6c16 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -1652,6 +1652,72 @@ void main() { }, ); + void openFloatingSnackBar(WidgetTester tester) { + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('SnackBar text'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + void expectSnackBarNotVisibleError(WidgetTester tester) { + final AssertionError exception = tester.takeException() as AssertionError; + const String message = 'Floating SnackBar presented off screen.\n' + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n' + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.'; + expect(exception.message, message); + } + + testWidgets('Snackbar with SnackBarBehavior.floating will assert when offsetted too high by a large Scaffold.floatingActionButton', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + floatingActionButton: Container(), + ), + ), + ); + + openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + expectSnackBarNotVisibleError(tester); + }); + + testWidgets('Snackbar with SnackBarBehavior.floating will assert when offsetted too high by a large Scaffold.persistentFooterButtons', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + persistentFooterButtons: [SizedBox(height: 1000)], + ), + ), + ); + + openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + expectSnackBarNotVisibleError(tester); + }); + + testWidgets('Snackbar with SnackBarBehavior.floating will assert when offsetted too high by a large Scaffold.bottomNavigationBar', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/84263 + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + bottomNavigationBar: SizedBox(height: 1000), + ), + ), + ); + + openFloatingSnackBar(tester); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + expectSnackBarNotVisibleError(tester); + }); + testWidgets( 'SnackBar has correct end padding when it contains an action with fixed behavior', (WidgetTester tester) async {