Unverified Commit f9118c0f authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Preserving SafeArea : Part 2 (#34298)

* WIP

* Added tests and updated SafeArea implementation.

* Analyzer nits

* Review feedback

* Updated for SnackBar and PersistentFooterButton cases, added tests to check other potential spots.

* doc addition for SafeArea

* Typos
parent c8cefce3
...@@ -1881,6 +1881,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -1881,6 +1881,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
@required bool removeRightPadding, @required bool removeRightPadding,
@required bool removeBottomPadding, @required bool removeBottomPadding,
bool removeBottomInset = false, bool removeBottomInset = false,
bool maintainBottomViewPadding = false,
}) { }) {
MediaQueryData data = MediaQuery.of(context).removePadding( MediaQueryData data = MediaQuery.of(context).removePadding(
removeLeft: removeLeftPadding, removeLeft: removeLeftPadding,
...@@ -1891,6 +1892,12 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -1891,6 +1892,12 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
if (removeBottomInset) if (removeBottomInset)
data = data.removeViewInsets(removeBottom: true); data = data.removeViewInsets(removeBottom: true);
if (maintainBottomViewPadding && data.viewInsets.bottom != 0.0) {
data = data.copyWith(
padding: data.padding.copyWith(bottom: data.viewPadding.bottom)
);
}
if (child != null) { if (child != null) {
children.add( children.add(
LayoutId( LayoutId(
...@@ -1995,7 +2002,6 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -1995,7 +2002,6 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
} }
final List<LayoutId> children = <LayoutId>[]; final List<LayoutId> children = <LayoutId>[];
_addIfNonNull( _addIfNonNull(
children, children,
widget.body != null && widget.extendBody ? _BodyBuilder(body: widget.body) : widget.body, widget.body != null && widget.extendBody ? _BodyBuilder(body: widget.body) : widget.body,
...@@ -2057,6 +2063,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -2057,6 +2063,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
removeTopPadding: true, removeTopPadding: true,
removeRightPadding: false, removeRightPadding: false,
removeBottomPadding: widget.bottomNavigationBar != null || widget.persistentFooterButtons != null, removeBottomPadding: widget.bottomNavigationBar != null || widget.persistentFooterButtons != null,
maintainBottomViewPadding: !_resizeToAvoidBottomInset,
); );
} }
...@@ -2085,6 +2092,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -2085,6 +2092,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
removeTopPadding: true, removeTopPadding: true,
removeRightPadding: false, removeRightPadding: false,
removeBottomPadding: false, removeBottomPadding: false,
maintainBottomViewPadding: !_resizeToAvoidBottomInset,
); );
} }
...@@ -2097,6 +2105,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { ...@@ -2097,6 +2105,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
removeTopPadding: true, removeTopPadding: true,
removeRightPadding: false, removeRightPadding: false,
removeBottomPadding: false, removeBottomPadding: false,
maintainBottomViewPadding: !_resizeToAvoidBottomInset,
); );
} }
......
...@@ -45,6 +45,7 @@ class SafeArea extends StatelessWidget { ...@@ -45,6 +45,7 @@ class SafeArea extends StatelessWidget {
this.right = true, this.right = true,
this.bottom = true, this.bottom = true,
this.minimum = EdgeInsets.zero, this.minimum = EdgeInsets.zero,
this.maintainBottomViewPadding = false,
@required this.child, @required this.child,
}) : assert(left != null), }) : assert(left != null),
assert(top != null), assert(top != null),
...@@ -70,6 +71,18 @@ class SafeArea extends StatelessWidget { ...@@ -70,6 +71,18 @@ class SafeArea extends StatelessWidget {
/// The greater of the minimum insets and the media padding will be applied. /// The greater of the minimum insets and the media padding will be applied.
final EdgeInsets minimum; final EdgeInsets minimum;
/// Specifies whether the [SafeArea] should maintain the [viewPadding] instead
/// of the [padding] when consumed by the [viewInsets] of the current
/// context's [MediaQuery], defaults to false.
///
/// For example, if there is an onscreen keyboard displayed above the
/// SafeArea, the padding can be maintained below the obstruction rather than
/// being consumed. This can be helpful in cases where your layout contains
/// flexible widgets, which could visibly move when opening a software
/// keyboard due to the change in the padding value. Setting this to true will
/// avoid the UI shift.
final bool maintainBottomViewPadding;
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
/// ///
/// The padding on the [MediaQuery] for the [child] will be suitably adjusted /// The padding on the [MediaQuery] for the [child] will be suitably adjusted
...@@ -81,7 +94,12 @@ class SafeArea extends StatelessWidget { ...@@ -81,7 +94,12 @@ class SafeArea extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
final EdgeInsets padding = MediaQuery.of(context).padding; final MediaQueryData data = MediaQuery.of(context);
EdgeInsets padding = data.padding;
// Bottom padding has been consumed - i.e. by the keyboard
if (data.padding.bottom == 0.0 && data.viewInsets.bottom != 0.0 && maintainBottomViewPadding)
padding = padding.copyWith(bottom: data.viewPadding.bottom);
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: math.max(left ? padding.left : 0.0, minimum.left), left: math.max(left ? padding.left : 0.0, minimum.left),
......
...@@ -111,6 +111,45 @@ testWidgets('Opaque bar pushes contents down', (WidgetTester tester) async { ...@@ -111,6 +111,45 @@ testWidgets('Opaque bar pushes contents down', (WidgetTester tester) async {
expect(tester.getSize(find.byType(Container)).height, 600.0); expect(tester.getSize(find.byType(Container)).height, 600.0);
}); });
testWidgets('Contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overriden', (WidgetTester tester) async {
const Widget child = CupertinoPageScaffold(
resizeToAvoidBottomInset: false,
navigationBar: CupertinoNavigationBar(
middle: Text('Opaque'),
backgroundColor: Color(0xFFF8F8F8),
),
child: Placeholder(),
);
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: 20.0)),
child: child
),
)
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
)
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('Contents are between opaque bars', (WidgetTester tester) async { testWidgets('Contents are between opaque bars', (WidgetTester tester) async {
const Center page1Center = Center(); const Center page1Center = Center();
......
...@@ -434,6 +434,48 @@ void main() { ...@@ -434,6 +434,48 @@ void main() {
expect(MediaQuery.of(innerContext).padding.bottom, 50); expect(MediaQuery.of(innerContext).padding.bottom, 50);
}); });
testWidgets('Tab contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overriden', (WidgetTester tester) async {
final Widget child = Directionality(
textDirection: TextDirection.ltr,
child: CupertinoTabScaffold(
resizeToAvoidBottomInset: false,
tabBar: _buildTabBar(),
tabBuilder: (BuildContext context, int index) {
return const Placeholder();
},
)
);
await tester.pumpWidget(
CupertinoApp(
home: MediaQuery(
data: const MediaQueryData(
viewInsets: EdgeInsets.only(bottom: 20.0),
),
child: child
),
),
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('Tab and page scaffolds do not double stack view insets', (WidgetTester tester) async { testWidgets('Tab and page scaffolds do not double stack view insets', (WidgetTester tester) async {
BuildContext innerContext; BuildContext innerContext;
......
...@@ -203,6 +203,40 @@ void main() { ...@@ -203,6 +203,40 @@ void main() {
expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 356.0)); expect(tester.getCenter(find.byType(FloatingActionButton)), const Offset(44.0, 356.0));
}); });
testWidgets('Floating Action Button bottom padding not consumed by viewInsets', (WidgetTester tester) async {
final Widget child = Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Container(),
floatingActionButton: const Placeholder(),
),
);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(bottom: 20.0),
),
child: child,
)
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('Drawer scrolling', (WidgetTester tester) async { testWidgets('Drawer scrolling', (WidgetTester tester) async {
final Key drawerKey = UniqueKey(); final Key drawerKey = UniqueKey();
const double appBarHeight = 256.0; const double appBarHeight = 256.0;
...@@ -348,6 +382,40 @@ void main() { ...@@ -348,6 +382,40 @@ void main() {
expect(appBarBottomRight, equals(sheetTopRight)); expect(appBarBottomRight, equals(sheetTopRight));
}); });
testWidgets('BottomSheet bottom padding is not consumed by viewInsets', (WidgetTester tester) async {
final Widget child = Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Container(),
bottomSheet: const Placeholder(),
),
);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(bottom: 20.0),
),
child: child,
)
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('Persistent bottom buttons are persistent', (WidgetTester tester) async { testWidgets('Persistent bottom buttons are persistent', (WidgetTester tester) async {
bool didPressButton = false; bool didPressButton = false;
await tester.pumpWidget( await tester.pumpWidget(
...@@ -403,6 +471,40 @@ void main() { ...@@ -403,6 +471,40 @@ void main() {
expect(tester.getBottomRight(find.byType(ButtonBar)), const Offset(770.0, 560.0)); expect(tester.getBottomRight(find.byType(ButtonBar)), const Offset(770.0, 560.0));
}); });
testWidgets('Persistent bottom buttons bottom padding is not consumed by viewInsets', (WidgetTester tester) async {
final Widget child = Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Container(),
persistentFooterButtons: const <Widget>[Placeholder()],
),
);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(bottom: 20.0),
),
child: child,
)
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
group('back arrow', () { group('back arrow', () {
Future<void> expectBackIcon(WidgetTester tester, TargetPlatform platform, IconData expectedIcon) async { Future<void> expectBackIcon(WidgetTester tester, TargetPlatform platform, IconData expectedIcon) async {
final GlobalKey rootKey = GlobalKey(); final GlobalKey rootKey = GlobalKey();
...@@ -907,6 +1009,62 @@ void main() { ...@@ -907,6 +1009,62 @@ void main() {
); );
}); });
testWidgets('Scaffold BottomNavigationBar bottom padding is not consumed by viewInsets.', (WidgetTester tester) async {
Widget boilerplate(Widget child) {
return Localizations(
locale: const Locale('en', 'us'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultWidgetsLocalizations.delegate,
DefaultMaterialLocalizations.delegate,
],
child: Directionality(
textDirection: TextDirection.ltr,
child: child,
),
);
}
final Widget child = boilerplate(
Scaffold(
resizeToAvoidBottomInset: false,
body: const Placeholder(),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.add),
title: Text('test'),
),
BottomNavigationBarItem(
icon: Icon(Icons.add),
title: Text('test'),
)
]
),
),
);
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 20.0)),
child: child,
),
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('floatingActionButton', (WidgetTester tester) async { testWidgets('floatingActionButton', (WidgetTester tester) async {
final GlobalKey key = GlobalKey(); final GlobalKey key = GlobalKey();
await tester.pumpWidget(MaterialApp(home: Scaffold( await tester.pumpWidget(MaterialApp(home: Scaffold(
......
...@@ -642,6 +642,61 @@ void main() { ...@@ -642,6 +642,61 @@ void main() {
expect(snackBarBottomCenter.dy == floatingActionButtonTopCenter.dy, true); expect(snackBarBottomCenter.dy == floatingActionButtonTopCenter.dy, true);
}); });
testWidgets('SnackBar bottom padding is not consumed by viewInsets', (WidgetTester tester) async {
final Widget child = Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.send),
onPressed: () {}
),
body: Builder(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
Scaffold.of(context).showSnackBar(
SnackBar(
content: const Text('I am a snack bar.'),
duration: const Duration(seconds: 2),
action: SnackBarAction(label: 'ACTION', onPressed: () {}),
)
);
},
child: const Text('X'),
);
}
),
));
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(bottom: 20.0),
),
child: child
)
);
await tester.tap(find.text('X'));
await tester.pumpAndSettle(); // Show snackbar
final Offset initialPoint = tester.getCenter(find.byType(SnackBar));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.zero,
viewPadding: EdgeInsets.only(bottom: 20),
viewInsets: EdgeInsets.only(bottom: 300),
),
child: child,
),
);
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
final Offset finalPoint = tester.getCenter(find.byType(SnackBar));
expect(initialPoint, finalPoint);
});
testWidgets('SnackBarClosedReason', (WidgetTester tester) async { testWidgets('SnackBarClosedReason', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
bool actionPressed = false; bool actionPressed = false;
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
void main() { void main() {
group('SafeArea', () { group('SafeArea', () {
...@@ -85,6 +86,92 @@ void main() { ...@@ -85,6 +86,92 @@ void main() {
expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(100.0, 30.0)); expect(tester.getTopLeft(find.byType(Placeholder)), const Offset(100.0, 30.0));
expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0)); expect(tester.getBottomRight(find.byType(Placeholder)), const Offset(800.0, 600.0));
}); });
group('SafeArea maintains bottom viewPadding when specified for consumed bottom padding', () {
Widget boilerplate(Widget child) {
return Localizations(
locale: const Locale('en', 'us'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultWidgetsLocalizations.delegate,
DefaultMaterialLocalizations.delegate,
],
child: Directionality(textDirection: TextDirection.ltr, child: child),
);
}
testWidgets('SafeArea alone.', (WidgetTester tester) async {
final Widget child = boilerplate(SafeArea(
maintainBottomViewPadding: true,
child: Column(
children: const <Widget>[
Expanded(child: Placeholder())
],
),
));
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.symmetric(vertical: 20.0),
viewPadding: EdgeInsets.only(bottom: 20.0),
),
child: child,
),
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(top: 20.0),
viewPadding: EdgeInsets.only(bottom: 20.0),
viewInsets: EdgeInsets.only(bottom: 300.0),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
testWidgets('SafeArea with nested Scaffold', (WidgetTester tester) async {
final Widget child = boilerplate(SafeArea(
maintainBottomViewPadding: true,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Column(
children: const <Widget>[
Expanded(child: Placeholder())
],
),
),
));
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.symmetric(vertical: 20.0),
viewPadding: EdgeInsets.only(bottom: 20.0),
),
child: child,
),
);
final Offset initialPoint = tester.getCenter(find.byType(Placeholder));
// Consume bottom padding - as if by the keyboard opening
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
padding: EdgeInsets.only(top: 20.0),
viewPadding: EdgeInsets.only(bottom: 20.0),
viewInsets: EdgeInsets.only(bottom: 300.0),
),
child: child,
),
);
final Offset finalPoint = tester.getCenter(find.byType(Placeholder));
expect(initialPoint, finalPoint);
});
});
}); });
group('SliverSafeArea', () { group('SliverSafeArea', () {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment