İçeriğe geç / Skip to content / Zum Inhalt

Flutter: Screen Size-Based Design with LayoutBuilder

Ahmet Balaman

Last updated:

9 min read

FlutterLayoutBuilderResponsiveBoxConstraintsAdaptiveUI
Flutter: Screen Size-Based Design with LayoutBuilder

LayoutBuilder hands you the BoxConstraints coming from its parent during build and lets you return a different widget tree depending on them. A card that stacks vertically on a phone and sits side by side on a tablet; a side panel that only appears when the width allows; an image that takes eighty percent of the space it was given: all of that is done with this widget. This post focuses on the concept itself: how LayoutBuilder differs from MediaQuery, what is inside the constraints object, and where the unbounded height and "intrinsic dimensions" errors come from. After that it moves on to a phone/tablet/desktop breakpoint layout based on the Material 3 window size classes. Proportional sizing against the screen is covered separately in the MediaQuery post.

Live Demo: LayoutBuilder Responsive Design

Drag the vertical divider to widen and narrow the example and watch the layout change:

💡 If the example above doesn't load, click DartPad to run it in a new tab.

Basic Usage

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    return Text('Available width: ${constraints.maxWidth}');
  },
)

Unlike a normal build method, builder is called during the layout phase, after the parent's constraints are known. That is why the tree you return can be shaped by the real available space. Whenever the constraints change (the screen rotates, the window is resized, a side panel opens), the builder runs again.

The BoxConstraints object holds four limits: minWidth, maxWidth, minHeight, maxHeight. A few helpers sit next to them: hasBoundedWidth and hasBoundedHeight tell you whether there is an upper limit on that axis, isTight whether min and max are equal, and biggest gives the largest Size that fits. In practice maxWidth is read most; before you read maxHeight, always check hasBoundedHeight, and I will explain why below.

LayoutBuilder vs MediaQuery

The single most important idea is this: MediaQuery.sizeOf(context) gives you the size of the screen, while LayoutBuilder gives you the space the parent allotted to you. The two report the same number only when your widget fills the whole screen.

MediaQuery measures the whole window while LayoutBuilder measures only the space given to the widget, such as a card inside a side panel

Picture a product card. The same card is used in a full-width list on a phone and in a 300 pixel side panel on a tablet. If the card asks MediaQuery "is the screen wider than 900?" to decide on a horizontal layout, it will try to squeeze a horizontal layout into 300 pixels inside the tablet's side panel and overflow. If it asks LayoutBuilder "was I given less than 500?", it behaves correctly wherever it is placed.

Rule of thumb: if the decision is about the widget's own space, use LayoutBuilder; if it is about the device (orientation, keyboard height, safe area, text scale), use MediaQuery.

Breakpoints and Window Size Classes

Material 3 splits the window width into classes: below 600 dp is compact (phone), 600 to 839 is medium (small tablet, phone in landscape), 840 and above is expanded (tablet, desktop). There are also large and extra-large classes for very wide screens, starting at 1200 dp. Taking your thresholds from these classes is more consistent than picking arbitrary numbers:

enum ScreenType { compact, medium, expanded }

ScreenType screenTypeFor(double width) {
  if (width < 600) return ScreenType.compact;
  if (width < 840) return ScreenType.medium;
  return ScreenType.expanded;
}

LayoutBuilder(
  builder: (context, constraints) => switch (screenTypeFor(constraints.maxWidth)) {
    ScreenType.compact => const MobileBody(),
    ScreenType.medium => const TabletBody(),
    ScreenType.expanded => const DesktopBody(),
  },
)

Keep the thresholds in one function; if the number < 600 is scattered across ten places, changing it later is painful. You can derive the column count from the same place: for a GridView's crossAxisCount, a computation such as constraints.maxWidth ~/ 180 (making sure the result is at least 1) adapts more smoothly than fixed breakpoints.

How the skeleton changes per class

A breakpoint is not just a threshold; the skeleton of the screen changes with each class. A common split looks like this:

  • Compact: One column, content flows vertically; the main navigation sits at the bottom.
  • Medium: Navigation can move to the side; list and detail start to sit next to each other. Expanded is the most practical tool for sharing the remaining width between columns: Row(children: [SizedBox(width: 240, child: SideMenu()), Expanded(child: Content())]).
  • Expanded and up: Several panes sit side by side, and the content is capped at a maximum width.

The reason for the last point is readability: on a wide monitor, lines of text that run across the whole screen make the eye lose its place when jumping back to the start of the next line, and pushing the content to the left with the right side empty makes the page look half finished. Centering the content and capping its width solves both:

Center(
  child: ConstrainedBox(
    constraints: const BoxConstraints(maxWidth: 1200),
    child: const Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Expanded(flex: 2, child: ContentArea()),
        SizedBox(width: 24),
        Expanded(child: Sidebar()),
      ],
    ),
  ),
)

The 1200 here is not a fixed rule but the readable width of your design. The same layout also works inside a window: when the user narrows the window on desktop, LayoutBuilder runs again and the layout drops to a smaller class.

Proportional Sizing

Ratios like constraints.maxWidth * 0.8 work well for widths. Do not scale font sizes to the width, though: the text becomes unreadable on a small phone and poster-sized on a wide monitor. Font size should come from the theme and the user's accessibility settings. If all you want is a proportional width, you do not even need LayoutBuilder; FractionallySizedBox(widthFactor: 0.8, child: ...) does the same job in a single widget.

When to Use It and When Not To

