Flutter Sliver 高级滚动打造 iOS 通讯录体验(十三)

12 阅读5分钟

前言

上一篇文章中,我们用 LayoutBuilder 实现了自适应布局——大屏幕并排显示、小屏幕单页显示。但侧边栏和详情面板里都是占位文字,还没有真正的联系人列表。

今天这篇文章基于官方教程的「Scrolling and Slivers」章节,我们将学习 Flutter 中最强大的滚动机制——Sliver。通过它,你可以实现 iOS 通讯录那样的效果:滑动时导航栏自动折叠、搜索框隐藏在顶部、联系人按字母分组排列。


一、什么是 Sliver?

1.1 Sliver vs 普通 Widget

在 Flutter 中,普通 Widget(如 ColumnRowContainer)可以在任何地方使用。而 Sliver 是一种专门为滚动设计的特殊 Widget,只能放在滚动视图(如 CustomScrollView)内部。

你可以把它们想象成两种不同的"积木":普通 Widget 是通用积木,哪里都能摆;Sliver 是专用的"滚轮积木",只能放在滑轨上。

1.2 核心规则

// ✅ 正确:Sliver 放在 CustomScrollView 的 slivers 列表中
CustomScrollView(
  slivers: [
    CupertinoSliverNavigationBar(...),  // ← Sliver 组件
    SliverList(...),                     // ← Sliver 组件
  ],
)

// ❌ 错误:普通 Widget 不能直接放在 slivers 中
CustomScrollView(
  slivers: [
    Text('Hello'),  // ← 报错!Text 不是 Sliver
  ],
)

// ✅ 解决:用 SliverToBoxAdapter 包裹普通 Widget
CustomScrollView(
  slivers: [
    SliverToBoxAdapter(child: Text('Hello')),  // ← 正确!
  ],
)

1.3 常用 Sliver 组件

Sliver 组件作用
CupertinoSliverNavigationBariOS 风格的可折叠导航栏
SliverList滚动列表(类似 ListView 的 Sliver 版本)
SliverGrid滚动网格
SliverFillRemaining填满剩余空间(用于放普通 Widget)
SliverToBoxAdapter将普通 Widget 转为 Sliver

二、构建联系人分组列表

2.1 创建可复用的 _ContactGroupsView

为了让分组列表在小屏幕(作为主页面)和大屏幕(作为侧边栏)中都能复用,我们创建一个私有组件 _ContactGroupsView

更新 lib/screens/contact_groups.dart

import 'package:flutter/cupertino.dart';
import 'package:rolodex/data/contact.dart';
import 'package:rolodex/data/contact_group.dart';
import 'package:rolodex/main.dart';

// ContactGroupsPage:公开的页面组件
// 小屏幕模式下直接显示此页面
class ContactGroupsPage extends StatelessWidget {
  const ContactGroupsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return _ContactGroupsView(
      selectedListId: 0,
      onListSelected: (list) {
        // 下一课(导航)会实现页面跳转
        debugPrint(list.toString());
      },
    );
  }
}

// _ContactGroupsView:私有的可复用视图
// 包含 CustomScrollView + Sliver 实现可折叠导航栏 + 分组列表
class _ContactGroupsView extends StatelessWidget {
  const _ContactGroupsView({
    required this.onListSelected,
    this.selectedListId,
  });

