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

Flutter: Expanded vs Flexible vs Spacer, With Recipes

Ahmet Balaman

Last updated:

8 min read

FlutterExpandedFlexibleSpacerLayoutResponsive
Flutter: Expanded vs Flexible vs Spacer, With Recipes

There are three tools for sharing space inside a Row or Column: Expanded fills the remaining space, Flexible lets a child stay small, Spacer only opens a gap. This post first shows how the three differ on a single row, then walks through four everyday scenarios that combine them in one layout (a toolbar, a card footer, form rows and a responsive split screen) with code and reasoning. The single-widget details live in their own posts: flex math, nesting and Expanded errors in the Expanded deep dive, gap ratios and the scrollable-area traps in the Spacer post.

Live Demo

You can try the Expanded widget in the interactive example below:

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

Core Difference

All three are part of the same mechanism: a Row or Column first measures its fixed-size children, then shares the remaining space among the children that have a flex value. What differs is what the child does with its share.

Expanded takes all remaining space, Flexible takes only what it needs, and Spacer pushes the two boxes apart

  • Expanded: The child must fill its whole share. It is shorthand for Flexible(fit: FlexFit.tight).
  • Flexible: The default fit is FlexFit.loose, which means the child may take "at most" its share. If its own size is smaller it stays small; if it does not fit it is squeezed into the share.
  • Spacer: An Expanded with nothing inside; it uses its share as invisible space and carries no content.

To see the difference between Flexible and Expanded, the child needs a size of its own. A short label is enough:

Row(
  children: [
    Flexible(
      child: Container(
        color: Colors.green,
        padding: const EdgeInsets.all(8),
        child: const Text('Short label'),
      ),
    ),
    const SizedBox(width: 8),
    const Icon(Icons.info_outline),
  ],
)

In this row the green area is as wide as the text and the icon sits right next to it. Make the text too long for the row and Flexible caps it at the remaining width, so the text wraps to a second line instead of overflowing. Write Expanded instead of Flexible in the same code and the green area stretches to the icon even with the short text, pushing the icon to the right edge.

Decision Table

Before the recipes, the short way to decide which widget goes on which child:

Need Tool
The child should fill all remaining space Expanded
The child keeps its own size but shrinks when it does not fit Flexible
Flexible gap between two items Spacer
Fixed gap between two items SizedBox
Items should wrap to the next line when they do not fit Wrap

Recipe 1: Toolbar

Picture the bottom bar of an editor screen: two icons on the left, a "Save" button on the right. Icons and button keep their own size; the gap between them should be flexible:

Row(
  children: [
    IconButton(onPressed: () {}, icon: const Icon(Icons.undo)),
    IconButton(onPressed: () {}, icon: const Icon(Icons.redo)),
    const Spacer(),
    FilledButton(onPressed: () {}, child: const Text('Save')),
  ],
)

Spacer could be replaced by mainAxisAlignment.spaceBetween here, but spaceBetween equalizes every gap; if the two icons must stay side by side, Spacer is the right tool. To add a title in the middle, put Expanded(child: Text(title, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis)) in place of the Spacer: the title takes the remaining space and is cut with an ellipsis when it is long.

The bottom line of a product card: price on the left, a favorite icon and an "Add to cart" button on the right:

Row(
  children: [
    Text('\$24.90', style: Theme.of(context).textTheme.titleMedium),
    const Spacer(),
    IconButton(onPressed: () {}, icon: const Icon(Icons.favorite_border)),
    FilledButton.tonal(onPressed: () {}, child: const Text('Add to cart')),
  ],
)

This line can overflow on a narrow screen when the price and the button label are long. Because Spacer is a flex child, it can shrink to zero; the overflow starts when the fixed children together are wider than the Row. At that point wrap the price in Flexible with overflow: TextOverflow.ellipsis, or replace the row with Wrap. The card component itself is covered in the Card usage post.

Recipe 3: Form Rows

In an address form, "City" and "Postal code" share one line, with the city field twice as wide:

Row(
  children: [
    Expanded(
      flex: 2,
      child: TextFormField(
        decoration: const InputDecoration(labelText: 'City'),
      ),
    ),
    const SizedBox(width: 12),
    Expanded(
      child: TextFormField(
        keyboardType: TextInputType.number,
        decoration: const InputDecoration(labelText: 'Postal code'),
      ),
    ),
  ],
)

Text fields inside a Row must be Expanded; left bare, the app throws (see below). The gap between them is fixed, so it is a SizedBox, not a Spacer. Validation and the Form widget are explained in the Form usage post.

Recipe 4: Responsive Split Screen

On tablet and desktop a list on the left and a detail pane on the right; on a phone only the list. The decision should depend on the width given to this widget, not on the screen width, which is what LayoutBuilder is for:

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth < 720) {
      return const NoteList();
    }
    return const Row(
      children: [
        SizedBox(width: 320, child: NoteList()),
        VerticalDivider(width: 1),
        Expanded(child: NoteDetail()),
      ],
    );
  },
)

