Chương 03 · Layout System

Layout System

Layout là chương debug nhiều nhất của Flutter dev. 80% error message Flutter là về constraint/size — không hiểu mental model thì cứ try-and-error mãi. Đặc biệt error "RenderFlex overflowed by X pixels" hay "unbounded height" — gặp mỗi tuần.

Độ dài: ~1100 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2 (widget tree)
🎯 Mục tiêu chương
  • Hiểu mental model: "Constraints flow down, Sizes flow up, Parent positions".
  • Sử dụng Row, Column với MainAxisAlignment, CrossAxisAlignment.
  • Sử dụng Expanded, Flexible, Spacer cho responsive.
  • Sử dụng Stack + Positioned cho overlap.
  • Container, Padding, SizedBox, Align, Center.
  • ConstrainedBox, LimitedBox, FractionallySizedBox.
  • Debug layout với debugPaintSizeEnabled = true và Flutter Inspector.
  • Hiểu khi nào widget bị "unbounded constraint" lỗi.

1. Mental model — Constraints flow

Quy tắc 3 dòng — luật vật lý của Flutter layout:

🧠 Luật 3 dòng
  1. Parent pass constraint xuống child.
  2. Child pick size trong constraint, trả lên parent.
  3. Parent position child theo logic của nó.
Center(
  child: Container(
    width: 100,
    height: 100,
    color: Colors.red,
  ),
)

Flow:

  1. Screen pass constraint (vd BoxConstraints(0..390, 0..844) trên iPhone) xuống Center.
  2. Center pass cùng constraint xuống Container.
  3. Container pick size (100, 100) (vì có width + height), trả lên Center.
  4. Center position container vào giữa screen size.

2. BoxConstraints

BoxConstraints(
  minWidth: 0,
  maxWidth: 390,
  minHeight: 0,
  maxHeight: 844,
)

// Tight — min == max (fixed size, no choice)
BoxConstraints.tight(Size(100, 100));
// → minWidth: 100, maxWidth: 100, minHeight: 100, maxHeight: 100

// Loose — min == 0 (có lựa chọn, max là giới hạn)
BoxConstraints.loose(Size(100, 100));
// → minWidth: 0, maxWidth: 100, minHeight: 0, maxHeight: 100

Phân loại:

LoạiĐịnh nghĩaVí dụ widget cấp
Tightmin == max — child không có lựa chọnScreen root, SizedBox với cả w+h
Loosemin == 0 — child tự do trong maxCenter, Align (loosen child)
Boundedmax là finiteĐa số case
Unboundedmax = infinityListView, SingleChildScrollView (theo axis cuộn)

3. RowColumn

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,   // trục chính (ngang)
  crossAxisAlignment: CrossAxisAlignment.center,        // trục phụ (dọc)
  children: [
    const Icon(Icons.menu),
    const Text('Title'),
    const Icon(Icons.search),
  ],
)

Column(
  mainAxisAlignment: MainAxisAlignment.center,         // trục chính (dọc)
  crossAxisAlignment: CrossAxisAlignment.stretch,        // trục phụ (ngang)
  children: [/*...*/],
)

MainAxisAlignment values

  • start — dồn về đầu trục chính.
  • end — dồn về cuối.
  • center — dồn vào giữa.
  • spaceBetween — child đầu/cuối sát mép, giữa chia đều.
  • spaceAround — child có khoảng cách bằng nhau quanh.
  • spaceEvenly — khoảng cách giữa các child + mép đều nhau.

CrossAxisAlignment values

  • start, end, center — căn theo trục phụ.
  • stretch — child fill toàn bộ trục phụ.
  • baseline — căn theo text baseline (cần textBaseline).

4. Expanded & Flexible

Dùng trong Row/Column/Flex để chia phần space còn lại.

Row(
  children: [
    Expanded(
      flex: 1,
      child: Container(height: 50, color: Colors.red),
    ),
    Expanded(
      flex: 2,
      child: Container(height: 50, color: Colors.green),
    ),
    Expanded(
      flex: 1,
      child: Container(height: 50, color: Colors.blue),
    ),
  ],
)
// Chia tỷ lệ 1:2:1 — red 25%, green 50%, blue 25%

Expanded = Flexible(fit: FlexFit.tight). Khác biệt:

  • Expanded — child BUỘC fill phần space chia (tight).
  • Flexible — child có thể nhỏ hơn (loose, fit: loose).

