卷 VII · 手上CH 26深度 26/28

一个页面:MaterialApp → Scaffold → 你的 body

直觉过境 Scaffold 我在 Compose 里也用过,一个页面就是套一层。」 改一下

Flutter 里没有「Activity」,也没有「页面」这个概念——一个页面就是一个 widget,只不过是一个会替你算高度的 widget。这一章从 main() 的第一行开始搭出一个真能用的页面,然后打开一台真的 Scaffold 布局器,看清它到底给你的 body 留了多少空间。

runAppMaterialApp 给了什么Scaffold 槽位安全区与键盘

main() 到屏幕上第一个像素

整个 App 的入口就三行,而且这三行的层次关系值得记住:

void main() {
  runApp(const CourseApp());        // ① 把一个 widget 交给框架
}

class CourseApp extends StatelessWidget {
  const CourseApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(              // ② 这一层给你「一个 App 需要的公共设施」
      title: '我的课表',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF027DFD)),
      ),
      home: const CoursesPage(),      // ③ 第一个页面
    );
  }
}

runApp 做的事,用第 6 章的话说就是:把你这张纸挂到一棵空的 Element 树的根上,然后启动第一帧。它不返回,也不需要返回——从这一刻起,一切都由框架的帧循环驱动。

✎ 一个 2026 年的小更新:别再写 useMaterial3: true

Material 3 从 3.16 起就是默认值了,本机 3.44 的源码里直接写着 useMaterial3 ??= true,而那个参数本身正在被淘汰。现在配主题的标准姿势就是给一个种子色,让 ColorScheme.fromSeed 按 M3 的算法推出整套配色(主色、容器色、文字色、深浅两套)。你只挑一个颜色,剩下三十多个它算给你。

MaterialApp 到底给了你什么

很多人把它当成「必须套的一层壳」,其实它是一批 InheritedWidget 的打包安装。少了它,一大堆 .of(context) 会当场抛异常。它至少装了这些:

它插进树里的东西没有它会怎样
Directionality任何 Text 都会抛「No Directionality widget found」。
MediaQueryMediaQuery.of(context) 抛异常,拿不到屏幕尺寸和安全区。
Theme / DefaultTextStyleTheme.of(context) 抛异常,文字没有默认样式。
Navigator + OverlayNavigator.push 无处可去;对话框、下拉菜单、tooltip 全都没地方浮。
ScaffoldMessengerSnackBar 弹不出来。

所以写 widget test 时你会经常需要手动包一层 MaterialAppDirectionality——不是仪式,是因为被测的 widget 真的在向上找这些东西。

Scaffold:一页纸的槽位

Scaffold 是 Material 给的「一个页面的标准骨架」。你要做的是往它的槽位里填东西:

class CoursesPage extends StatelessWidget {
  const CoursesPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('我的课表'),
        actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
      ),
      body: ListView(                       // ← 你的内容
        children: const [CourseTile(name: 'INFOSYS 722', room: '401')],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
      bottomNavigationBar: NavigationBar(
        destinations: const [
          NavigationDestination(icon: Icon(Icons.list), label: '课表'),
          NavigationDestination(icon: Icon(Icons.person), label: '我'),
        ],
      ),
      drawer: const AppDrawer(),            // 左边划出来的抽屉
    );
  }
}

这些槽位在 Android 那边你都见过:AppBar ≈ Toolbar,drawer ≈ NavigationView,bottomNavigationBar ≈ BottomNavigationView。真正需要重新学的不是有哪些槽位,是它们怎么互相挤空间。

◆ 这一章唯一要你记住的一句

Scaffold 是一台布局代理:它按固定顺序量完 AppBar 和底部栏,把剩下的高度以松约束交给 body——而且它从不body 避让安全区。

「松约束」意味着 body 收到的是 0 ≤ h ≤ 剩下的,不是「你必须这么高」。所以一个不设高度、没有 child 的 Container 放进 body 会撑满(因为宽度是紧的、高度它自己选最大),而一个 Column 会缩到内容的高度。

