Skip to content

04 · Flutter 布局与滚动进阶

【通用进阶】02 章讲的是「能跑起来的布局」,这一章讲「复杂滚动效果怎么做」。 项目里列表都用 ListView + 分页骨架,一旦要做滚动时折叠的详情页头就会撞墙——这一章解决它。


开篇:前端概念对照

前端概念Flutter 对应差异
overflow: scrollListView / SingleChildScrollView
虚拟滚动ListView.builderFlutter 默认懒加载
position: stickySliverPersistentHeader
吸顶导航SliverAppBar(pinned: true)
视差滚动头部SliverAppBar(flexibleSpace)
@media (max-width: 600px)LayoutBuilder / MediaQueryFlutter 按约束而非屏幕尺寸
env(safe-area-inset-top)SafeArea / MediaQuery.padding
100vhMediaQuery.sizeOf(context).height
100dvh(动态视口)MediaQuery.viewInsetsOf键盘弹起时视口变化
滚动容器嵌套NestedScrollView

一、Sliver 体系

为什么需要 Sliver

ListView 能滚动,但它是一整块——里面所有 item 是一个整体,无法实现「头部滚动时折叠、然后吸顶」这种效果。

Sliver 是「可滚动区域的一个片段」。多个 Sliver 组合进 CustomScrollView,各片段可以有不同的滚动行为

ListView      =  [全部内容作为一个整体滚动]
CustomScrollView = [Sliver A][Sliver B][Sliver C]   ← 各自独立控制

前端类比

最接近的是 CSS position: sticky + 多个段落的组合。但 Sliver 更强大——每个片段可以声明「我怎么被消费」。

核心 Widget

Widget作用前端类比
CustomScrollViewSliver 的容器滚动容器
SliverAppBar可折叠/吸顶的头部sticky header + 视差
SliverList线性列表ListView
SliverGrid网格GridView
SliverToBoxAdapter把普通 Widget 塞进 Sliver(桥梁)
SliverPersistentHeader自定义吸顶头position: sticky
SliverFillRemaining填满剩余空间min-height: 100%
SliverPadding给 Sliver 加内边距

基本结构

dart
CustomScrollView(
  slivers: [
    const SliverAppBar(
      title: Text('电站详情'),
      expandedHeight: 200,     // 展开高度
      pinned: true,            // 折叠后吸顶
      flexibleSpace: FlexibleSpaceBar(
        background: Image.network(url, fit: BoxFit.cover),
      ),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => DeviceTile(items[index]),
        childCount: items.length,
      ),
    ),
  ],
)

SliverAppBar 的三种行为

参数效果
pinned: true折叠后吸顶保留(导航栏效果)
floating: true向下滚动时立即出现(不用滚到顶)
snap: true配合 floating,滚动停止时自动完全展开或收起
都不设滚走就没了
dart
SliverAppBar(pinned: true, floating: true, snap: true)   // 常见组合

⚠️ snap 必须配合 floating 使用,否则断言报错。

普通 Widget 怎么塞进 Sliver

SliverToBoxAdapter 是桥梁:

dart
CustomScrollView(
  slivers: [
    const SliverAppBar(...),
    SliverToBoxAdapter(
      child: Padding(
        padding: EdgeInsets.all(AppTokens.spacingMd),
        child: StatCardsRow(),        // 普通 Widget
      ),
    ),
    SliverList(...),
  ],
)

⚠️ 不要滥用SliverToBoxAdapter 里的 Widget 会一次性全部构建,没有懒加载。长列表一定要用 SliverList

自定义吸顶头:SliverPersistentHeader

dart
SliverPersistentHeader(
  pinned: true,
  delegate: _MyHeaderDelegate(minHeight: 56, maxHeight: 56),
)

需要实现 SliverPersistentHeaderDelegate

dart
class _MyHeaderDelegate extends SliverPersistentHeaderDelegate {
  _MyHeaderDelegate({required this.minHeight, required this.maxHeight});
  final double minHeight;
  final double maxHeight;

  @override
  double get minExtent => minHeight;
  @override
  double get maxExtent => maxHeight;

  @override
  Widget build(context, double shrinkOffset, bool overlapsContent) {
    // shrinkOffset:当前收缩了多少像素,可用于做渐变/缩放
    final progress = shrinkOffset / maxExtent;
    return Container(
      color: Color.lerp(Colors.transparent, Colors.white, progress),
      child: const Center(child: Text('吸顶栏')),
    );
  }

  @override
  bool shouldRebuild(covariant _MyHeaderDelegate oldDelegate) =>
      oldDelegate.maxHeight != maxHeight;
}

shrinkOffset 是精髓——它告诉你滚动进度,可以做透明度渐变、字号缩放等效果。

练习 4.1:折叠吸顶头

