代碼案例:Flutter 經典佈局 12 種

37 阅读6分钟

Flutter 經典佈局 12 種

內容: 日常開發 + 面試高頻的 12 種佈局模式,每種都有完整可運行代碼,通過菜單列表點擊進入各佈局演示。

#佈局名稱核心 API面試考點
1圣杯佈局(Holy Grail)Column + Row + Expanded左右固定寬,中間自適應
2雙飛翼佈局同上,主內容 DOM 順序優先與圣杯的 CSS 實現差異
3粘性底部(Sticky Footer)Column + Expanded內容少粘底,多時被推下
4垂直+水平居中Center/Align/Row+Column/Stack5 種居中方式對比
5瀑布流(Masonry)雙列 Column 交錯排列不等高網格,第三方 flutter_staggered_grid_view
6響應式網格LayoutBuilder + GridView等價 CSS auto-fill minmax
7Sliver 折叠頭部CustomScrollView + SliverAppBar + SliverPersistentHeader滾動聯動 sticky
8底部導航欄BottomNavigationBar + IndexedStackIndexedStack 保留 Tab 狀態
9抽屜側邊欄(Drawer)Scaffold.drawer / endDrawer左右抽屜,程式碼控制開關
10主從佈局(Master-Detail)MediaQuery 判斷寬屏/窄屏響應式導航,Tablet vs Phone
11FAB 懸浮按鈕FloatingActionButton + ColumnSpeedDial 展開多操作
12固定頭尾 + 中間滾動Column + Expanded + ListView鍵盤彈起時 body 自動上移

运行

在线效果可以复制到:dartpad.dev/ 查看,更推荐本地flutter build web调试学习

效果图

image.png

学习代码

import 'package:flutter/material.dart';

// ============================================================
// Flutter 经典布局汇总(日常 + 面试高频)
// ============================================================

void main() => runApp(const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: _LayoutMenu(),
    ));

// 菜单入口,点击跳转对应布局
class _LayoutMenu extends StatelessWidget {
  const _LayoutMenu();

  static const List<(String, Widget)> layouts = [
    ('1. 圣杯布局(Holy Grail)', HolyGrailLayout()),
    ('2. 双飞翼布局', DoubleFlyLayout()),
    ('3. 粘性底部(Sticky Footer)', StickyFooterLayout()),
    ('4. 垂直 + 水平居中', CenterLayout()),
    ('5. 瀑布流(Masonry)', WaterfallLayout()),
    ('6. 响应式网格', ResponsiveGridLayout()),
    ('7. Sliver 折叠头部', SliverLayout()),
    ('8. 底部导航栏', BottomNavLayout()),
    ('9. 抽屉侧边栏(Drawer)', DrawerLayout()),
    ('10. 主从布局(Master-Detail)', MasterDetailLayout()),
    ('11. 悬浮操作按钮布局(FAB)', FabLayout()),
    ('12. 固定头尾 + 中间滚动', FixedHeaderScrollLayout()),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter 经典布局')),
      body: ListView.separated(
        itemCount: layouts.length,
        separatorBuilder: (_, __) => const Divider(height: 1),
        itemBuilder: (context, i) => ListTile(
          title: Text(layouts[i].$1),
          trailing: const Icon(Icons.arrow_forward_ios, size: 14),
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => Scaffold(
                appBar: AppBar(title: Text(layouts[i].$1)),
                body: layouts[i].$2,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

// ============================================================
// 1. 圣杯布局(Holy Grail)
// 结构:顶部 Header + 中间三列(左侧栏 / 主内容 / 右侧栏)+ 底部 Footer
// CSS 传统痛点:左右固定宽,中间自适应
// Flutter:Row + Expanded 轻松实现
// ============================================================
class HolyGrailLayout extends StatelessWidget {
  const HolyGrailLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Header
        Container(
          width: double.infinity,
          height: 60,
          color: Colors.blue,
          child: const Center(
              child: Text('Header', style: TextStyle(color: Colors.white))),
        ),

        // 中间三列:左侧栏 + 主内容 + 右侧栏
        Expanded(
          child: Row(
            children: [
              // 左侧栏:固定宽度
              Container(
                width: 80,
                color: Colors.green[100],
                child: const Center(
                    child: Text('Left\nSide', textAlign: TextAlign.center)),
              ),
              // 主内容:自适应宽度(flex:1)
              Expanded(
                child: Container(
                  color: Colors.grey[100],
                  child: const Center(
                      child: Text('Main Content\n(自适应)',
                          textAlign: TextAlign.center)),
                ),
              ),
              // 右侧栏:固定宽度
              Container(
                width: 80,
                color: Colors.orange[100],
                child: const Center(
                    child: Text('Right\nSide', textAlign: TextAlign.center)),
              ),
            ],
          ),
        ),

        // Footer
        Container(
          width: double.infinity,
          height: 60,
          color: Colors.blue,
          child: const Center(
              child: Text('Footer', style: TextStyle(color: Colors.white))),
        ),
      ],
    );
  }
}

// ============================================================
// 2. 双飞翼布局
// 与圣杯区别:主内容优先渲染,左右列通过负 margin 定位(CSS 做法)
// Flutter 中用 Row 顺序调整 + Expanded 实现等价效果
// 重点:主内容 flex:1,左右固定,但主内容 DOM 顺序排第一
// ============================================================
class DoubleFlyLayout extends StatelessWidget {
  const DoubleFlyLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          height: 50,
          color: Colors.teal,
          child: const Center(
              child: Text('Header', style: TextStyle(color: Colors.white))),
        ),
        Expanded(
          child: Row(
            children: [
              // 主内容优先(DOM 顺序第一,视觉上居中)
              Expanded(
                child: Container(
                  color: Colors.grey[50],
                  padding: const EdgeInsets.all(16),
                  child: const Text(
                      '主内容区域(优先渲染,自适应宽度)\n\n双飞翼与圣杯的核心差异在于 CSS 实现方式,Flutter 中两者结果相同'),
                ),
              ),
              // 左翼
              Container(
                width: 80,
                color: Colors.purple[100],
                child: const Center(
                    child: Text('左翼', textAlign: TextAlign.center)),
              ),
              // 右翼
              Container(
                width: 80,
                color: Colors.pink[100],
                child: const Center(
                    child: Text('右翼', textAlign: TextAlign.center)),
              ),
            ],
          ),
        ),
        Container(
          height: 50,
          color: Colors.teal,
          child: const Center(
              child: Text('Footer', style: TextStyle(color: Colors.white))),
        ),
      ],
    );
  }
}

