Flutter 中使用NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

599 阅读3分钟

Flutter 中使用 NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

Demo 仓库github.com/wutao23yzd/…


前言

在日常社区类 App 开发中,「用户主页」往往需要展示 关注 / 粉丝 等多 Tab 列表,并且顶部通常伴随着可吸顶的用户信息栏。要做到:

  1. 顶部信息栏(SliverAppBar)通过 pinned: true 始终固定在顶部;
  2. TabBarTabBarView 滑动手势联动;
  3. 列表切换时保持滚动位置 & 避免重复构建;
  4. 状态统一交由 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_blocBloc/Cubit 状态管理
equatableImmutable 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: trueSliverAppBar 在滚动到顶后保持可见;两侧 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() 限制为竖屏

性能优化点

  1. 缓存列表cacheExtent: 2760SliverList 预缓存更多 item,提升滚动流畅度;
  2. 避免重建:两列表分别混入 AutomaticKeepAliveClientMixin,切 Tab 不重跑 build
  3. Selector 粒度context.select 精准订阅,只有关心字段变化时才重建 widget;

小结

  • 解决「顶部信息随滚动吸顶」与「内部 Tab 列表滑动」的场景。
  • 通过 Bloc 将 UI 与业务完全解耦,状态单向流动,易于测试与维护。
  • 一些常见痛点(滚动冲突、点击反馈、亮暗适配)都可以通过封装组件复用。

希望本文能帮助你在实际项目中快速落地关注/粉丝页,或者提供一些架构上的启发。🎉