dart
// 粘到 lib/main_playground.dart
class SliverDemo extends StatelessWidget {
  const SliverDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        const SliverAppBar(
          title: Text('电站详情'),
          expandedHeight: 220,
          pinned: true,
          flexibleSpace: FlexibleSpaceBar(
            background: ColoredBox(color: Colors.blue),
          ),
        ),
        SliverToBoxAdapter(
          child: Container(
            height: 100,
            color: Colors.orange.shade100,
            alignment: Alignment.center,
            child: const Text('统计卡片区(随滚动)'),
          ),
        ),
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, i) => ListTile(title: Text('设备 $i')),
            childCount: 30,
          ),
        ),
      ],
    );
  }
}

观察

  1. 向上滚动,蓝色头部逐渐折叠,最后只剩导航栏吸顶
  2. 分别去掉 pinned、加上 floating: true, snap: true,对比行为差异

自检

  • [ ] 知道 ListViewCustomScrollView + Sliver 的区别
  • [ ] 会用 SliverAppBar 做折叠吸顶头
  • [ ] 知道普通 Widget 要用 SliverToBoxAdapter 桥接,且它没有懒加载
  • [ ] 知道 snap 必须配 floating

二、NestedScrollView:嵌套滚动

场景

外层滚动 + 内层 Tab 各自滚动,且滚动是连贯的(滚内层到底后接着滚外层)。

典型:详情页顶部大图 → 吸顶 TabBar → Tab 内容各自是列表。

┌─────────────────┐
│   大图/头部      │ ← 外层,滚上去消失
├─────────────────┤
│  Tab1 │ Tab2    │ ← 吸顶
├─────────────────┤
│                 │
│  Tab 内容列表    │ ← 内层,独立滚动
│                 │
└─────────────────┘

基本结构

dart
NestedScrollView(
  headerSliverBuilder: (context, innerBoxIsScrolled) => [
    const SliverAppBar(
      expandedHeight: 200,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(title: Text('详情')),
    ),
  ],
  body: TabBarView(
    controller: _tabController,
    children: [
      ListView.builder(...),    // Tab 1
      ListView.builder(...),    // Tab 2
    ],
  ),
)

关键headerSliverBuilder 放外层头部(Sliver),body 放可滚动的内容。

⚠️ 两个经典坑

坑一:内层列表要设 physics

如果内层滚动不想要独立惯性(希望完全由外层驱动):

dart
ListView.builder(
  physics: const NeverScrollableScrollPhysics(),   // 禁止内层滚动
  shrinkWrap: true,
  ...
)

坑二:shrinkWrap: true 的性能代价

shrinkWrap 会让列表一次性测量所有子项,失去懒加载优势。长列表严禁使用

只在这种情况用:子项极少(< 20)且必须嵌在非滚动容器里。

项目实例:为什么本项目没用 NestedScrollView

本项目详情页用的是 HeTabbedDetailScaffoldlib/he_components/src/widgets/),Tab 内容各自是独立列表,没有外层联动滚动需求——头部是固定不滚的。

这是个合理的简化:uni-app 老项目的交互本来就是「头部固定 + Tab 切换」,迁移时保持了原样。

所以你在本项目遇到的大概率是:

dart
// 固定头 + Tab + 各自滚动(本项目主流)
Column(children: [
  _buildFixedHeader(),          // 不滚
  TabBar(...),
  Expanded(child: TabBarView(...)),   // 只有内容区滚
])

选型建议:只有当设计稿明确要求「头部跟着滚上去再吸顶」时,才上 NestedScrollView。否则 Column + Expanded 更简单可靠。

自检

  • [ ] 知道 NestedScrollView 用于「外层头 + 内层 Tab 列表」的联动滚动
  • [ ] 知道 shrinkWrap: true 会失去懒加载,长列表禁用
  • [ ] 能判断什么时候不需要 NestedScrollView

三、LayoutBuilder:按约束而非屏幕尺寸响应

前端对照

前端Flutter
@media (max-width: 600px)LayoutBuilder 判断 constraints.maxWidth
100% 宽度constraints.maxWidth

核心理念差异

前端媒体查询看的是「屏幕/视口尺寸」;Flutter 的 LayoutBuilder 看的是「父给我的约束」。

后者更准确——同一个 Widget 在不同位置可能拿到完全不同的约束,用 LayoutBuilder 能正确响应。

dart
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return _buildWideLayout();      // 宽屏:左右两栏
    }
    return _buildNarrowLayout();      // 窄屏:上下堆叠
  },
)

项目实例

lib/ui/plant_detail/views/plant_equipment_tab.dart:371-374 —— 按父容器宽度计算卡片尺寸:

