Debugging layout sizing problems in Flutter

Photo by Avel Chuklanov on Unsplash
This post is not going to be about how to debug common layout problems in Flutter like widget overflow, sizing, etc. related to Column and Row widgets but we will go through one very specific example first explaining the problem and then going through the process on how to debug and fix it. I would recommend reading this article by Katie Lee first which goes in depth explaining how to debug such common layout problems using Dart DevTools.
In fact, after learning this process from her article last week i stumbled upon one such issue in my work project. We will go through this process together in this post. We will learn on how to debug and fix such problems using combination of reading Flutter documentation along with using Dart Dev tools rather than going through trial-and-error approach or looking for Stackoverflow answers 😜. It also helped me learn a couple of key concepts in Flutter related to BoxConstraints, ModalBottomSheet, Align, Stack, Positioned widgets as well. Sharing in hope that this process of debugging and learning these concepts together might be useful to someone in future. Without any further delay let's start 🚀
I've divided this post into following 4 parts:
Explaining Problem
Bottom sheets have nowadays become standard way of showing inline menus or simple confirmation dialogs on mobile apps. Flutter provides showModalBottomSheet method to show such non-persistent bottom sheet dialogs based on some actions.
In one of my work project we have to use these bottom sheets in many screens for showing confirmation dialogs and they have very similar widget structure in most of those dialogs in following format.
Code :
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
titleText,
contentText,
if(_mutipleCTAButtons_ != null) ..._multipleCTAButtons,
else commonCTAButton
]
)
Visually:
Considering our layout is quite common in all this dialogs our obvious choice was to create a wrapper function over showModalBottomSheet so we don't have write Column wrapping code every time we need to create any such bottom sheet dialog. So, we wrote down something like following:
import 'package:flutter/material.dart';
Future<T> showBottomDialog<T>({
BuildContext context,
String title,
String content,
Widget titleWidget,
Widget contentWidget,
List<Widget> actions,
bool allowBackNavigation = false,
}) {
assert(title != null || titleWidget != null,
'title and titleWidget both must not be null');
assert(content != null || contentWidget != null,
'content and contentWidget both must not be null');
final theme = Theme.of(context);
return showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
isDismissible: allowBackNavigation,
builder: (context) => WillPopScope(
onWillPop: () async => allowBackNavigation,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
titleWidget ??
Text(
title,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.headline2,
),
SizedBox(height: 16),
contentWidget ??
Text(
content,
textAlign: TextAlign.left,
style: Theme.of(context)
.textTheme
.bodyText2
.copyWith(height: 1.5),
),
SizedBox(height: 48),
if (actions != null)
...actions
else
OutlineButton(
child: Text('GOT IT!'),
borderSide: BorderSide(color: theme.primaryColor),
onPressed: () {
Navigator.of(context).pop();
},
),
],
),
),
),
);
}
And then using this wrapper function becomes easy like following:
class DialogExample extends StatelessWidget {
void _showDialog(BuildContext context) {
showBottomDialog(
context: context,
allowBackNavigation: true,
title: 'Do you wish to purchase add-ons?',
content:
'Add-ons help you save some extra money when you purchase them along with our original products. Plus, they help your chances of winning as well.',
actions: [
RaisedButton(
child: Text('YES, GO AHEAD'),
onPressed: () {},
),
FlatButton(
child: Text('SKIP'),
onPressed: () {},
)
],
);
}
Widget build(BuildContext context) {
return Container(
child: Center(
child: RaisedButton(
child: Text('Show Dialog'),
onPressed: () => _showDialog(context),
),
),
);
}
}
All good till now! But, wait! That's not possible because remember we had some problem we're facing, no?
Yes, now in some of such Bottom Sheets in the App we needed to add an IconButton on top right corner of the dialog to be able to close it. We've might use it where we've disabled dismissing dialog by setting isDismissable : true
for some specific reasons and want users to explicitly chose to close dialog by clicking on that IconButton
.
Easy-peasy, right? Let's change widget structure a bit and add Stack as parent so we can position IconButton
in the top right corner in the dialog.
Implementation would change to:
Stack(
children : <Widget>[
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
titleText,
contentText,
if(_mutipleCTAButtons_ != null) ..._multipleCTAButtons,
else commonCTAButton
]
),
if(closeButton)
Align(
alignment : Alignment.topRight,
child : IconButton(
icon: Icon(
Icons.close,
size: 24,
),
onPressed: onClose ?? () => Navigator.pop(context),
)
)
]
)
Changes in to our showBottomDialog function becomes:
Future<T> showBottomDialog<T>({
BuildContext context,
String title,
String content,
Widget titleWidget,
Widget contentWidget,
List<Widget> actions,
bool allowBackNavigation = false, bool showCloseButton = false, Function onClose,
}) {
assert(title != null || titleWidget != null,
'title and titleWidget must not both be null');
assert(content != null || contentWidget != null,
'content and contentWidget must not both be null');
final theme = Theme.of(context);
return showModalBottomSheet(
...
builder: (context) => WillPopScope(
onWillPop: () async => allowBackNavigation, child: Stack( children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
...
...
// Removed for brevity
],
),
), if (showCloseButton) Align( alignment : Alignment.topRight, child: IconButton( icon: Icon(Icons.close), onPressed: onClose ?? () => Navigator.pop(context), ), ),
],
),
),
);
}
And then changes in usage side with above example becomes :
class DialogExample extends StatelessWidget {
void _showDialog(BuildContext context) {
showBottomDialog(
...
...
showCloseButton : true, );
}
// Removed other content for brevity
}
Cool! Everything should work now, correct? Lets looks at it visually:
WAIT! What just happened?
- Why is our Bottom Sheet now taking more space than the content?
- Why is it taking up almost half height of the screen?
Now that we know what the issue is, let's work together to solve it 😁
Using Dart Dev Tools
Okay, let's first see which Widget is causing our BottomSheet's height to increase by opening Dart DevTools. (without assuming it has to be Align with IconButton because adding it is what caused the issue, right? 😜)
Note : I'm not covering here on how to use Dart DevTools step by step. If you'd like to know more about them i'd suggest checking out official docs or article i linked earlier in this post.
Highlighted area represents our BottomSheet's layout hierarchy. Let's turn on Select Widget mode in DevTool and start selecting widgets to understand which widget is taking how much space in layout.
Selecting Stack in widget tree shows following on our device.
Okay! Let's select Stack's
children one by one.
- Padding with Column
- Align
- IconButton
With Padding selected:
With Align selected:
Looking at above 3 screenshots we can see that
- Padding with Column widget is taking up correct amount of space based on in its content size with
mainAxisSize
set toMainAxis.min
. - IconButton is taking up its default icon size of
24
plus padding of8
on all sides correctly.
However, what is happening with Align widget over here? 🤔
Why is it taking up the entire space of BottomSheet? And, why is BottomSheet taking up little bit more than a half height of our screen in first place? So many questions ❓❓❓
Let's have a look at the Flutter documentation for all of the associated widgets to figure out where we could be going wrong in all of this.
Reading Flutter documentation
Before we start reading documentation let's first look at dimensions(width and height) of Stack
in DevTools.
Note those dimensions. It has constraints set where both minWidth
and maxWidth
are set equal to screenWidth
and not set as "0" and "screenWidth" respectively(i.e. 0.0<=w<=414
). This means it is explicitly told to take up full screen width and not take into consideration the width of its children.
And, maxHeight
is exactly set to 9/16 of screenHeight
.
As a result, Stack's size is getting set to w = 414
(screenWidth) and h = 504
(screenHeight) which means it is taking entire height it is allowed to take.
Interesting 🤔. Could those constraints might have been set by BottomSheet under which we're rendering our Stack Widget? Let's find out.
Using your "Go to Definition" keyboard shortcut of Editor/IDE let's go and see implementation of showModalBottomSheet
method which we call to render our BottomSheet.
Note : I'll refer to "Go to Definition" as "GTD" for rest of the post. Also, i'd recommend following this process together so you can see other relevant details when navigating along with me.
return Navigator.of(context, rootNavigator: useRootNavigator).push(_ModalBottomSheetRoute<T>( builder: builder,
...
...
// Removed for brevity
));
It is pushing _ModalBottomSheetRoute
to our navigation stack. Cool! Let's continue by again using GTD shortcut and see _ModalBottomSheetRoute's
implementation which is a type of ModalRoute
.
class _ModalBottomSheetRoute<T> extends PopupRoute<T>{
...
...
Widget buildPage(BuildContext context.
Animation<double> animation,
Animation<double> secondaryAnimation) {
...
...
Widget bottomSheet = MediaQuery.removePadding(
context: context,
removeTop: true,
child: _ModalBottomSheet<T>( route: this,
...
...
),
);
...
return bottomSheet;
}
}
Ohh there it is we see _ModalBottomSheet
inside buildPage method of _ModalBottomSheetRoute
. Ignoring other info and just focussing on _ModalBottomSheet
let's use GTD shortcut and see its implementation.
build(BuildContext context) {
...
...
return AnimatedBuilder(
animation: widget.route.animation,
builder: (BuildContext context, Widget child) {
...
return Semantics(
...
child: ClipRect(
child: CustomSingleChildLayout( delegate: _ModalBottomSheetLayout(animationValue, widget.isScrollControlled), child: BottomSheet(
animationController: widget.route._animationController,
onClosing: () {
if (widget.route.isCurrent) {
Navigator.pop(context);
}
},
// This is what we provide in our
// `builder` method of
// `showModalBottomSheet()`.
builder: widget.route.builder, ...
...
),
),
),
);
},
);
}
Widget
It is a StatefulWidget wrapped with AnimatedBuilder for all bottom sheet animations you see when pushing and popping it. Okay!
What is CustomSingleChildLayout
? Let's checkout using GTD shortcut. It's documentation says following:
/// A widget that defers the layout of its single child to a delegate.
///
/// The delegate can determine the layout constraints for the child and can
/// decide where to position the child. The delegate can also determine the size
/// of the parent, but the size of the parent cannot depend on the size of the
/// child.
class CustomSingleChildLayout extends SingleChildRenderObjectWidget {
Okay, so it is delegate of CustomSingleChildLayout
which determines layout constraints for the child which is nothing but our BottomSheet. Good! Moving on to _ModalBottomSheetLayout
which is set as its delegate.
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
_ModalBottomSheetLayout(this.progress, this.isScrollControlled);
final double progress;
final bool isScrollControlled;
BoxConstraints getConstraintsForChild(BoxConstraints constraints) { return BoxConstraints( minWidth: constraints.maxWidth, maxWidth: constraints.maxWidth, minHeight: 0.0, maxHeight: isScrollControlled ? constraints.maxHeight : constraints.maxHeight * 9.0 / 16.0, );
}
...
...
}
THERE IT IS!! So, our BottomSheet constraints are set as follows:
Width:
final minWidth = constraints.maxWidth;
final maxWidth = constraints.maxWidth
Note: constraints.maxWidth = double.infinity.
Height:
final minHeight = 0.0;
final maxHeight = (9/16) * constraints.maxHeight;
This is because in our case isScrollControlled is set to false by default.
Ah! That is the reason all BottomSheets by default have full screen width and can have max height of 9/16 of screen's height. Makes sense! One mystery solved. Phew! :D
The previous constraint of BoxConstraint(w=414, 0<=h<=504)
which we saw earlier in DevTool on Stack
Widget makes sense now for width
but for height
it can take any height between 0 and 504 so why is it(Stack) taking up entire height then? Is that how Stack works? Let's find out.
Take a break and come back! 😅
Let's check out Stack's documentation to understand how its size is calculated. Using GTD shorcut by keeping cursor on Stack
and let's go!
It has really good doc comments with examples on how to use it but the one particular paragraph of our interest i found out was following:
Each child of a [Stack] widget is either positioned or non-positioned.
Positioned children are those wrapped in a [Positioned] widget that has at least one non-null property. The stack sizes itself to contain all the non-positioned children, which are positioned according to [alignment] (which defaults to the top-left corner in left-to-right environments and the top-right corner in right-to-left environments). The positioned children are then placed relative to the stack according to their top, right, bottom, and left properties.
Neat! Understood. Still to clear up this concept related to Stack's sizing i like to try this out in playground project by writing following code.
import 'package:flutter/material.dart';
class StackExample extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: <Widget>[
Container(
width: 350,
height: 100,
color: Colors.green,
),
Container(
width: 200,
height: 400,
color: Colors.yellow,
),
],
),
),
);
}
}
By above documentation, width should be max of width of our both non-positioned Container
widgets and same for height.
width = max(350, 200) // => 350
height = max(100, 400) // => 400
Let's check out visually:
All good! How about we add combination of positioned and non-positioned children in Stack like below:
import 'package:flutter/material.dart';
class StackExample extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: <Widget>[
// Non-Positioned
Container(
width: double.infinity,
height: 450,
color: Colors.orange,
),
// Positioned to top-left
Positioned(
top: 0,
left: 0,
child: Container(
width: 350,
height: 100,
color: Colors.green,
),
),
// Positioned to top-right
Positioned(
top: 0,
right: 0,
child: Container(
width: 200,
height: 700,
color: Colors.yellow,
),
),
],
),
),
);
}
}
WDYT would be width and height of Stack set to?
As per documentation and our understanding it should be calculated like following because there is only non-positoned children Container
with width = 450
and height = double.infinity
:
width = max(450) // 450
height = max(double.infinity) // double.infinity
Checking out visually:
Fair enough? Wait, but one of our Container
Widget has height set equal to 700
but still height of our Stack Widget is getting set to 400
. What?
Yes, that is because by default all positioned children which have size greater than max of all non-positioned childrens size will get clipped because the default Overflow behavior of Stack is set to
Overflow.clip
.
All Right! So, after using DevTools and reading Flutter documentation we now have fair bit of understanding about following:
- BottomSheet and how it sets constraints on its child widget.
- Stack and how its sizes its according to its children.
Let's recreate the issue we're having using example we just saw above but without using BottomSheet. We will set similar BoxConstraints as we saw earlier and try to fix it.
Fixing Problem
import 'package:flutter/material.dart';
class StackExample extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth,
maxWidth: constraints.maxWidth,
minHeight: 0.0,
maxHeight: constraints.maxHeight * 9.0 / 16.0,
),
child: Stack(
children: <Widget>[
Container(
width: double.infinity,
height: 200,
color: Colors.yellow,
),
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: Icon(Icons.close),
onPressed: () {},
),
)
],
),
);
},
),
),
);
}
}
As per our understanding, Stack's size be set to max of non-positioned children size, correct?
final containerWidth = double.infinity;
final iconButtonWidth = 40; // IconButton has default size set to 40. 24 - Icon size + 8 padding on all sides.
final containerHeight = 200;
final iconButtonHeight = 40;
width = max(containerWidth, iconButtonWidth); // double.infinity
height = max(containerHeight, iconButtonHeight); // 200
However, if you open DevTools again and check Stack's childrens heights you will see the same problem we had earlier. It's height is getting set equal to 9/16 * screenHeight
and not equal to 200
as per our calculation 🤬
Back to using "Select Widget" mode in DevTool.
If you check in DevTool the size of IconButton
is correct(40) but the Align
widget's size is getting set equal to Size(screenWidth, (9/16) * screenHeight).
Hot Damn! Looks like we don't understand Align correctly. For one last time let's check its documentation using GTD shortcut. Again, solid doc comments with examples on how to use Align widget but the one particular paragraph of our interest in the documentation i found out was following:
Point1 : This widget will be as big as possible if its dimensions are constrained and [widthFactor] and [heightFactor] are null.
Point2 : And, if a dimension is unconstrained and the corresponding size factor is null then the widget will match its child's size in that dimension.
In our case Align is wrapped under Constrained dimension set by our BottomSheet's BoxConstraints. And, we haven't set widthFactor and heighFactor on it. So, it ticks all the checkboxes under pt1 thereby it(Align) taking size as big as possible which is nothing but equal to :
const size = Size(constraints.maxWidth, constrainst.maxHeight);
// where constraints.maxHeight = 9/16 * screenHeight
Looks like we didn't understood this about Align widget earlier and were using assuming it should do the job on aligning our IconButton
at correct place in top right corner which it did 😅 but thereby messing up Stack's height. What is the obvious replacement in this case? Positioned widget? It is used to position children in Stack layout, correct?.
Let's try it out by replacing Align
with Positioned
widget.
import 'package:flutter/material.dart';
class StackCalc extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth,
maxWidth: constraints.maxWidth,
minHeight: 0.0,
maxHeight: constraints.maxHeight * 9.0 / 16.0,
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Stack(
children: <Widget>[
Container(
width: double.infinity,
height: 200,
color: Colors.yellow,
),
Positioned( top: 0, right: 0, child: IconButton( icon: Icon(Icons.close), onPressed: () {}, ), ) ],
),
),
);
},
),
),
);
}
}
Going back to DevTools and checking Stack's size both in Select Widget
mode to check visually and in Details tree
for seeing info
Details tree:
Select Widget mode:
This fixes it! Let's fix our issue by doing same change of swapping Align with Positioned widget in our wrapper function of showBottomDialog
.
import 'package:flutter/material.dart';
Future<T> showBottomDialog<T>({
BuildContext context,
String title,
String content,
Widget titleWidget,
Widget contentWidget,
List<Widget> actions,
bool allowBackNavigation = false,
bool showCloseButton = false,
Function onClose,
}) {
assert(title != null || titleWidget != null,
'title and titleWidget must not both be null');
assert(content != null || contentWidget != null,
'content and contentWidget must not both be null');
final theme = Theme.of(context);
return showModalBottomSheet(
...
builder: (context) => WillPopScope(
...
child: Stack(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...
],
),
),
if (showCloseButton)
Positioned( top: 0, right: 0, child: IconButton( icon: Icon( Icons.close, size: 24, ), onPressed: onClose ?? () => Navigator.pop(context), ), ), ],
),
),
);
}
Looks Good ❤️
Closing Note
You'd think it was apparent to change it from Align
to Positioned
widget if I had read the Align and Positioned documents correctly first. You're right! And, i did that too and immediately replaced Align with Positioned widget as my 1st trail and error approach.
However, i realized i don't really understand how all these widgets like BoxConstraints, BottomSheet, Stack, Align, Positioned, etc. calculate their sizes and several other related things. Looked like a solid opportunity to learn it and teach it in case someone is not aware about it. Sharing in hope that this might help someone to use similar debugging process of DevTools + Documentation to debug and fix similar layout issues they might face in future.
Hope that was useful! 🙂