Flutter 經典佈局 12 種
內容: 日常開發 + 面試高頻的 12 種佈局模式,每種都有完整可運行代碼,通過菜單列表點擊進入各佈局演示。
| # | 佈局名稱 | 核心 API | 面試考點 |
|---|---|---|---|
| 1 | 圣杯佈局(Holy Grail) | Column + Row + Expanded | 左右固定寬,中間自適應 |
| 2 | 雙飛翼佈局 | 同上,主內容 DOM 順序優先 | 與圣杯的 CSS 實現差異 |
| 3 | 粘性底部(Sticky Footer) | Column + Expanded | 內容少粘底,多時被推下 |
| 4 | 垂直+水平居中 | Center/Align/Row+Column/Stack | 5 種居中方式對比 |
| 5 | 瀑布流(Masonry) | 雙列 Column 交錯排列 | 不等高網格,第三方 flutter_staggered_grid_view |
| 6 | 響應式網格 | LayoutBuilder + GridView | 等價 CSS auto-fill minmax |
| 7 | Sliver 折叠頭部 | CustomScrollView + SliverAppBar + SliverPersistentHeader | 滾動聯動 sticky |
| 8 | 底部導航欄 | BottomNavigationBar + IndexedStack | IndexedStack 保留 Tab 狀態 |
| 9 | 抽屜側邊欄(Drawer) | Scaffold.drawer / endDrawer | 左右抽屜,程式碼控制開關 |
| 10 | 主從佈局(Master-Detail) | MediaQuery 判斷寬屏/窄屏 | 響應式導航,Tablet vs Phone |
| 11 | FAB 懸浮按鈕 | FloatingActionButton + Column | SpeedDial 展開多操作 |
| 12 | 固定頭尾 + 中間滾動 | Column + Expanded + ListView | 鍵盤彈起時 body 自動上移 |
运行
在线效果可以复制到:dartpad.dev/ 查看,更推荐本地flutter build web调试学习
效果图
学习代码
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)),
);