// ============================================================
// 3. 粘性底部(Sticky Footer)
// 内容少时 Footer 粘在底部,内容多时 Footer 被推下去正常滚动
// CSS 传统做法:min-height: 100vh + margin-top: auto
// Flutter:Column + Expanded 撑开中间区域
// ============================================================
class StickyFooterLayout extends StatelessWidget {
  const StickyFooterLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 内容区:Expanded 撑满剩余空间,把 Footer 挤到底部
        Expanded(
          child: SingleChildScrollView(
            child: Column(
              children: List.generate(
                5, // 改成 30 可以看到 Footer 被推下去
                (i) => ListTile(title: Text('内容项 $i')),
              ),
            ),
          ),
        ),

        // Footer 始终粘在底部
        Container(
          width: double.infinity,
          height: 60,
          color: Colors.indigo,
          child: const Center(
            child: Text('Sticky Footer(内容少时粘底,内容多时被推下)',
                style: TextStyle(color: Colors.white, fontSize: 12)),
          ),
        ),
      ],
    );
  }
}

// ============================================================
// 4. 垂直 + 水平居中(面试必考)
// 各种居中方式的对比
// ============================================================
class CenterLayout extends StatelessWidget {
  const CenterLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          _label('方式1:Center Widget(最简单)'),
          Container(
            height: 100,
            color: Colors.blue[50],
            child: const Center(child: Text('水平 + 垂直居中')),
          ),
          _label('方式2:Align'),
          Container(
            height: 100,
            color: Colors.green[50],
            child: const Align(
              alignment: Alignment.center,
              child: Text('Align 居中'),
            ),
          ),
          _label('方式3:Row + Column mainAxis/crossAxis'),
          Container(
            height: 100,
            color: Colors.orange[50],
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center, // 水平居中
              crossAxisAlignment: CrossAxisAlignment.center, // 垂直居中
              children: const [Text('Row + Column 居中')],
            ),
          ),
          _label('方式4:Stack + Positioned.fill + Align'),
          SizedBox(
            height: 100,
            child: Stack(
              children: [
                Container(color: Colors.purple[50]),
                const Positioned.fill(
                  child: Align(
                    alignment: Alignment.center,
                    child: Text('Stack 绝对居中'),
                  ),
                ),
              ],
            ),
          ),
          _label('方式5:只水平居中(左对齐容器内)'),
          Container(
            height: 60, color: Colors.red[50],
            // crossAxisAlignment 控制交叉轴对齐
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: const [Text('水平居中(Column 默认行为)')],
            ),
          ),
        ],
      ),
    );
  }
}