亲手量一遍:你的 body 到底拿到多高

这台机器实现的就是 _ScaffoldLayout.performLayout 那段真算法。左边是一台 390 × 844 的刘海屏手机(状态栏 47、底部安全区 34),右边是它一步步算出来的账。把开关一个个打开:

几个结论值得单独拎出来,因为它们解释了你在真机上遇到的大部分「怎么会这样」:

一、AppBar 的高度不是 56

103kToolbarHeight 确实等于 56,但 Scaffold 给 AppBar 的槽位是 preferredSize.height + MediaQuery.padding.top——状态栏那 47 是 AppBar 自己吃掉的。所以 body 从 y = 103 开始,拿到 844 − 103 = 741 的最大高度。

推论:你不需要在 AppBar 上面再包一层 SafeArea。包了会多留 47。

二、底部栏也不是 56

BottomNavigationBar 实测 92,Material 3 的 NavigationBar 实测 114。原因一样:kBottomNavigationBarHeight = 56 只是 minHeight,真高度是内容撑出来的高度(58 / 80)加上底部安全区 34

推论:别在代码里写死 56 去算偏移。要读真实高度,就让内容自己被 Scaffold 挤开,或者用 MediaQuery.paddingOf(context).bottom

三、Scaffold 帮 body 躲了状态栏,但没帮它躲底部

这是最容易被漏掉的一条。Scaffold 在把 body 塞进槽位时做了这件事:

  • 有 AppBar → 把 body 那层 MediaQuerypadding.top 抹成 0(因为 AppBar 已经吃掉了);
  • 有底部栏 → 把 padding.bottom 抹成 0(同理);
  • 没有底部栏 → padding.bottom 保持 34,一分不减。

也就是说:一个「AppBar + 一个列表」的普通页面,列表的最后一项会跑到 home indicator 下面去。demo 里把「body 包 SafeArea」打开,你会看到可用高度从 741 掉到 707——那 34 就是这么让出来的。

⚠ SafeArea 的正确用法:一次,而且要挑边

别到处包。嵌套两层 SafeArea 不会让出两倍(内层读到的 padding 已经被外层抹掉了),但会让人读不懂。

要挑边。已经有 AppBar 时写 SafeArea(top: false, child: ...)——顶上不用管,只躲底下。

列表页有更好的写法。不要用 SafeArea 把整个 ListView 缩进去(那样滚动条也缩了、滚到底有一块空白),而是给它一个底部内边距:

ListView.builder(
  padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom + 16),
  itemBuilder: …,
)

这样内容能滚到安全区里、但停下来时不会被挡住——这也是 Scaffold 只告诉你数值、不替你决定的原因:躲开的方式不止一种。

四、键盘一弹就 overflow,根因在这里

键盘弹起时 viewInsets.bottom 变成约 336,Scaffold 默认(resizeToAvoidBottomInset: true)会把 contentBottom 从 844 顶到 508,于是 body 的最大高度从 741 掉到 405

你的 Column 不知道这件事——它还想要原来那么高,于是你收到那条熟悉的黄黑警戒条:A RenderFlex overflowed by … pixels(第 11、12 章那台约束求解器就会算这个数)。

正确解法是让内容能滚,不是关掉 resize:

// ✓ 表单页的标准骨架
Scaffold(
  appBar: AppBar(title: const Text('新建作业')),
  body: SingleChildScrollView(          // 键盘顶上来时,内容变成可滚的
    padding: const EdgeInsets.all(16),
    child: Column(children: [ …一堆 TextField… ]),
  ),
)

什么时候才该关 resizeToAvoidBottomInset?只有一种情况:页面有一张铺满的背景图或视频,被压缩会变形。关了之后键盘会直接盖在内容上,所以有输入框的页面不要关。

五、extendBodyextendBodyBehindAppBar:铺满,然后自己躲