Spacer

Row(
  children: [
    const Text('Left'),
    const Spacer(),       // = Expanded(child: SizedBox())
    const Text('Right'),
  ],
)
// Left | (space) | Right

5. Stack & Positioned

Overlap children — child sau vẽ đè child trước.

Stack(
  alignment: Alignment.center,    // align cho non-positioned children
  children: [
    // Background
    Container(height: 200, color: Colors.amber),

    // Centered text (non-positioned — dùng stack alignment)
    const Text('Title', style: TextStyle(fontSize: 24)),

    // Positioned
    const Positioned(
      top: 10,
      right: 10,
      child: Icon(Icons.favorite, color: Colors.red),
    ),

    // Positioned.fill
    Positioned.fill(
      bottom: 0,
      top: null,
      child: Container(height: 30, color: Colors.black54),
    ),
  ],
)

6. Container — composite widget

Containerkitchen sink — combine Padding + ColoredBox + DecoratedBox + ConstrainedBox + Align + Transform. Dùng nhanh nhưng đôi khi nên tách primitive cho performance.

Container(
  width: 200,
  height: 100,
  margin: const EdgeInsets.all(8),
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    border: Border.all(color: Colors.grey),
    boxShadow: [
      BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2)),
    ],
    gradient: const LinearGradient(
      colors: [Colors.amber, Colors.orange],
    ),
  ),
  child: const Text('Card'),
)
💡 Khi nào tách primitive thay Container?

Container chỉ cần padding 8 → dùng Padding trực tiếp. Chỉ cần color → ColoredBox. Primitive nhẹ hơn Container (Container compose nhiều thứ).

7. SizedBox + Padding + Center/Align

SizedBox

const SizedBox(width: 100, height: 50)
const SizedBox(height: 16)               // spacer dọc
const SizedBox(width: 8)                 // spacer ngang
const SizedBox.shrink()                    // (0, 0) — invisible spacer
const SizedBox.expand()                    // fill cha

Padding & EdgeInsets

EdgeInsets.all(16)
EdgeInsets.symmetric(horizontal: 16, vertical: 8)
EdgeInsets.only(left: 8, top: 4)
EdgeInsets.fromLTRB(8, 4, 8, 16)        // left, top, right, bottom

Center & Align

const Center(child: Text('centered'))

// Center là Align với alignment center
const Align(
  alignment: Alignment.bottomRight,
  child: Text('bottom right'),
)

// Alignment values: 9 preset (-1..1, -1..1)
Alignment.topLeft
Alignment.topCenter
Alignment.topRight
Alignment.centerLeft
Alignment.center
Alignment.centerRight
Alignment.bottomLeft
Alignment.bottomCenter
Alignment.bottomRight

// Custom
Alignment(0.5, -0.7)   // 50% right of center, 70% up from center

8. ConstrainedBox / LimitedBox / FractionallySizedBox

// ConstrainedBox — apply constraint thêm cho child
ConstrainedBox(
  constraints: const BoxConstraints(minHeight: 50, maxHeight: 200),
  child: Text(longText),
)

// LimitedBox — chỉ apply khi parent unbounded
// VD trong ListView, cha cho child unbounded height
LimitedBox(
  maxHeight: 200,
  child: SomeWidget(),
)

// FractionallySizedBox — size theo % parent
FractionallySizedBox(
  widthFactor: 0.5,        // 50% width của parent
  heightFactor: 0.3,
  child: Container(color: Colors.red),
)

9. AspectRatio

AspectRatio(
  aspectRatio: 16 / 9,            // w/h
  child: Container(color: Colors.amber),
)

// Hữu ích cho video player, image, card
AspectRatio(
  aspectRatio: 1,
  child: Image.network(url),       // vuông 1:1
)

10. 3 lỗi phổ biến + fix

10.1 "RenderFlex overflowed by X pixels"

// ❌ Lỗi — text dài quá row
Row(
  children: [
    const Icon(Icons.info),
    Text('Lorem ipsum dolor sit amet consectetur adipiscing elit ...'),
  ],
)

// ✅ Fix: wrap Expanded
Row(
  children: [
    const Icon(Icons.info),
    Expanded(child: Text('Lorem ipsum ...')),
  ],
)

// ✅ Fix khác: SingleChildScrollView horizontal
SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Row(children: [/*long row*/]),
)