Reach for LayoutBuilder:

  • When a widget must build a different structure based on its own space (switching Row/Column, hiding a panel, choosing a column count).
  • When you write a reusable component and do not know where it will be placed.
  • When you need to find out whether the parent is unbounded and act accordingly.

Switch to another tool:

  • For device-level decisions, MediaQuery (orientationOf, paddingOf, textScalerOf).
  • For a plain portrait/landscape split, OrientationBuilder; it uses LayoutBuilder internally but reads more clearly.
  • For sharing the remaining space, Expanded and Flexible; they need no measuring.
  • For pushing overflow onto the next line, Wrap; it saves you from writing breakpoints for chip and tag lists.

Common Mistakes

1. Reading maxHeight inside a Column or ListView

Symptom: BoxConstraints forces an infinite height. or a layout error because a Container height is Infinity. Column and scrollable widgets give their children unbounded height along the main axis; constraints.maxHeight returns double.infinity there, and turning that into a size blows up.

LayoutBuilder(
  builder: (context, constraints) {
    final height = constraints.hasBoundedHeight
        ? constraints.maxHeight * 0.5
        : 200.0; // a sensible constant when unbounded
    return SizedBox(height: height, child: const Placeholder());
  },
)

Fix: check hasBoundedHeight, or move the LayoutBuilder somewhere with a known height (Expanded, SizedBox, Scaffold body).

2. "LayoutBuilder does not support returning intrinsic dimensions"

Symptom: exactly that error in the console. LayoutBuilder only knows its child after constraints arrive, so it cannot answer the question "what is your natural size?". The widgets that ask it are IntrinsicHeight, IntrinsicWidth, Table with intrinsic column widths and, watch out, the content area of AlertDialog. Putting a LayoutBuilder directly inside a dialog triggers this error. Fix: remove the intrinsic wrapper, give the dialog a concrete size such as SizedBox(width: 400, child: ...), or pass the decision in as a parameter from outside instead of using LayoutBuilder.

3. Calling setState inside the builder

Symptom: setState() or markNeedsBuild() called during build. The builder runs during layout; changing state there leaves the tree inconsistent. Instead of writing a width-dependent value into state, compute it as a local variable inside the builder and use it directly.

4. Using LayoutBuilder to measure the screen

Symptom: a widget inside a side panel falls back to the "mobile" layout instead of "desktop". LayoutBuilder reports the parent's space, not the screen; inside a 300 pixel panel you will always read 300. If you want a device decision, use MediaQuery.sizeOf(context).width. Strictly speaking this is not a bug but exactly what LayoutBuilder is supposed to do; be clear about your intent so it does not surprise you.

Mini Scenario: A Product Card That Fits Anywhere

In an e-commerce app the product card is used both in the full-width list on the home page and in the narrow column of the cart screen. When the space it receives is under 420 pixels, the card puts the image on top; above that, the image sits on the left and the text on the right.

class ProductCard extends StatelessWidget {
  const ProductCard({super.key, required this.title, required this.price, required this.imageUrl});

  final String title;
  final String price;
  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      child: LayoutBuilder(
        builder: (context, constraints) {
          final isNarrow = constraints.maxWidth < 420;

          final image = AspectRatio(
            aspectRatio: isNarrow ? 16 / 9 : 1,
            child: Image.network(imageUrl, fit: BoxFit.cover),
          );

          final info = Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(title, style: Theme.of(context).textTheme.titleMedium),
                const SizedBox(height: 8),
                Text(price, style: Theme.of(context).textTheme.bodyLarge),
                const SizedBox(height: 12),
                FilledButton(onPressed: () {}, child: const Text('Add to Cart')),
              ],
            ),
          );

          if (isNarrow) {
            return Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              mainAxisSize: MainAxisSize.min,
              children: [image, info],
            );
          }

          return Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              SizedBox(width: 160, child: image),
              Expanded(child: info),
            ],
          );
        },
      ),
    );
  }
}

The card does not know where it is used and does not need to: in a list on a phone it gets 360 pixels and chooses the vertical arrangement; in a 700 pixel area on a tablet it switches to horizontal; placed in a 300 pixel side panel on that same tablet it goes back to vertical. Written with MediaQuery, the third case would break. In the horizontal layout Expanded lets the text take the width left over by the image; why that is necessary is explained in the Expanded post.

Frequently Asked Questions

What is the difference between LayoutBuilder and MediaQuery?

MediaQuery gives the size of the screen (more precisely, the app window); LayoutBuilder gives the space the parent allotted to that widget. If the widget does not fill the whole screen, the two values differ; when a component decides based on its own space, LayoutBuilder is the right tool.

Does LayoutBuilder rebuild every frame?

No. The builder runs only when the constraints coming from the parent change or when the LayoutBuilder itself is rebuilt. Still, avoid heavy computation inside the builder; constraint changes arrive in quick succession while a window is being resized.

Why does constraints.maxHeight return Infinity?

Because the parent set no limit on that axis; Column, ListView and SingleChildScrollView pass unbounded constraints along their main axis. Check hasBoundedHeight, or move the LayoutBuilder under a parent with a known height.

Can LayoutBuilder be used to find out a child's size?

No. LayoutBuilder tells you how much space the child may take, not how much it ended up using. Reading a widget's real size requires other techniques, such as reaching its RenderBox through a GlobalKey after layout has completed.

Which breakpoint values should I use?

If you are starting from scratch, the Material 3 window size classes are a good base: below 600 dp compact, 600 to 839 medium, 840 and above expanded. Keep the values in a single function and make the decision based on the width coming from LayoutBuilder, not on the screen; that way the same component behaves correctly in a narrow panel too.

Comments