这两个开关是做「毛玻璃底栏」和「透明 AppBar 压在大图上」的标准姿势,逻辑一模一样:让 body 铺到栏下面去,同时把被挡住的高度通过 MediaQuery.padding 告诉你。demo 里能看到:extendBody 打开后 body 从 649 涨回 741,而 padding.bottom 变成 92;extendBodyBehindAppBar 打开后 body 拿到整块 844,而 padding.top 变成 103。

拿到这两个数字要干什么?给你的滚动内容加对应的内边距——这样内容可以从底栏后面滚过去(好看),但停住时不会被挡住(好用)。

第二个页面,与页面之间

新建页面没有任何仪式:再写一个 StatelessWidget,里面再返回一个 Scaffold然后把它推到栈上:

// 推过去
Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => CourseDetailPage(course: c)),
);

// 带结果回来
final picked = await Navigator.push<String>(context, MaterialPageRoute(
  builder: (_) => const PickRoomPage(),
));
if (!context.mounted) return;      // await 之后用 context 前,先看一眼
if (picked != null) setState(() => _room = picked);

那句 if (!context.mounted) return; 值得记进肌肉:await 之后这个页面可能已经被 pop 掉了,此时再用 context(哪怕只是 Navigator.of)就是在用一个死掉的 Element。分析器有一条 lint 专门盯这个(use_build_context_synchronously),别把它关掉。

栈、命名路由、深链接、以及为什么小 App 也建议直接上 go_router——那是第 21 章的内容,这里不重复。这一章只需要你知道:「页面」在 Flutter 里没有特殊地位,它就是被 Navigator 这个 widget 管着的一个 widget。

✎ 一个可以直接抄的页面骨架
class CoursesPage extends StatelessWidget {
  const CoursesPage({super.key});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);             // 样式从主题取,别硬编码
    return Scaffold(
      appBar: AppBar(title: const Text('我的课表')),
      body: SafeArea(                            // 顶上 AppBar 管了,这里只躲底下
        top: false,
        child: RefreshIndicator(                 // 下拉刷新,几乎白送
          onRefresh: () async { /* 重新拉数据 */ },
          child: ListView.builder(               // 永远用 .builder(第 23 章)
            padding: const EdgeInsets.symmetric(vertical: 8),
            itemCount: courses.length,
            itemBuilder: (context, i) => CourseTile(course: courses[i]),
          ),
        ),
      ),
    );
  }
}

四个默认选择:样式走 Theme.of(换主题、深色模式免费)、SafeArea(top: false)ListView.builder列表页顺手加 RefreshIndicator。这四条你在学校那个 App 的每个列表页都会重复用到。

和 Compose 的精确对照

⇄ Compose 对照 · 同名的 Scaffold,相反的默认

Compose:Scaffold(topBar = {...}, bottomBar = {...}) { innerPadding -> ... }。它把避让量作为参数交给你,你必须自己 Modifier.padding(innerPadding);忘了,内容就压在 topBar 底下。

Flutter:反过来——body 的位置和最大高度已经被 Scaffold 减好了(从 y=103 开始、741 高),你什么都不用做就不会被 AppBar 压住。但底部安全区它不管,那部分要你自己 SafeArea

所以两边容易犯的错正好相反:Compose 是「忘了用 innerPadding,内容被顶栏盖住」;Flutter 是「以为 Scaffold 全包了,内容被 home indicator 盖住」。

另外一处对照:Compose 的 WindowInsets 那一套(imePadding()navigationBarsPadding())在 Flutter 这边全部收进了一个 MediaQuerypadding 是安全区、viewInsets 是键盘、viewPadding 是「不管键盘的安全区」。三个名字记住,这类问题就都能自己查了。

这一章的一句话

一个页面 = MaterialApp(装公共设施)+ Scaffold(按顺序量槽位、把剩下的高度以松约束交给 body)+ 你的 widget;Scaffold 替你躲开了 AppBar 和键盘,但底部安全区要你自己 SafeArea

骨架搭好了,但它现在还很丑:白底、黑字、方角。下一章把「好看」这件事拆开——为什么 Flutter 里没有 Modifier 链,Container 其实是一台十层组装厂,以及那条最常见的断言为什么会炸。