// ✅ Fix khác: FittedBox shrink
FittedBox(
  fit: BoxFit.scaleDown,
  child: Row(children: [/*long*/]),
)

10.2 "Vertical viewport was given unbounded height"

// ❌ Lỗi — ListView trong Column không có constraint
Column(
  children: [
    const Text('Header'),
    ListView(children: [/*...*/]),  // ListView muốn height infinity
  ],
)

// ✅ Fix 1: Expanded
Column(
  children: [
    const Text('Header'),
    Expanded(child: ListView(children: [/*...*/])),
  ],
)

// ✅ Fix 2: SizedBox fixed
Column(
  children: [
    const Text('Header'),
    SizedBox(
      height: 300,
      child: ListView(children: [/*...*/]),
    ),
  ],
)

// ✅ Fix 3: shrinkWrap (cẩn thận performance, ListView không lazy nữa)
Column(
  children: [
    const Text('Header'),
    ListView(shrinkWrap: true, children: [/*...*/]),
  ],
)

10.3 "Expanded must be a descendant of a Flex"

// ❌ Lỗi — Expanded chỉ dùng trong Row/Column/Flex
Container(
  child: Expanded(child: Text('...')),  // crash
)

// ✅ Fix: wrap Row/Column trước, hoặc dùng widget khác
SizedBox(
  width: double.infinity,
  child: Container(child: Text('...')),
)

11. Debug layout

// Trong main() — vẽ border quanh mọi widget
import 'package:flutter/rendering.dart';

void main() {
  debugPaintSizeEnabled = true;
  runApp(const MyApp());
}

Flag debug khác:

  • debugPaintBaselinesEnabled — vẽ text baseline.
  • debugPaintPointersEnabled — highlight tap area.
  • debugRepaintRainbowEnabled — màu thay đổi mỗi repaint (chương 13).

Flutter Inspector trong DevTools — bật "Select Widget Mode" → tap widget trên app → xem constraint, size, vị trí. Là tool quan trọng nhất khi debug layout.

12. Bài tập

Twitter card layout

Recreate Twitter post layout: avatar + name + handle + text content + action row (4 icon). Chỉ widget chương 3.

💡 Gợi ý đáp án
Padding(
  padding: const EdgeInsets.all(16),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const CircleAvatar(radius: 24, backgroundColor: Colors.amber),
      const SizedBox(width: 12),
      Expanded(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: const [
                Text('Việt', style: TextStyle(fontWeight: FontWeight.bold)),
                SizedBox(width: 6),
                Text('@viet · 2h', style: TextStyle(color: Colors.grey)),
              ],
            ),
            const SizedBox(height: 4),
            const Text('Đang học Flutter với IT Basic. Layout thật là phức tạp!'),
            const SizedBox(height: 12),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: const [
                _ActionIcon(icon: Icons.chat_bubble_outline, label: '12'),
                _ActionIcon(icon: Icons.repeat, label: '5'),
                _ActionIcon(icon: Icons.favorite_border, label: '42'),
                _ActionIcon(icon: Icons.share, label: ''),
              ],
            ),
          ],
        ),
      ),
    ],
  ),
)

Responsive 2-column với LayoutBuilder

Build responsive layout: nếu screen width > 600 thì 2 column ngang 1:1, ngược lại 1 column dọc. Dùng LayoutBuilder.

💡 Gợi ý đáp án
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return Row(
        children: [
          Expanded(child: _ColumnA()),
          Expanded(child: _ColumnB()),
        ],
      );
    }
    return Column(
      children: [_ColumnA(), _ColumnB()],
    );
  },
)

LayoutBuilder cho phép build dựa trên constraint của parent — useful cho responsive.

Stack profile header

Tạo profile header với background gradient phía sau, avatar tròn center bottom (50% chìm dưới header). Dùng Stack + Positioned.

💡 Gợi ý đáp án
Stack(
  clipBehavior: Clip.none,
  alignment: Alignment.bottomCenter,
  children: [
    Container(
      height: 200,
      decoration: const BoxDecoration(
        gradient: LinearGradient(colors: [Colors.purple, Colors.pink]),
      ),
    ),
    Positioned(
      bottom: -50,   // 50% radius (radius=50, full=100)
      child: Container(
        width: 100, height: 100,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.white,
          border: Border.all(color: Colors.white, width: 4),
        ),
      ),
    ),
  ],
)