dart
Widget _addCard(BuildContext context, RuntimeI18n l10n, AppTokens tokens,
    double w, double cardWidth, bool hasPermission) {
  final cardHeight = w * 245 / 750;
  final iconSize = w * 50 / 750;
  ...
}

这里 w 是外部传入的可用宽度,用比例(/750,即设计稿宽度)算出各元素尺寸——这是本项目适配的核心思路,与 ScreenUtil 的 designSize: Size(750, 1624) 一致。

⚠️ 陷阱:不要在 LayoutBuilder 里做重活

builder 可能被调用多次(每次约束变化)。保持轻量。

自检

  • [ ] 理解 LayoutBuilder 响应的是父约束,不是屏幕尺寸
  • [ ] 能用它实现宽窄屏两套布局

四、MediaQuery:屏幕信息

常用取值

dart
final mq = MediaQuery.of(context);
mq.size;              // 屏幕尺寸(逻辑像素)
mq.padding;           // 系统遮挡区域(刘海、状态栏、底部 Home 条)
mq.viewInsets;        // 被系统 UI 遮挡的部分(**键盘弹起时非零**)
mq.viewPadding;       // padding + viewInsets
mq.platformBrightness;// 系统亮度(light / dark)
mq.textScaler;        // 系统字体缩放

⚠️ 性能陷阱:MediaQuery.of(context) 会让 Widget 依赖整个 MediaQuery

任何 MediaQuery 属性变化(键盘弹起、旋转、系统字体变更)都会触发重建。

精准订阅(Flutter 3.7+ 推荐):

dart
MediaQuery.sizeOf(context);          // 只在尺寸变化时重建
MediaQuery.paddingOf(context);       // 只在 padding 变化时重建
MediaQuery.viewInsetsOf(context);    // 只在键盘等变化时重建
MediaQuery.platformBrightnessOf(context);

前端类比:类似 useSyncExternalStore 的 selector,避免无关更新引起 re-render。

项目实例:精准取值

lib/main.dart:158-160

dart
ThemeMode.system =>
  View.of(context).platformDispatcher.platformBrightness == Brightness.dark,

这里刻意没有用 MediaQuery.platformBrightnessOf(context),而是直接读 platformDispatcher——因为该 Widget 位于 MaterialApp 之上,子树里没有 MediaQuery,用 MediaQuery.of 会 fallback 成错误值。

lib/main.dart:152-153 的注释解释了这个坑:

dart
// 本组件位于 MaterialApp 之上、子树里没有 MediaQuery,不能用
// MediaQuery.platformBrightnessOf(会 fallback 成 light,跟随系统深色
// 模式时图标亮度会算错),改直接读窗口的平台亮度。

这是一个很好的示范:知道 API 的适用边界比记住 API 更重要。

自检

  • [ ] 知道 MediaQuery.of 的重建范围过大,优先用 xxxOf 精准方法
  • [ ] 知道 viewInsets 是键盘高度
  • [ ] 知道位于 MaterialApp 之上时 MediaQuery.of 不可用

五、SafeArea 与刘海屏

作用

避开系统遮挡区域(刘海、状态栏、底部 Home 指示条)。

dart
SafeArea(
  child: MyContent(),
)

前端类比env(safe-area-inset-top) / viewport-fit=cover

参数

dart
SafeArea(
  top: true,        // 避开顶部(默认 true)
  bottom: true,     // 避开底部(默认 true)
  left: true,
  right: true,
  minimum: EdgeInsets.zero,   // 额外最小边距
  child: ...,
)

⚠️ 常见误用

① Scaffold 的 body 通常不需要 SafeArea 顶部——AppBar 已经处理了状态栏。

dart
Scaffold(
  appBar: AppBar(...),
  body: SafeArea(child: ...),     // ⚠️ 顶部多此一举,但底部有用
)

更精准:

dart
body: SafeArea(top: false, child: ...),   // 只避底部

② 本项目是沉浸式(edge-to-edge)

lib/main.dart:164-171 把系统栏刷成透明:

dart
return AnnotatedRegion<SystemUiOverlayStyle>(
  value: SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
    systemNavigationBarColor: Colors.transparent,
    ...
  ),
  child: child,
);

沉浸式的意思是:内容绘制到系统栏下面(更美观),此时需要自己处理安全区——通常用 SafeArea 或手动 padding。

自检

  • [ ] 知道 SafeArea 用于避开刘海/Home 条
  • [ ] 知道有 AppBar 时顶部通常不需要 SafeArea
  • [ ] 知道本项目是沉浸式,系统栏透明

六、键盘遮挡(前端最容易踩的坑之一)

问题

页面上有个输入框在底部。点击输入 → 键盘弹起 → 输入框被键盘挡住

前端对照

前端浏览器通常会自动滚动到聚焦元素。Flutter 不会自动处理,需要手动适配。

