Debugging layout sizing problems in Flutter

August 16, 2020 ■ 18 min read

First of all this post is not going to be a post explaining how to debug common layout problems 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 those 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 on how to debug such problems using combination of reading Flutter documentation along with using Dart Dev tools to understand and fix this problem rather than going with trial-and-error approach or looking for Stackoverflow answers 😜. Bonus point it also helped me learn a couple of concepts in Flutter related to BoxConstraints, ModalBottomSheet, Align, Stack, Positioned widgets as well which were not clear to me earlier. Sharing in hope that this process of debugging and learning some concepts which we will will discuss might be useful to someone in future. So, without any further delay let's start 🚀

I've divided this post into following 4 parts:

  1. Explaining Problem
  2. Using Dart Dev tools
  3. Reading Flutter documentation
  4. Fixing Problem

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:

BottomSheet Visual

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 an IconButton on top right corner of the dialog to be able to close it when 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 and change it 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:

BottomSheet Incorrect sizing

WAIT! WTH just happened? Why is our bottom sheet now taking more space than the content and also is taking up almost half height of the screen?

Now we know what problem is? So, let's figure it out together 😁


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.

Devtool_Bottom_Sheet_Layout

Highlighted area represents our BottomSheet's layout hierarchy. Let's turn on Select Widget mode in DevTool and start selecting widgets to see which widget is taking how much space in layout.

Selecting Stack in widget tree shows following on our device. Devtool_Select_Widget_Mode_Stack_Selected

Okay! Let's select Stack's children one by one.

  1. Padding with Column
  2. Align
  3. IconButton

Padding selected:

Devtool_Select_Widget_Padding_Selected

Align selected:

Devtool_Select_Widget_Align_Selected

IconButton selected: Devtool_Select_Widget_Iconbutton_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 to MainAxis.min.
  • IconButton is taking up its default icon size of 24 plus padding of 8 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 look at Flutter documentation of all the related widgets to understand the mistake we might be making in all this work.

Reading Flutter documentation

Before we start reading documentation let's first look at dimensions(width and height) of Stack in DevTools.

Devtool_Stack_Dimensions

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) which 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.

 
  Widget 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,                 ...
                ...
              ),
            ),
          ),
        );
      },
    );
  }

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! So, that is the reason all BottomSheets by default have full screen width and can have max height of 9/16 of screen's height. Now we know! One mystery solved. Phew! :D

So, 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! 😅

Ohhkay! 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:

Stack_Non_Positioned_Width_Height_Calc

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:

Stack_Positioned_And_Non_Positioned_Calc

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 and just setting similar BoxConstraints on it 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 after reading docs shouldn't Stack's size be set to max of non-positioned children size and that should be:

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 🤬.

Select Widget Mode screenshot:

Devtool_Stack_Wrong_Height

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! So, looks like we don't understand Align correctly. So, 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 closeIconButton at correct place in top right corner which it did 😅 but thereby messing up Stack's height. So, what is the obvious replacement in this case? Positioned widget? Because that is what is the purpose of positioned widget is, correct? It is to position children in Stack layout.

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:

Devtool_Stack_Size_With_Positioned_Widget

Select Widget mode:

Devtool_Stack_Size_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 ❤️

Bottomsheet_Positioned_Widget_Fix

Closing Note

You must be thinking it was obvious to change it from Align to Positioned have i read the docs of Align and Positioned correctly first. You're right! And, i did that too and immediately replaced Align with Positioned.

However, i realized i don't really understand how this BoxConstraints, BottomSheet, Stack, Align, Positioned, etc. calculate their sizes and several other related things. So, this looked like an opportunity to learn it and teach it in case someone is not aware about using this debugging process of using DevTools + Documentation combination to debug and fix layout issues they might face in future.

Hope that was useful! 🙂

Want to be notified of similar posts? Follow me @punit__d on Twitter 🙂