clipBehavior: Clip.none cho phép child overflow ra ngoài Stack.

Debug overflow 3 cách fix

Cho code có "RenderFlex overflowed":

Row(
  children: [
    const Icon(Icons.info),
    Text('A very very long text that overflows the row width'),
  ],
)

Fix bằng 3 cách: Expanded, SingleChildScrollView, FittedBox. Trong mỗi cách giải thích trade-off.

💡 Gợi ý đáp án

Expanded: text auto wrap xuống nhiều dòng.

SingleChildScrollView: user scroll ngang, không wrap. UX có thể không tối ưu.

FittedBox(fit: BoxFit.scaleDown): scale down toàn row cho vừa, có thể text nhỏ khó đọc.

Pricing 3 plan side-by-side

Build pricing card 3 plan (Free / Pro / Enterprise) hiển thị side-by-side với Row + Expanded.

💡 Gợi ý đáp án
Row(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: const [
    Expanded(child: _PlanCard(name: 'Free', price: '0')),
    SizedBox(width: 12),
    Expanded(child: _PlanCard(name: 'Pro', price: '99', highlighted: true)),
    SizedBox(width: 12),
    Expanded(child: _PlanCard(name: 'Enterprise', price: '299')),
  ],
)

crossAxisAlignment: stretch để mọi card cao bằng nhau.

13. Quiz

Q1

Expanded dùng ngoài Row/Column được không?

Xem đáp án

Đáp án: Không. Lỗi runtime "Expanded widgets must be placed inside Flex widgets". Chỉ dùng trong Row/Column/Flex.

Q2

Row mà children tổng > viewport — chuyện gì?

Xem đáp án

Đáp án: "RenderFlex overflowed" error + yellow/black stripe. Fix: Expanded/Flexible cho 1 child, hoặc wrap scrollable, hoặc FittedBox.

Q3

mainAxisAlignment của Row là trục nào?

Xem đáp án

Đáp án: Trục ngang (horizontal) — vì main axis của Row là ngang. Của Column thì main là dọc.

Q4

Container(width: 200) trong parent constraint tight 100 — kết quả width?

Xem đáp án

Đáp án: 100. Parent constraint thắng — Container không có lựa chọn vì constraint tight (min == max).

Q5

Stack default alignment là gì?

Xem đáp án

Đáp án: AlignmentDirectional.topStart (top-left ở LTR locale). Áp dụng cho non-positioned children.

Q6

Padding vs Container(padding: ...) — khác performance?

Xem đáp án

Đáp án: Padding đơn lẻ rẻ hơn. Container compose nhiều thứ (Padding + ColoredBox + DecoratedBox + ...) — nặng hơn nếu chỉ cần padding.

Q7

Flexible(flex: 2)Expanded(flex: 2) khác gì?

Xem đáp án

Đáp án: Expanded = Flexible(fit: FlexFit.tight). Flexible mặc định loose — child có thể nhỏ hơn space cấp.

Q8

Lỗi "Vertical viewport was given unbounded height" — nguyên nhân?

Xem đáp án

Đáp án: Đặt scrollable (ListView, GridView) trong Column không có constraint height. Fix: Expanded, SizedBox fixed height, hoặc shrinkWrap: true.

14. Tổng kết

  • ✅ Mental model: Constraints flow down, sizes flow up, parent positions.
  • ✅ BoxConstraints: tight / loose, bounded / unbounded.
  • ✅ Row / Column + mainAxis / crossAxis alignment.
  • ✅ Expanded (tight) / Flexible (loose) / Spacer — trong Flex.
  • ✅ Stack + Positioned cho overlap.
  • ✅ Container kitchen sink — biết khi tách primitive.
  • ✅ Padding, SizedBox, Center, Align, EdgeInsets variants.
  • ✅ ConstrainedBox, LimitedBox, FractionallySizedBox, AspectRatio.
  • ✅ 3 lỗi phổ biến + fix: overflow, unbounded height, Expanded outside Flex.
  • ✅ Debug: debugPaintSizeEnabled + Flutter Inspector.

15. Kết nối

  • Chương 4 — Theming: apply theme cho layout (color, text, spacing).
  • Chương 5 — Navigation: layout per page.
  • Chương 9 — Forms: form layout patterns.
  • Chương 13 — Custom Paint: RenderObject + constraint deep dive.