// ============================================================
// 5. 瀑布流(Masonry / Waterfall)
// 面试考点:如何实现不等高的网格列表
// Flutter:GridView.builder 实现等高网格,不等高用第三方 flutter_staggered_grid_view
// 这里用 CustomScrollView + SliverGrid 模拟不等高效果
// ============================================================
class WaterfallLayout extends StatelessWidget {
  const WaterfallLayout({super.key});

  @override
  Widget build(BuildContext context) {
    // 模拟不同高度的卡片数据
    final items = List.generate(20, (i) => (i + 1) * (i % 3 + 1) * 10 + 60.0);

    return SingleChildScrollView(
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 左列
          Expanded(
            child: Column(
              children: [
                for (int i = 0; i < items.length; i += 2)
                  _WaterfallCard(index: i, height: items[i]),
              ],
            ),
          ),
          const SizedBox(width: 4),
          // 右列
          Expanded(
            child: Column(
              children: [
                for (int i = 1; i < items.length; i += 2)
                  _WaterfallCard(index: i, height: items[i]),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _WaterfallCard extends StatelessWidget {
  final int index;
  final double height;
  const _WaterfallCard({required this.index, required this.height});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: height,
      margin: const EdgeInsets.all(2),
      decoration: BoxDecoration(
        color: Colors.primaries[index % Colors.primaries.length][200],
        borderRadius: BorderRadius.circular(8),
      ),
      child: Center(child: Text('Item $index\nh:${height.toInt()}')),
    );
  }
}

// ============================================================
// 6. 响应式网格
// 屏幕宽度变化时自动调整列数
// CSS:grid-template-columns: repeat(auto-fill, minmax(150px, 1fr))
// Flutter:LayoutBuilder + GridView
// ============================================================
class ResponsiveGridLayout extends StatelessWidget {
  const ResponsiveGridLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // 根据父级宽度决定列数(等价于 CSS auto-fill)
        final columns = (constraints.maxWidth / 150).floor().clamp(2, 6);

        return GridView.builder(
          padding: const EdgeInsets.all(8),
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: columns, // 动态列数
            crossAxisSpacing: 8,
            mainAxisSpacing: 8,
            childAspectRatio: 1,
          ),
          itemCount: 20,
          itemBuilder: (_, i) => Container(
            decoration: BoxDecoration(
              color: Colors.primaries[i % Colors.primaries.length][100],
              borderRadius: BorderRadius.circular(8),
            ),
            child: Center(child: Text('$i')),
          ),
        );
      },
    );
  }
}

// ============================================================
// 7. Sliver 折叠头部(面试高频)
// 滚动时 AppBar 逐渐收缩/隐藏,等价于 CSS position:sticky + 滚动联动
// 核心:CustomScrollView + SliverAppBar + SliverList
// ============================================================
class SliverLayout extends StatelessWidget {
  const SliverLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        // 可折叠的大头部
        SliverAppBar(
          expandedHeight: 200, // 展开高度
          pinned: true, // 折叠后固定在顶部(sticky)
          floating: false, // true = 向下滚一点就展开
          snap: false,
          flexibleSpace: FlexibleSpaceBar(
            title: const Text('折叠 AppBar'),
            background: Container(
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  colors: [Colors.blue, Colors.purple],
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                ),
              ),
              child: const Center(
                child:
                    Text('向上滚动查看折叠效果', style: TextStyle(color: Colors.white70)),
              ),
            ),
          ),
        ),

        // 固定头部(sticky section header)
        const SliverPersistentHeader(
          pinned: true,
          delegate: _StickyHeader('置顶的分组标题'),
        ),

        // 列表内容
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (_, i) => ListTile(title: Text('列表项 $i')),
            childCount: 30,
          ),
        ),
      ],
    );
  }
}