方案一:resizeToAvoidBottomInsets(最简单)

dart
Scaffold(
  resizeToAvoidBottomInsets: true,    // 默认值,键盘弹起时自动压缩 body 高度
  body: ...,
)

设为 true 时,Scaffold 的 body 高度会减去键盘高度,内容自动上移。

⚠️ 失效场景:body 里有 Stack 定位的元素,或 resizeToAvoidBottomInsets: false 时。

方案二:手动加键盘高度 padding

dart
Padding(
  padding: EdgeInsets.only(
    bottom: MediaQuery.viewInsetsOf(context).bottom,   // 键盘高度(未弹起时为 0)
  ),
  child: MyInputArea(),
)

这个写法最可靠 —— 键盘没弹起时 viewInsets.bottom 为 0,弹起时自动撑开。

方案三:SingleChildScrollView 包裹

表单页常见做法:

dart
SingleChildScrollView(
  padding: EdgeInsets.only(
    bottom: MediaQuery.viewInsetsOf(context).bottom,
  ),
  child: Form(...),
)

键盘弹起时,页面可滚动,且底部留出了键盘空间。

⚠️ 陷阱:MediaQuery.of(context) 在这里反而有用

因为你希望键盘变化时重建。所以用 MediaQuery.of(context).viewInsets.bottomviewInsetsOf 都可以,但要意识到这会让 Widget 在键盘弹起时重建(这正是我们想要的)。

项目实例

本项目是竖屏锁定的(lib/main.dart:83):

dart
// 锁定竖屏:App 全局仅允许竖屏方向,不随设备旋转横屏。
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

所以不需要处理横屏适配,键盘处理也相对简单。

自检

  • [ ] 知道 Flutter 不会自动处理键盘遮挡
  • [ ] 知道 resizeToAvoidBottomInsets 的作用与失效场景
  • [ ] 会用 MediaQuery.viewInsetsOf(context).bottom 手动加 padding

七、响应式 / 自适应

与前端的根本差异

前端Flutter
媒体查询看视口宽度LayoutBuilder父约束
断点(breakpoint)驱动约束驱动
rem / vw 单位ScreenUtil(本项目 designSize: 750×1624

本项目的适配方案:ScreenUtil 等比缩放

配置 —— lib/main.dart:370-371

dart
ScreenUtilInit(
  designSize: const Size(750, 1624),   // 设计稿尺寸(750rpx 宽)
  minTextAdapt: true,
  ...
)

设计稿按 750px 宽出图,运行时按屏幕宽度等比缩放。

文字缩放被禁用 —— lib/main.dart:391-395

dart
return MediaQuery(
  data: MediaQuery.of(context).copyWith(
    textScaler: TextScaler.noScaling,   // 固定文字缩放为 1.0
  ),
  child: content,
);

为什么禁用:App 字体不跟随系统字体大小更改(否则用户把系统字体调大,UI 会崩)。

⚠️ 禁止:页面层直接用 .w / .h / .sp

dart
SizedBox(height: 32.h)      // ❌ 禁止
SizedBox(height: AppTokens.spacingMd)   // ✅ 正确

原因:所有尺寸统一收口到 AppTokens,便于全局调整与 Figma 对齐。详见 11-组件与主题

ScreenUtil 双轨制(重要)

对象处理方式
业务 UIAppTokens.*(内部已含 ScreenUtil 缩放)
TDesign 组件内部固定 dp,不要再包 .w/.h

TDesign 组件内部是固定 dp,在 ScreenUtil 等比环境下外层再缩放会双重缩放——这也是 02 章提到的「TDesign 假溢出」的根因。

自检

  • [ ] 知道本项目设计稿基准是 750×1624
  • [ ] 知道文字缩放被强制固定为 1.0
  • [ ] 禁止在页面层用 .w/.h/.sp,一律走 AppTokens
  • [ ] 知道 TDesign 组件不要再套 ScreenUtil

八、本章速查

需求方案
折叠吸顶头SliverAppBar(pinned: true)
滚动即显头SliverAppBar(floating: true, snap: true)
普通 Widget 进 SliverSliverToBoxAdapter(无懒加载)
懒加载 Sliver 列表SliverList + SliverChildBuilderDelegate
自定义吸顶SliverPersistentHeader + delegate
外层头 + 内层 TabNestedScrollView
按可用宽度分栏LayoutBuilder
取屏幕尺寸MediaQuery.sizeOf(context)
取键盘高度MediaQuery.viewInsetsOf(context).bottom
避开刘海SafeArea
键盘顶起输入框resizeToAvoidBottomInsets 或手动 padding
项目内所有尺寸AppTokens.*(禁用 .w/.h/.sp

下一步

05 · Flutter 交互与动画