PopularPage 页面布局组件详解
PopularPage 页面布局组件详解
本文档详细介绍 PopularPage 中使用的所有 Flutter 布局组件及其用途。
重点学习,这个页面构造了大量的动画效果,如上拉下滑
向右滑动、返回功能等。
📚 目录
🏗️ 基础框架组件
StatefulWidget
作用:可变状态的 Widget 基类,允许通过 State 对象管理动态内容。
在代码中的使用:
class PopularPage extends StatefulWidget {
const PopularPage({super.key});
@override
State<PopularPage> createState() => _PopularPageState();
}为什么使用:
- 页面需要维护滚动位置、加载状态等动态数据
- 需要根据用户交互更新 UI(如刷新、加载更多)
Scaffold
作用:Material Design 页面的基础结构,提供标准的视觉元素布局。
在代码中的使用:
Scaffold(
body: CustomScrollView(...),
floatingActionButton: FloatingActionButton(...),
)提供的功能:
body:页面主要内容区域floatingActionButton:悬浮操作按钮(FAB)- 自动处理内边距、安全区域等
SafeArea
作用:确保子组件避开系统 UI(如刘海屏、状态栏、导航栏)的安全区域。
在代码中的使用:
flexibleSpace: SafeArea(
child: dtb.DragToMoveArea(
child: LayoutBuilder(...)
),
)为什么需要:
- 避免内容被系统 UI 遮挡
- 自动适配不同屏幕(刘海屏、挖孔屏等)
PopScope
作用:监听并拦截返回手势(物理返回键或手势返回)。
在代码中的使用:
PopScope(
canPop: false, // 禁止默认返回行为
onPopInvokedWithResult: (bool didPop, Object? result) {
if (didPop) return;
onBackPressed(context); // 自定义返回逻辑
},
child: Scaffold(...)
)实际用途:
- 实现"双击返回键退出应用"
- 优先关闭弹窗再退出
📜 滚动与滑动组件
CustomScrollView
作用:创建包含多个 Sliver 的自定义滚动视图。
在代码中的使用:
CustomScrollView(
controller: scrollController,
slivers: [
buildSliverAppBar(),
SliverToBoxAdapter(...),
SliverPadding(...),
],
)为什么用 CustomScrollView 而不是 ListView:
- 需要将 AppBar、进度条、内容网格组合在一起
- Sliver 可以精确控制每个部分的滚动行为
ScrollController
作用:控制滚动视图的位置和监听滚动事件。
在代码中的使用:
final ScrollController scrollController = ScrollController();
// 添加监听器
scrollController.addListener(scrollListener);
// 保存滚动位置
popularController.scrollOffset = scrollController.offset;
// 滚动到顶部
scrollController.animateTo(0,
duration: Duration(milliseconds: 350),
curve: Curves.easeOut
);核心功能:
- 监听滚动位置触发加载
- 编程式滚动到指定位置
- 保存和恢复滚动状态
SliverAppBar
作用:可伸缩的应用栏,支持滚动时展开/收缩。
在代码中的使用:
SliverAppBar(
pinned: true, // 固定在顶部
stretch: true, // 启用拉伸效果
expandedHeight: 120, // 完全展开时的高度
elevation: 0, // 无阴影
backgroundColor: Theme.of(context).colorScheme.surface,
flexibleSpace: SafeArea(
child: dtb.DragToMoveArea(...)
),
)关键参数解释:
pinned: true:收缩后固定在顶部不消失stretch: true:下拉时产生拉伸动画效果expandedHeight:展开高度(大于收缩高度产生视差)
SliverToBoxAdapter
作用:将普通 Widget 转换为 Sliver,用于在 CustomScrollView 中插入非 Sliver 组件。
在代码中的使用:
SliverToBoxAdapter(
child: Observer(
builder: (_) => AnimatedOpacity(
opacity: popularController.isLoadingMore ? 1.0 : 0.0,
child: popularController.isLoadingMore
? const LinearProgressIndicator(minHeight: 4)
: const SizedBox(height: 4),
),
),
)为什么需要:
LinearProgressIndicator不是 Sliver- 需要在 Sliver 列表中插入普通 Widget
SliverPadding
作用:为 Sliver 添加内边距。
在代码中的使用(两处):
1. 包裹错误页面和内容网格:
SliverPadding(
padding: const EdgeInsets.fromLTRB(
StyleString.cardSpace, 0, StyleString.cardSpace, 0
),
sliver: Observer(builder: (_) {
if (popularController.isTimeOut) {
return SliverToBoxAdapter(child: SizedBox(...));
}
return contentGrid(...);
})
)2. 在 contentGrid 方法中:
Widget contentGrid(bangumiList) {
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(...),
);
}SliverGrid
作用:创建网格布局的 Sliver。
在代码中的使用:
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
mainAxisSpacing: StyleString.cardSpace - 2,
crossAxisSpacing: StyleString.cardSpace,
crossAxisCount: crossCount,
mainAxisExtent: MediaQuery.of(context).size.width / crossCount / 0.65 +
MediaQuery.textScalerOf(context).scale(32.0),
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return bangumiList!.isNotEmpty
? BangumiCardV(bangumiItem: bangumiList[index])
: null;
},
childCount: bangumiList!.isNotEmpty ? bangumiList!.length : 10,
),
)参数详解:
mainAxisSpacing:行间距(垂直方向间距)crossAxisSpacing:列间距(水平方向间距)crossAxisCount:列数(根据屏幕宽度动态计算:3/5/6 列)mainAxisExtent:每个卡片的高度(宽度的 1/0.65 + 文字缩放补偿)
SliverChildBuilderDelegate
作用:按需构建 SliverGrid 的子项,提高性能。
在代码中的使用:
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return bangumiList!.isNotEmpty
? BangumiCardV(bangumiItem: bangumiList[index])
: null;
},
childCount: bangumiList!.isNotEmpty ? bangumiList!.length : 10,
)优势:
- 只渲染可见区域的子项
- 懒加载提升长列表性能
FloatingActionButton
作用:悬浮操作按钮,通常用于主要操作。
在代码中的使用:
floatingActionButton: FloatingActionButton(
onPressed: () => scrollController.animateTo(0,
duration: Duration(milliseconds: 350),
curve: Curves.easeOut
),
child: const Icon(Icons.arrow_upward),
)功能:点击后平滑滚动到页面顶部
🎨 Material 设计组件
IconButton
作用:带图标的按钮,常用于工具栏。
在代码中的使用(多处):
搜索按钮:
IconButton(
tooltip: '搜索',
onPressed: () => Modular.to.pushNamed('/search/'),
icon: const Icon(Icons.search),
)历史记录按钮:
IconButton(
tooltip: '历史记录',
onPressed: () => Modular.to.pushNamed('/settings/history/'),
icon: const Icon(Icons.history),
)关闭按钮(桌面端):
IconButton(
tooltip: '退出',
onPressed: () => windowManager.close(),
icon: const Icon(Icons.close),
)LinearProgressIndicator
作用:线性进度指示器,显示加载状态。
在代码中的使用:
LinearProgressIndicator(minHeight: 4)配合 AnimatedOpacity 实现淡入淡出:
AnimatedOpacity(
opacity: popularController.isLoadingMore ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
child: popularController.isLoadingMore
? const LinearProgressIndicator(minHeight: 4)
: const SizedBox(height: 4),
)Text
作用:显示文本。
在代码中的使用:
Text(
isTrend ? '热门番组' : popularController.currentTag,
style: theme.textTheme.headlineMedium!.copyWith(
fontWeight: fontWeight, // 动态字重
fontSize: fontSize, // 动态字号
),
)Icon
作用:显示图标。
在代码中的使用:
Icon(Icons.keyboard_arrow_down,
size: fontSize, color: theme.iconTheme.color)Row
作用:水平排列子组件的线性布局。
在代码中的使用:
Row(
mainAxisSize: MainAxisSize.min, // 最小宽度适应内容
children: [
Text(...),
const SizedBox(width: 4),
Icon(...),
],
)关键参数:
mainAxisSize: MainAxisSize.min:Row 只占用必要宽度
Column(隐式使用)
作用:垂直排列子组件的线性布局。
在代码中的使用:虽然代码中没有直接使用 Column,但 SliverGrid 内部使用了类似的网格布局逻辑。
SizedBox
作用:定义固定尺寸的盒子,常用于占位、间隔。
在代码中的使用(多处):
1. 作为占位符:
const SizedBox(height: 4) // 当不显示进度条时占位2. 作为间距:
const SizedBox(width: 4) // Text 和 Icon 之间的间隔3. 限制高度:
SizedBox(
height: 44, // 标题区域固定高度
child: Observer(...)
)4. 错误页面容器:
SizedBox(
height: 400,
child: GeneralErrorWidget(...)
)Align
作用:按照 alignment 属性对齐子组件。
在代码中的使用:
Align(
alignment: Alignment.centerLeft, // 左对齐
child: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8, right: 60),
child: SizedBox(...)
),
)Padding
作用:为子组件添加内边距。
在代码中的使用:
Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8, right: 60),
child: SizedBox(...)
)为什么不用 SliverPadding:
Padding用于普通 Widget 树SliverPadding用于 Sliver 列表
LayoutBuilder
作用:根据父组件提供的约束条件构建子组件,获取可用空间信息。
在代码中的使用:
LayoutBuilder(
builder: (context, constraints) {
final double maxExtent = 120 - MediaQuery.of(context).padding.top;
// 计算当前展开比例 t (0 = 完全收缩,1 = 完全展开)
final t = (1 - ((constraints.maxHeight - kToolbarHeight) /
(maxExtent - kToolbarHeight)).clamp(0.0, 1.0));
// 根据 t 值动态调整字体
final fontWeight = t < 0.5 ? FontWeight.w700 : FontWeight.w500;
final fontSize = lerpDouble(28, 20, t)!;
return Align(...);
},
)核心用途:
- 获取 AppBar 的实时高度
constraints.maxHeight - 计算展开比例
t用于动画插值
DragToMoveArea(自定义组件)
作用:允许用户拖动窗口移动的区域(桌面端特性)。
在代码中的使用:
dtb.DragToMoveArea(
child: LayoutBuilder(...)
)适用场景:无边框窗口模式下,用户可通过该区域拖动整个窗口
⚡ 状态响应组件
Observer
作用:MobX 响应式状态监听器,当被观察的状态变化时自动重建 UI。
在代码中的使用(多处):
1. 监听加载进度条:
Observer(
builder: (_) => AnimatedOpacity(
opacity: popularController.isLoadingMore ? 1.0 : 0.0,
child: ...
),
)2. 监听错误状态和内容列表:
Observer(builder: (_) {
if (popularController.isTimeOut) {
return SliverToBoxAdapter(child: SizedBox(...));
}
return contentGrid(
(popularController.currentTag == '')
? popularController.trendList
: popularController.bangumiList,
);
})3. 监听当前标签:
Observer(
builder: (_) {
final bool isTrend = popularController.currentTag == '';
return InkWell(
onTap: showTagMenu,
child: Row(
children: [
Text(isTrend ? '热门番组' : popularController.currentTag, ...),
Icon(...),
],
),
);
},
)为什么用 Observer:
popularController是 MobX Store- 当
isLoadingMore、isTimeOut、currentTag等状态变化时 - Observer 会自动触发
builder重新构建 UI - 无需手动调用
setState()
AnimatedOpacity
作用:透明度过渡动画组件。
在代码中的使用:
AnimatedOpacity(
opacity: popularController.isLoadingMore ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: popularController.isLoadingMore
? const LinearProgressIndicator(minHeight: 4)
: const SizedBox(height: 4),
)工作原理:
opacity: 1.0→ 完全不透明(显示)opacity: 0.0→ 完全透明(隐藏)duration:动画持续时间 300ms- 当
isLoadingMore变化时,透明度平滑过渡
AnimatedOpacity vs Opacity
| 特性 | AnimatedOpacity | Opacity |
|---|---|---|
| 动画 | ✅ 有过渡动画 | ❌ 瞬间变化 |
| 性能 | 稍高(需要计算插值) | 更低 |
| 使用场景 | 状态切换 | 静态透明度 |
🛠️ 自定义与工具组件
CustomDropdownMenu(自定义组件)
作用:自定义下拉菜单组件。
在代码中的使用:
CustomDropdownMenu(
offset: offset, // 锚点位置
buttonSize: size, // 触发按钮尺寸
animation: animation, // 动画控制器
maxWidth: 80, // 最大宽度
items: ['', ...defaultAnimeTags],
itemBuilder: (item) => item.isEmpty ? '热门番组' : item,
)为什么不用 PopupMenuButton:
- 避免闪烁问题
- 支持不同的字体大小(按钮和菜单项)
- 更灵活的定位和动画控制
PageRouteBuilder
作用:自定义路由过渡动画。
在代码中的使用:
PageRouteBuilder(
opaque: false, // 背景透明
barrierDismissible: true, // 点击外部关闭
barrierColor: Colors.transparent,
pageBuilder: (context, animation, secondaryAnimation) {
return CustomDropdownMenu(...);
},
transitionDuration: const Duration(milliseconds: 200),
reverseTransitionDuration: const Duration(milliseconds: 150),
)参数解释:
opaque: false:允许看到底层页面barrierDismissible: true:点击遮罩关闭transitionDuration:进入动画时长reverseTransitionDuration:退出动画时长
GlobalKey
作用:唯一标识一个 Widget,用于访问其状态或位置。
在代码中的使用:
final GlobalKey selectorKey = GlobalKey();
// 在 Widget 上标记
InkWell(
key: selectorKey,
borderRadius: BorderRadius.circular(8),
onTap: showTagMenu,
...
)
// 获取位置和尺寸
final RenderBox renderBox = selectorKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;为什么需要:
- 计算下拉菜单的弹出位置
- 获取按钮的精确坐标和尺寸
InkWell
作用:可点击的区域,提供水波纹效果。
在代码中的使用:
InkWell(
key: selectorKey,
borderRadius: BorderRadius.circular(8),
onTap: showTagMenu,
child: Row(...)
)与 GestureDetector 的区别:
InkWell:Material 风格水波纹,必须放在 Material 上GestureDetector:纯手势检测,无视觉效果
📐 辅助工具类
MediaQuery
作用:获取屏幕尺寸、方向、文字缩放等信息。
在代码中的使用(多处):
1. 获取屏幕宽度计算列数:
if (MediaQuery.sizeOf(context).width > LayoutBreakpoint.compact['width']!) {
crossCount = 5;
}
if (MediaQuery.sizeOf(context).width > LayoutBreakpoint.medium['width']!) {
crossCount = 6;
}2. 获取屏幕宽度计算卡片高度:
mainAxisExtent: MediaQuery.of(context).size.width / crossCount / 0.65 +
MediaQuery.textScalerOf(context).scale(32.0),3. 判断屏幕方向:
if (MediaQuery.of(context).orientation == Orientation.portrait)4. 获取状态栏高度:
MediaQuery.of(context).padding.topEdgeInsets
作用:定义矩形区域的四个方向的边距。
在代码中的使用:
1. 左右内边距:
const EdgeInsets.fromLTRB(
StyleString.cardSpace, 0, StyleString.cardSpace, 0
)2. 全方向内边距:
const EdgeInsets.all(8)3. 单侧内边距:
const EdgeInsets.only(left: 16, top: 8, bottom: 8, right: 60)BorderRadius
作用:定义圆角半径。
在代码中的使用:
borderRadius: BorderRadius.circular(8)效果:为 InkWell 添加 8px 的圆角点击效果
Offset
作用:表示二维坐标系中的一个点。
在代码中的使用:
final Offset offset = renderBox.localToGlobal(Offset.zero);含义:获取按钮左上角在全局坐标系中的位置
Duration
作用:表示时间长度。
在代码中的使用:
const Duration(milliseconds: 300) // 300 毫秒
const Duration(milliseconds: 350) // 350 毫秒
const Duration(milliseconds: 200) // 200 毫秒
const Duration(milliseconds: 150) // 150 毫秒
const Duration(seconds: 2) // 2 秒Curves
作用:动画曲线,控制动画的速度变化。
在代码中的使用:
curve: Curves.easeOut // 先快后慢的减速曲线常见曲线类型:
Curves.easeOut:减速曲线(适合滚动到顶部)Curves.easeIn:加速曲线Curves.linear:匀速Curves.bounceOut:弹跳效果
🔍 特殊函数和工具
lerpDouble
作用:在两个 double 值之间进行线性插值。
在代码中的使用:
final fontSize = lerpDouble(28, 20, t)!;含义:
- 当
t = 0时,返回 28 - 当
t = 1时,返回 20 - 当
t = 0.5时,返回 24
clamp
作用:将值限制在指定范围内。
在代码中的使用:
t = (1 - ((constraints.maxHeight - kToolbarHeight) /
(maxExtent - kToolbarHeight))).clamp(0.0, 1.0);为什么需要:
- 防止计算出的 t 值超出 0-1 范围
- 确保动画插值的正确性
📊 组件关系图
PopularPage (StatefulWidget)
└── PopScope
└── Scaffold
├── CustomScrollView
│ ├── SliverAppBar
│ │ └── SafeArea
│ │ └── DragToMoveArea
│ │ └── LayoutBuilder
│ │ └── Align
│ │ └── Padding
│ │ └── SizedBox
│ │ └── Observer
│ │ └── InkWell
│ │ └── Row
│ │ ├── Text
│ │ └── Icon
│ │
│ ├── SliverToBoxAdapter
│ │ └── Observer
│ │ └── AnimatedOpacity
│ │ └── LinearProgressIndicator
│ │
│ └── SliverPadding
│ └── Observer
│ ├── SliverToBoxAdapter (错误页面)
│ │ └── SizedBox
│ │ └── GeneralErrorWidget
│ │
│ └── SliverGrid (contentGrid)
│ └── SliverChildBuilderDelegate
│ └── BangumiCardV
│
└── FloatingActionButton
└── Icon💡 总结
按功能分类
1. 页面框架
StatefulWidget- 状态管理基类Scaffold- Material 页面结构PopScope- 返回拦截
2. 滚动相关
CustomScrollView- 自定义滚动视图ScrollController- 滚动控制SliverAppBar- 可伸缩标题栏SliverGrid- 网格布局SliverPadding- Sliver 内边距SliverToBoxAdapter- 普通 Widget 转 Sliver
3. 状态响应
Observer- MobX 状态监听AnimatedOpacity- 透明度动画
4. 布局组件
Row- 水平布局Column- 垂直布局(隐式)SizedBox- 固定尺寸盒子Padding- 内边距Align- 对齐LayoutBuilder- 约束构建器
5. Material 组件
IconButton- 图标按钮FloatingActionButton- 悬浮按钮LinearProgressIndicator- 进度条InkWell- 水波纹点击Text- 文本Icon- 图标
6. 自定义组件
DragToMoveArea- 拖动区域CustomDropdownMenu- 下拉菜单
7. 工具类
MediaQuery- 屏幕信息EdgeInsets- 边距BorderRadius- 圆角GlobalKey- 全局键PageRouteBuilder- 自定义路由
🎯 学习建议
- 先掌握基础布局:
Row、Column、SizedBox、Padding - 理解滚动机制:
CustomScrollView+Sliver系列 - 学会状态管理:
Observer+ MobX - 掌握动画组件:
AnimatedOpacity、AnimatedContainer等 - 实践自定义:根据需求组合现有组件