class _StickyHeader extends SliverPersistentHeaderDelegate {
  final String title;
  const _StickyHeader(this.title);

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      color: Colors.amber[100],
      alignment: Alignment.centerLeft,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
    );
  }

  @override
  double get maxExtent => 48;
  @override
  double get minExtent => 48;
  @override
  bool shouldRebuild(_StickyHeader old) => old.title != title;
}

// ============================================================
// 8. 底部导航栏布局(日常最常用)
// 面试考点:如何保持 Tab 切换时页面状态不丢失
// 关键:IndexedStack 保留每个 tab 的 Widget 树,不销毁
// ============================================================
class BottomNavLayout extends StatefulWidget {
  const BottomNavLayout({super.key});

  @override
  State<BottomNavLayout> createState() => _BottomNavState();
}

class _BottomNavState extends State<BottomNavLayout> {
  int _index = 0;

  // IndexedStack:所有子页面同时存在于树中,只显示当前 index 的
  // 状态不会因为切换 tab 而丢失(对比直接用 children[_index]:每次切换都重建)
  final List<Widget> _pages = const [
    _TabPage(label: '首页', color: Colors.blue),
    _TabPage(label: '发现', color: Colors.green),
    _TabPage(label: '我的', color: Colors.orange),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // IndexedStack 保持状态,切换 tab 不重建页面
      body: IndexedStack(index: _index, children: _pages),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _index,
        onTap: (i) => setState(() => _index = i),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.explore), label: '发现'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
        ],
      ),
    );
  }
}

class _TabPage extends StatefulWidget {
  final String label;
  final Color color;
  const _TabPage({required this.label, required this.color});

  @override
  State<_TabPage> createState() => _TabPageState();
}

class _TabPageState extends State<_TabPage> {
  int _count = 0; // 切换 tab 后再切回来,count 还在(IndexedStack 保留了状态)

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(widget.label,
              style: TextStyle(fontSize: 24, color: widget.color)),
          const SizedBox(height: 16),
          Text('count: $_count(切换 Tab 再回来不会重置)'),
          TextButton(
            onPressed: () => setState(() => _count++),
            child: const Text('+1'),
          ),
        ],
      ),
    );
  }
}

// ============================================================
// 9. 抽屉侧边栏(Drawer)
// 等价于 CSS 的 off-canvas 侧边栏
// 面试考点:Drawer 的打开/关闭方式,EndDrawer(右侧抽屉)
// ============================================================
class DrawerLayout extends StatelessWidget {
  const DrawerLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Drawer 布局'),
        // AppBar 有 Drawer 时自动显示汉堡图标
      ),

      // 左侧抽屉
      drawer: Drawer(
        child: ListView(
          children: [
            const DrawerHeader(
              decoration: BoxDecoration(color: Colors.blue),
              child: Text('用户信息',
                  style: TextStyle(color: Colors.white, fontSize: 20)),
            ),
            ListTile(
              leading: const Icon(Icons.home),
              title: const Text('首页'),
              onTap: () => Navigator.pop(context), // 关闭抽屉
            ),
            ListTile(
              leading: const Icon(Icons.settings),
              title: const Text('设置'),
              onTap: () => Navigator.pop(context),
            ),
          ],
        ),
      ),

      // 右侧抽屉(endDrawer)
      endDrawer: Drawer(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text('右侧抽屉'),
              TextButton(
                onPressed: () => Navigator.pop(context),
                child: const Text('关闭'),
              ),
            ],
          ),
        ),
      ),

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('左划或点左上角 → 左侧抽屉'),
            const SizedBox(height: 16),
            // 代码控制打开抽屉
            Builder(
                builder: (context) => TextButton(
                      onPressed: () => Scaffold.of(context).openEndDrawer(),
                      child: const Text('点击打开右侧抽屉'),
                    )),
          ],
        ),
      ),
    );
  }
}

// ============================================================
// 10. 主从布局(Master-Detail)
// 宽屏:左列表 + 右详情并排;窄屏:列表页 push 到详情页
// 面试考点:响应式导航,Tablet vs Phone 不同交互
// ============================================================
class MasterDetailLayout extends StatefulWidget {
  const MasterDetailLayout({super.key});

  @override
  State<MasterDetailLayout> createState() => _MasterDetailState();
}

class _MasterDetailState extends State<MasterDetailLayout> {
  int? _selected;
  final items = List.generate(10, (i) => '列表项 $i');