  final int? selectedListId;
  // 回调函数:用户点击某个分组时调用
  final Function(ContactGroup) onListSelected;

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      backgroundColor: CupertinoColors.extraLightBackgroundGray,
      // ===== CustomScrollView =====
      // Sliver 组件的容器,所有 Sliver 都放在 slivers 列表中
      child: CustomScrollView(
        slivers: [
          // ===== 可折叠导航栏 =====
          // 向下滚动时,大标题会折叠成小标题,节省屏幕空间
          const CupertinoSliverNavigationBar(
            largeTitle: Text('Lists'),
          ),

          // ===== 分组列表 =====
          // SliverFillRemaining 填满导航栏下方的所有剩余空间
          // 内部放普通 Widget(非 Sliver)
          SliverFillRemaining(
            // ValueListenableBuilder 监听分组数据变化
            // 当数据更新时自动重绘列表
            child: ValueListenableBuilder<List<ContactGroup>>(
              valueListenable: contactGroupsModel.listsNotifier,
              builder: (context, contactLists, child) {
                // 定义图标:全部联系人用群组图标,其他用双人图标
                const groupIcon = Icon(
                  CupertinoIcons.group, weight: 900, size: 32,
                );
                const pairIcon = Icon(
                  CupertinoIcons.person_2, weight: 900, size: 24,
                );

                // CupertinoListSection.insetGrouped:iOS 风格的圆角分组列表
                return CupertinoListSection.insetGrouped(
                  header: const Text('iPhone'),
                  children: [
                    for (final ContactGroup contactList in contactLists)
                      CupertinoListTile(
                        // 根据分组类型显示不同图标
                        leading: contactList.id == 0 ? groupIcon : pairIcon,
                        title: Text(contactList.label),
                        // 右侧显示联系人数量和箭头
                        trailing: _buildTrailing(
                          contactList.contacts, context,
                        ),
                        // 点击时调用回调
                        onTap: () => onListSelected(contactList),
                      ),
                  ],
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  // 构建列表项右侧的"数量 + 箭头"
  Widget _buildTrailing(List<Contact> contacts, BuildContext context) {
    final TextStyle style = CupertinoTheme.of(context)
        .textTheme.textStyle
        .copyWith(color: CupertinoColors.systemGrey);

    return Row(
      mainAxisSize: MainAxisSize.min, // 让 Row 只占内容需要的宽度
      children: [
        Text(contacts.length.toString(), style: style),
        const Icon(
          CupertinoIcons.forward,
          color: CupertinoColors.systemGrey3,
          size: 18,
        ),
      ],
    );
  }
}

三、构建联系人列表(带搜索和字母索引)

更新 lib/screens/contacts.dart

import 'package:flutter/cupertino.dart';
import 'package:rolodex/data/contact.dart';
import 'package:rolodex/data/contact_group.dart';
import 'package:rolodex/main.dart';

// ContactListsPage:公开的页面组件
class ContactListsPage extends StatelessWidget {
  const ContactListsPage({super.key, required this.listId});

  final int listId;

  @override
  Widget build(BuildContext context) {
    return _ContactListView(listId: listId);
  }
}

// _ContactListView:私有的可复用联系人列表视图
// 包含:可折叠导航栏 + 搜索框 + 按字母分组的联系人列表
class _ContactListView extends StatelessWidget {
  const _ContactListView({
    required this.listId,
    this.automaticallyImplyLeading = true,
  });

  final int listId;
  // 是否自动显示返回按钮(大屏幕侧边栏模式不需要)
  final bool automaticallyImplyLeading;

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      child: ValueListenableBuilder<List<ContactGroup>>(
        valueListenable: contactGroupsModel.listsNotifier,
        builder: (context, contactGroups, child) {
          // 根据 listId 获取对应的分组数据
          final ContactGroup contactList =
              contactGroupsModel.findContactList(listId);
          // 获取按字母分组的联系人 Map
          // 如 {'A': [Alex, Anna], 'B': [Ben], ...}
          final AlphabetizedContactMap contacts =
              contactList.alphabetizedContacts;

          return CustomScrollView(
            slivers: [
              // ===== 可折叠导航栏 + 搜索框 =====
              // .search 构造函数提供集成搜索功能
              // 向下滚动时,大标题折叠,搜索框隐入导航栏
              CupertinoSliverNavigationBar.search(
                largeTitle: Text(contactList.title),
                automaticallyImplyLeading: automaticallyImplyLeading,
                // iOS 风格的搜索输入框
                searchField: const CupertinoSearchTextField(
                  // 右侧麦克风图标
                  suffixIcon: Icon(CupertinoIcons.mic_fill),
                  suffixMode: OverlayVisibilityMode.always,
                ),
              ),

              // ===== 按字母分组的联系人列表 =====
              // SliverList.list 接收普通 Widget 列表
              // 将它们变成可滚动的 Sliver 内容
              SliverList.list(
                children: [
                  const SizedBox(height: 20),
                  // 遍历每个字母分组,生成一个 ContactListSection
                  ...contacts.keys.map(
                    (String initial) => ContactListSection(
                      lastInitial: initial,
                      contacts: contacts[initial]!,
                    ),
                  ),
                ],
              ),
            ],
          );
        },
      ),
    );
  }
}

// ContactListSection:单个字母分组
// 如 "A" 分组下显示 Alex Anderson、Anna Haro 等
class ContactListSection extends StatelessWidget {
  const ContactListSection({
    super.key,
    required this.lastInitial,   // 字母(如 'A')
    required this.contacts,      // 该字母下的联系人列表
  });

  final String lastInitial;
  final List<Contact> contacts;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 20, 0),
      child: Column(
        children: [
          const SizedBox(height: 15),
          // 字母标题(如 "A"、"B")
          Align(
            alignment: AlignmentDirectional.bottomStart,
            child: Text(
              lastInitial,
              style: const TextStyle(
                color: CupertinoColors.systemGrey,
                fontSize: 15,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
          // 该字母下的联系人列表
          CupertinoListSection(
            backgroundColor: CupertinoColors.systemBackground,
            dividerMargin: 0,
            additionalDividerMargin: 0,
            topMargin: 4,
            children: [
              for (final Contact contact in contacts)
                CupertinoListTile(
                  padding: EdgeInsets.zero,
                  title: Text('${contact.firstName} ${contact.lastName}'),
                ),
            ],
          ),
        ],
      ),
    );
  }
}

四、本节知识点小结

Sliver: Flutter 中专为滚动设计的特殊 Widget。只能放在 CustomScrollView 等滚动视图中。普通 Widget 需要用 SliverToBoxAdapterSliverFillRemaining 包裹后才能在 Sliver 上下文中使用。

CustomScrollView: Sliver 的容器,通过 slivers 列表组合多个 Sliver,实现复杂的滚动效果。

CupertinoSliverNavigationBar: iOS 风格的可折叠导航栏。滚动时大标题自动折叠成小标题。.search 构造函数还能集成搜索框。

SliverList: Sliver 版本的列表。SliverList.list 接收普通 Widget 列表,自动将它们变成可滚动内容。

字母索引: 利用 ContactGroup.alphabetizedContacts 按姓氏首字母分组,每组一个 ContactListSection,实现 iOS 通讯录风格的字母索引。


五、下一步学习

联系人列表已经能滚动、能搜索、按字母分组了!下一课是本系列的最后一课——Stack Based Navigation(基于栈的导航),让小屏幕上点击分组能跳转到联系人列表页面。

我们下篇文章见!

参考资料:Flutter 官方教程 - Scrolling and Slivers