The list is a fixed 320 pixels and the detail pane takes everything else. If the list should grow proportionally too, replace the SizedBox with Expanded(flex: 1) and give the detail pane flex: 2. Choosing between LayoutBuilder and MediaQuery is covered in the MediaQuery post, and a full breakpoint-based responsive layout in the LayoutBuilder post.

When to Use It and When Not To

These three are the right tools in every Row and Column where space is shared between siblings. But in three situations you need something else:

  • If the Row or Column sits inside a SingleChildScrollView, the main axis has no limit; Expanded and Spacer do not work there. Give a fixed size or move the scrolling out of the outer widget.
  • For chips and tags of unknown count, even Flexible is not enough; items must move to the next line. Use Wrap.
  • If the Row must wrap its content (mainAxisSize: MainAxisSize.min), Expanded and Spacer defeat that intent, because both demand all remaining space. In such rows use only Flexible and SizedBox. How a Row picks its own width is explained in the mainAxisSize post.

Common Mistakes

1. A bare TextField inside a Row

Symptom: An InputDecorator, which is typically created by a TextField, cannot have an unbounded width. A Row gives its children no width limit, and the text field cannot know how wide to be. The fix is wrapping the TextField in Expanded or Flexible. If you want a fixed width, SizedBox(width: 120, child: TextField(...)) also works.

2. Writing Flexible and expecting it to fill

Symptom: You wrote Flexible(child: Container(color: Colors.blue, child: Text('A'))) and the blue area is only as wide as the text. The default fit of Flexible is FlexFit.loose: the child is not forced to grow, and the Container wraps its child. If it should fill, use Expanded or set fit: FlexFit.tight.

3. Testing Flexible with a childless Container

Symptom: You wrote Flexible(child: Container(color: Colors.green)), the result looks exactly like Expanded, and you conclude there is no difference. There is one, but this example cannot show it. A Container with no child and no size grows as large as possible when it receives bounded constraints. A loose constraint says "at most this much", so the Container simply picks the largest allowed size and fills the whole share. To see the difference, use a child with its own size, like the label example above.

4. Spacer inside a scrollable area

SingleChildScrollView(
  child: Column(
    children: const [
      Text('Top'),
      Spacer(), // Error
      Text('Bottom'),
    ],
  ),
)

Symptom: RenderFlex children have non-zero flex but incoming height constraints are unbounded. Inside a scroll area the height is infinite and "remaining space" is undefined. Use a fixed SizedBox(height: 24); if you really want to pin the bottom item to the bottom of the screen, take the Column out of the scroll area and make the content part Expanded(child: SingleChildScrollView(...)).

5. Using them inside a non-Flex parent

All three can only be direct children of a Row, Column or Flex. Put them inside a Stack, Container or Padding and you get Incorrect use of ParentDataWidget. The most common form is a Padding slipping in between Expanded and its Row; the fix (swap the order) is covered in detail in the Expanded post.

Mini Scenario: A Product List Row

A product row that brings the four recipes together. Thumbnail on the left, title and description in the middle, price on the right; below, a favorite icon and an add-to-cart button:

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

  final String title;
  final String subtitle;
  final String price;

  @override
  Widget build(BuildContext context) {
    final text = Theme.of(context).textTheme;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const SizedBox(width: 64, height: 64, child: Placeholder()),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(title, style: text.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis),
                      Text(subtitle, style: text.bodySmall, maxLines: 2, overflow: TextOverflow.ellipsis),
                    ],
                  ),
                ),
                const SizedBox(width: 12),
                Text(price, style: text.titleMedium),
              ],
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                const Spacer(),
                IconButton(onPressed: () {}, icon: const Icon(Icons.favorite_border)),
                FilledButton.tonal(onPressed: () {}, child: const Text('Add to cart')),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

In the top row the image and the price are fixed, and the middle Column takes all the width between them through Expanded; a long title is cut with an ellipsis instead of overflowing, because Expanded gives the text a bounded width. The gaps are fixed, so they are SizedBox. In the bottom row the Spacer pushes the icon and the button to the right; since nothing sits on the left, mainAxisAlignment.end would give the same result, but Spacer keeps working without changes when you later add an "In stock" label on the left. The card's outer Column is mainAxisSize.min, so it contains no Expanded; if it did, the card would throw an infinite height error inside a list row.

Frequently Asked Questions

Why does a TextField inside a Row throw an error?

A Row gives its children no width limit, and a text field cannot have unbounded width. Wrap the TextField in Expanded or Flexible, or give it a fixed width with SizedBox.

Spacer or mainAxisAlignment?

If all gaps should be equal, spaceBetween or spaceEvenly is enough. If the gap must sit in a specific place or be shared in different proportions, use Spacer(flex:). Do not combine the two in one row: the Spacer takes all the free space, so spaceBetween has no visible effect.

When is Flexible preferred over Expanded?

When the child should keep its own size but shrink when space runs out. In content-wrapping layouts such as chat bubbles and chips Flexible is right; where all remaining space must be filled, Expanded is the choice.

Why do these widgets not work inside a ScrollView?

A scroll area offers infinite space on the main axis, and "remaining space" cannot be computed. Give a fixed size, or move the scrolling into the inner part wrapped in Expanded rather than around the Column.

Comments