  @override
  Widget build(BuildContext context) {
    final isWide = MediaQuery.of(context).size.width > 600;

    if (isWide) {
      // 宽屏:左右并排
      return Row(
        children: [
          SizedBox(
            width: 200,
            child: ListView.builder(
              itemCount: items.length,
              itemBuilder: (_, i) => ListTile(
                title: Text(items[i]),
                selected: _selected == i,
                onTap: () => setState(() => _selected = i),
              ),
            ),
          ),
          const VerticalDivider(width: 1),
          Expanded(
            child: _selected == null
                ? const Center(child: Text('请选择左侧列表项'))
                : Center(
                    child: Text('${items[_selected!]} 的详情页',
                        style: const TextStyle(fontSize: 24))),
          ),
        ],
      );
    } else {
      // 窄屏:列表页,点击 push 到详情页
      return ListView.builder(
        itemCount: items.length,
        itemBuilder: (_, i) => ListTile(
          title: Text(items[i]),
          trailing: const Icon(Icons.arrow_forward_ios, size: 14),
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => Scaffold(
                appBar: AppBar(title: Text(items[i])),
                body: Center(
                    child: Text('${items[i]} 的详情页',
                        style: const TextStyle(fontSize: 24))),
              ),
            ),
          ),
        ),
      );
    }
  }
}

// ============================================================
// 11. FAB 悬浮按钮布局
// 面试考点:FAB 的各种定位方式,SpeedDial(展开多个操作)
// ============================================================
class FabLayout extends StatefulWidget {
  const FabLayout({super.key});

  @override
  State<FabLayout> createState() => _FabLayoutState();
}

class _FabLayoutState extends State<FabLayout> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: const Center(child: Text('点击右下角 FAB')),

      // FAB 位置:floatingActionButtonLocation 控制
      floatingActionButtonLocation:
          FloatingActionButtonLocation.endFloat, // 右下(默认)
      // FloatingActionButtonLocation.centerFloat  → 底部居中
      // FloatingActionButtonLocation.endTop       → 右上
      // FloatingActionButtonLocation.startFloat   → 左下

      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min, // Column 高度包裹内容
        children: [
          // SpeedDial 展开的子操作
          if (_expanded) ...[
            FloatingActionButton.small(
              heroTag: 'add',
              onPressed: () {},
              child: const Icon(Icons.add),
            ),
            const SizedBox(height: 8),
            FloatingActionButton.small(
              heroTag: 'edit',
              onPressed: () {},
              child: const Icon(Icons.edit),
            ),
            const SizedBox(height: 8),
            FloatingActionButton.small(
              heroTag: 'delete',
              onPressed: () {},
              child: const Icon(Icons.delete),
            ),
            const SizedBox(height: 8),
          ],
          // 主 FAB
          FloatingActionButton(
            heroTag: 'main',
            onPressed: () => setState(() => _expanded = !_expanded),
            child: AnimatedRotation(
              turns: _expanded ? 0.125 : 0, // 展开时旋转 45°
              duration: const Duration(milliseconds: 200),
              child: const Icon(Icons.add),
            ),
          ),
        ],
      ),
    );
  }
}

// ============================================================
// 12. 固定头尾 + 中间滚动(日常最常用)
// 面试考点:键盘弹起时 body 自动上移,避免遮挡输入框
// resizeToAvoidBottomInset: true(默认)= 键盘弹起时 body 缩小
// ============================================================
class FixedHeaderScrollLayout extends StatelessWidget {
  const FixedHeaderScrollLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 固定头部
        Container(
          height: 56,
          color: Colors.blue,
          child: const Center(
            child: Text('固定头部(不随内容滚动)', style: TextStyle(color: Colors.white)),
          ),
        ),

        // 中间内容区可滚动
        Expanded(
          child: ListView(
            padding: const EdgeInsets.all(16),
            children: [
              const TextField(
                  decoration: InputDecoration(labelText: '输入框(键盘弹起不遮挡)')),
              const SizedBox(height: 16),
              ...List.generate(
                20,
                (i) => ListTile(title: Text('可滚动内容 $i')),
              ),
            ],
          ),
        ),

        // 固定底部
        Container(
          height: 56,
          color: Colors.grey[200],
          child: const Center(child: Text('固定底部(始终可见)')),
        ),
      ],
    );
  }
}

// 公共标签组件
Widget _label(String text) => Padding(
      padding: const EdgeInsets.fromLTRB(0, 16, 0, 4),
      child: Text(text,
          style:
              const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
    );