Flutter 中使用 NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)
Demo 仓库:github.com/wutao23yzd/…
前言
在日常社区类 App 开发中,「用户主页」往往需要展示 关注 / 粉丝 等多 Tab 列表,并且顶部通常伴随着可吸顶的用户信息栏。要做到:
- 顶部信息栏(
SliverAppBar)通过pinned: true始终固定在顶部; TabBar与TabBarView滑动手势联动;- 列表切换时保持滚动位置 & 避免重复构建;
- 状态统一交由 Bloc 管理;
本文将基于一段完整的 Demo 代码,拆解实现思路与关键细节。
目录结构概览
lib/
├─ main.dart // 入口 & Provider 注入
├─ app_scaffold.dart // 通用 Scaffold 封装
└─ follow/
├─ bloc/ // ✨ Bloc
| ├─ user_profile_bloc.dart
| ├─ user_profile_event.dart
| └─ user_profile_state.dart
└─ view/
├─ user_profile_statistics.dart // 页面主体
├─ user_profile_followers.dart // 粉丝列表
├─ user_profile_following.dart // 关注列表
├─ user_profile_list_tile.dart // 列表项
├─ user_profile_button.dart // 通用按钮
└─ tappable.dart // 自定义点击反馈
命名约定:所有与「用户资料」相关的文件统一前缀
user_profile_,查看起来更直观。
关键依赖
| Package | 说明 |
|---|---|
flutter_bloc | Bloc/Cubit 状态管理 |
equatable | Immutable state 对比 |
UI 结构拆解
1. 入口 & BlocProvider
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.black)),
home: BlocProvider(
create: (_) => UserProfileBloc(),
child: const UserProfileStatistics(tabIndex: 0),
),
);
}
}
通过 顶层
BlocProvider注入UserProfileBloc,后续所有子组件可直接context.select(...)读取状态。
2. UserProfileStatistics 页面核心
class UserProfileStatistics extends StatefulWidget {
const UserProfileStatistics({super.key, required this.tabIndex});
final int tabIndex;
...
}
class _UserProfileStatisticsState extends State<UserProfileStatistics>
with TickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// 根据外部传入的 tabIndex 初始定位
WidgetsBinding.instance.addPostFrameCallback((_) {
_tabController.animateTo(widget.tabIndex);
});
}
@override
Widget build(BuildContext context) {
return AppScaffold(
body: NestedScrollView(
headerSliverBuilder: (_, __) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: UserProfileStatisticsAppBar(controller: _tabController),
),
],
body: TabBarView(
controller: _tabController,
children: const [
UserProfileFollowers(),
UserProfileFollowing(),
],
),
),
);
}
}
SliverOverlapAbsorber/Injector解决 Tab 内部滚动冲突。AutomaticKeepAliveClientMixin保证TabBarView内部列表 不被销毁。
3. 顶部 SliverAppBar + TabBar
class UserProfileStatisticsAppBar extends StatelessWidget {
const UserProfileStatisticsAppBar({super.key, required this.controller});
final TabController controller;
@override
Widget build(BuildContext context) {
final followers = context.select((UserProfileBloc b) => b.state.followersCount);
final followings = context.select((UserProfileBloc b) => b.state.followingsCount);
final user = context.select((UserProfileBloc b) => b.state.user);
return SliverAppBar(
pinned: true,
title: Text(user.username ?? ''),
bottom: TabBar(
controller: controller,
indicatorWeight: 1,
labelColor: context.adaptiveColor,
tabs: [
Tab(text: '$followers 粉丝'),
Tab(text: '$followings 关注'),
],
),
);
}
}
pinned: true让SliverAppBar在滚动到顶后保持可见;两侧Tab文案通过 Bloc 实时反映 关注/粉丝数量。
4. 列表项 UserProfileListTile
class UserProfileListTile extends StatelessWidget {
const UserProfileListTile({super.key, required this.user, required this.follower});
final User user; // 当前渲染的用户
final bool follower; // 是否粉丝列表
@override
Widget build(BuildContext context) {
return Tappable.faded(
onTap: () {},
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
CircleAvatar(foregroundImage: NetworkImage(user.avatarUrl ?? ''), radius: 26),
const SizedBox(width: 12),
Expanded(child: _UserInfo(follower: follower, user: user)),
],
),
);
}
}
- 自定义
Tappable.faded组件通过FadeTransition实现 点击渐变动效; UserActionButton根据当前登录用户/目标用户身份动态切换「关注 / 粉丝 / 移除」等按钮文案。
Bloc 状态管理
1. State
class UserProfileState extends Equatable {
const UserProfileState({
required this.user,
required this.followings,
required this.followers,
});
// 省略初始 mock 数据...
@override
List<Object> get props => [user, followings, followers];
}
2. Event
目前 Demo 仅展示静态数据,正式接入接口后可以新增:
sealed class UserProfileEvent extends Equatable {
const UserProfileEvent();
}
class FollowToggled extends UserProfileEvent {
final User target;
const FollowToggled(this.target);
}
3. Bloc
class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
UserProfileBloc() : super(UserProfileState.initial()) {
on<FollowToggled>(_onFollowToggled);
}
void _onFollowToggled(
FollowToggled event,
Emitter<UserProfileState> emit,
) {
// 根据 event.target.id 更新 followings / followers
// emit(newState);
}
}
推荐再配合 `` 将用户资料进行本地持久化,优化首屏体验。
交互与细节
| 细节 | 方案 |
|---|---|
| 点击反馈 | Tappable.faded 使用 AnimationController + FadeTransition,避免手写 GestureDetector 重复代码 |
| iOS / Android 状态栏主题 | SystemUiOverlayStyle 封装,自适应亮/暗模式 |
| 垂直方向锁定 | SystemUiOverlayTheme.setPortraitOrientation() 限制为竖屏 |
性能优化点
- 缓存列表:
cacheExtent: 2760让SliverList预缓存更多 item,提升滚动流畅度; - 避免重建:两列表分别混入
AutomaticKeepAliveClientMixin,切 Tab 不重跑build; - Selector 粒度:
context.select精准订阅,只有关心字段变化时才重建 widget;
小结
- 解决「顶部信息随滚动吸顶」与「内部 Tab 列表滑动」的场景。
- 通过 Bloc 将 UI 与业务完全解耦,状态单向流动,易于测试与维护。
- 一些常见痛点(滚动冲突、点击反馈、亮暗适配)都可以通过封装组件复用。
希望本文能帮助你在实际项目中快速落地关注/粉丝页,或者提供一些架构上的启发。🎉