Flutter 小白入门教程Flutter 小白入门教程
首页
学习指南
项目实战
Flutter 官网
编程指南
首页
学习指南
项目实战
Flutter 官网
编程指南
  • 入门基础

    • 📚 基础教程
    • 第1章 - 认识 Flutter
    • 第2章 - 环境搭建
    • 第3章 - Dart 语言基础
    • 第4章 - 第一个 Flutter 应用
    • 第5章 - Widget 基础
    • 第6章 - 布局系统
    • 第7章 - 状态管理入门
    • 第8章 - 页面导航
    • 第9章 - 资源管理
  • 进阶开发

    • 第10章 - 网络请求
    • 第11章 - 本地存储
    • 第12章 - 对话框与反馈
    • 第13章 - 列表进阶
    • 第14章 - 主题定制
    • 第15章 - 状态管理进阶
    • 第16章 - 动画入门
    • 第17章 - 常用第三方包
  • 调试与发布

    • 第18章 - 调试与性能优化
    • 第19章 - 打包与发布
  • 附录

    • 附录A - UI 框架与组件库推荐
    • 附录B - 项目结构最佳实践
    • 附录C - 国际化配置
    • 附录D - 权限处理

第13章 - 列表进阶

嗨,朋友!我是长安。

在之前的章节中,我们学习了 ListView 的基本用法。但实际开发中,列表往往需要更多功能,比如下拉刷新、上拉加载更多、滑动删除等。这一章,我们来深入学习列表的进阶用法。

🔄 下拉刷新

使用 RefreshIndicator

RefreshIndicator 是 Flutter 内置的下拉刷新组件:

class RefreshListPage extends StatefulWidget {
  const RefreshListPage({super.key});

  @override
  State<RefreshListPage> createState() => _RefreshListPageState();
}

class _RefreshListPageState extends State<RefreshListPage> {
  List<String> _items = List.generate(20, (index) => '项目 $index');

  Future<void> _onRefresh() async {
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 2));
    
    setState(() {
      // 更新数据
      _items = List.generate(20, (index) => '刷新后的项目 $index');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('下拉刷新')),
      body: RefreshIndicator(
        onRefresh: _onRefresh,
        color: Colors.blue,  // 指示器颜色
        backgroundColor: Colors.white,  // 背景色
        displacement: 40,  // 下拉距离
        child: ListView.builder(
          itemCount: _items.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(_items[index]),
            );
          },
        ),
      ),
    );
  }
}

自定义刷新指示器

RefreshIndicator(
  onRefresh: _onRefresh,
  color: Colors.blue,
  strokeWidth: 3,  // 指示器粗细
  child: ListView.builder(
    physics: const AlwaysScrollableScrollPhysics(),  // 确保可以下拉
    itemCount: _items.length,
    itemBuilder: (context, index) {
      return ListTile(title: Text(_items[index]));
    },
  ),
)

📜 上拉加载更多(无限滚动)

使用 ScrollController 监听滚动

class InfiniteListPage extends StatefulWidget {
  const InfiniteListPage({super.key});

  @override
  State<InfiniteListPage> createState() => _InfiniteListPageState();
}

class _InfiniteListPageState extends State<InfiniteListPage> {
  final ScrollController _scrollController = ScrollController();
  final List<String> _items = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 1;

  @override
  void initState() {
    super.initState();
    _loadData();
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    // 当滚动到底部时加载更多
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      _loadMore();
    }
  }

  Future<void> _loadData() async {
    setState(() {
      _isLoading = true;
      _page = 1;
    });

    await Future.delayed(const Duration(seconds: 1));

    setState(() {
      _items.clear();
      _items.addAll(List.generate(20, (index) => '项目 ${index + 1}'));
      _isLoading = false;
      _hasMore = true;
    });
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() {
      _isLoading = true;
    });

    await Future.delayed(const Duration(seconds: 1));

    _page++;
    final newItems = List.generate(
      20,
      (index) => '项目 ${(_page - 1) * 20 + index + 1}',
    );

    setState(() {
      _items.addAll(newItems);
      _isLoading = false;
      // 假设加载 5 页后没有更多数据
      _hasMore = _page < 5;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('无限滚动')),
      body: RefreshIndicator(
        onRefresh: _loadData,
        child: ListView.builder(
          controller: _scrollController,
          itemCount: _items.length + 1,  // +1 用于底部加载指示器
          itemBuilder: (context, index) {
            // 最后一个项目显示加载状态
            if (index == _items.length) {
              return _buildLoadMoreWidget();
            }
            return ListTile(
              leading: CircleAvatar(child: Text('${index + 1}')),
              title: Text(_items[index]),
            );
          },
        ),
      ),
    );
  }

  Widget _buildLoadMoreWidget() {
    if (!_hasMore) {
      return const Padding(
        padding: EdgeInsets.all(16),
        child: Center(
          child: Text(
            '没有更多数据了',
            style: TextStyle(color: Colors.grey),
          ),
        ),
      );
    }

    return const Padding(
      padding: EdgeInsets.all(16),
      child: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

使用 NotificationListener

另一种监听滚动的方式:

NotificationListener<ScrollNotification>(
  onNotification: (ScrollNotification notification) {
    if (notification is ScrollEndNotification) {
      if (notification.metrics.pixels >= 
          notification.metrics.maxScrollExtent - 200) {
        _loadMore();
      }
    }
    return false;
  },
  child: ListView.builder(
    itemCount: _items.length,
    itemBuilder: (context, index) {
      return ListTile(title: Text(_items[index]));
    },
  ),
)

👆 滑动删除

使用 Dismissible

ListView.builder(
  itemCount: _items.length,
  itemBuilder: (context, index) {
    final item = _items[index];
    return Dismissible(
      key: Key(item),  // 必须提供唯一的 key
      // 滑动方向
      direction: DismissDirection.endToStart,
      // 背景(滑动时显示)
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      // 确认删除
      confirmDismiss: (direction) async {
        return await showDialog(
          context: context,
          builder: (context) => AlertDialog(
            title: const Text('确认删除'),
            content: Text('确定要删除 "$item" 吗?'),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(context, false),
                child: const Text('取消'),
              ),
              TextButton(
                onPressed: () => Navigator.pop(context, true),
                child: const Text('删除'),
              ),
            ],
          ),
        );
      },
      // 删除回调
      onDismissed: (direction) {
        setState(() {
          _items.removeAt(index);
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('已删除 "$item"'),
            action: SnackBarAction(
              label: '撤销',
              onPressed: () {
                setState(() {
                  _items.insert(index, item);
                });
              },
            ),
          ),
        );
      },
      child: ListTile(
        title: Text(item),
      ),
    );
  },
)

左右滑动不同操作

Dismissible(
  key: Key(item),
  // 左滑背景(删除)
  background: Container(
    color: Colors.green,
    alignment: Alignment.centerLeft,
    padding: const EdgeInsets.only(left: 20),
    child: const Icon(Icons.archive, color: Colors.white),
  ),
  // 右滑背景(归档)
  secondaryBackground: Container(
    color: Colors.red,
    alignment: Alignment.centerRight,
    padding: const EdgeInsets.only(right: 20),
    child: const Icon(Icons.delete, color: Colors.white),
  ),
  onDismissed: (direction) {
    if (direction == DismissDirection.startToEnd) {
      // 左滑 - 归档
      print('归档');
    } else {
      // 右滑 - 删除
      print('删除');
    }
  },
  child: ListTile(title: Text(item)),
)

🔀 列表排序

使用 ReorderableListView

class ReorderListPage extends StatefulWidget {
  const ReorderListPage({super.key});

  @override
  State<ReorderListPage> createState() => _ReorderListPageState();
}

class _ReorderListPageState extends State<ReorderListPage> {
  final List<String> _items = List.generate(10, (index) => '项目 ${index + 1}');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('拖拽排序')),
      body: ReorderableListView.builder(
        itemCount: _items.length,
        onReorder: (oldIndex, newIndex) {
          setState(() {
            if (newIndex > oldIndex) {
              newIndex -= 1;
            }
            final item = _items.removeAt(oldIndex);
            _items.insert(newIndex, item);
          });
        },
        itemBuilder: (context, index) {
          return ListTile(
            key: Key(_items[index]),  // 必须提供 key
            leading: const Icon(Icons.drag_handle),
            title: Text(_items[index]),
            tileColor: Colors.white,
          );
        },
      ),
    );
  }
}

📌 Sticky Header(粘性头部)

使用 SliverList 和 SliverPersistentHeader

class StickyHeaderPage extends StatelessWidget {
  const StickyHeaderPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // AppBar
          const SliverAppBar(
            title: Text('粘性头部'),
            floating: true,
          ),
          // 第一组
          SliverPersistentHeader(
            pinned: true,
            delegate: _StickyHeaderDelegate(
              child: Container(
                color: Colors.blue,
                alignment: Alignment.centerLeft,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: const Text(
                  'A',
                  style: TextStyle(color: Colors.white, fontSize: 20),
                ),
              ),
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => ListTile(title: Text('A - 项目 $index')),
              childCount: 5,
            ),
          ),
          // 第二组
          SliverPersistentHeader(
            pinned: true,
            delegate: _StickyHeaderDelegate(
              child: Container(
                color: Colors.green,
                alignment: Alignment.centerLeft,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: const Text(
                  'B',
                  style: TextStyle(color: Colors.white, fontSize: 20),
                ),
              ),
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => ListTile(title: Text('B - 项目 $index')),
              childCount: 5,
            ),
          ),
          // 第三组
          SliverPersistentHeader(
            pinned: true,
            delegate: _StickyHeaderDelegate(
              child: Container(
                color: Colors.orange,
                alignment: Alignment.centerLeft,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: const Text(
                  'C',
                  style: TextStyle(color: Colors.white, fontSize: 20),
                ),
              ),
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => ListTile(title: Text('C - 项目 $index')),
              childCount: 10,
            ),
          ),
        ],
      ),
    );
  }
}

// StickyHeader 代理
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
  final Widget child;
  
  _StickyHeaderDelegate({required this.child});

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return SizedBox.expand(child: child);
  }

  @override
  double get maxExtent => 50;

  @override
  double get minExtent => 50;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    return false;
  }
}

📱 分组列表

带分组头的列表

class GroupedListPage extends StatelessWidget {
  GroupedListPage({super.key});

  final Map<String, List<String>> _data = {
    '水果': ['苹果', '香蕉', '橙子', '葡萄'],
    '蔬菜': ['白菜', '萝卜', '西红柿', '黄瓜'],
    '肉类': ['猪肉', '牛肉', '鸡肉', '羊肉'],
  };

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('分组列表')),
      body: ListView.builder(
        itemCount: _data.length,
        itemBuilder: (context, groupIndex) {
          final groupName = _data.keys.elementAt(groupIndex);
          final items = _data[groupName]!;
          
          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 分组头
              Container(
                width: double.infinity,
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                color: Colors.grey[200],
                child: Text(
                  groupName,
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                    fontSize: 16,
                  ),
                ),
              ),
              // 分组内容
              ...items.map((item) => ListTile(
                title: Text(item),
                onTap: () {},
              )),
            ],
          );
        },
      ),
    );
  }
}

🎯 实战:完整的新闻列表

import 'package:flutter/material.dart';

class News {
  final int id;
  final String title;
  final String summary;
  final String imageUrl;
  final String date;

  News({
    required this.id,
    required this.title,
    required this.summary,
    required this.imageUrl,
    required this.date,
  });
}

class NewsListPage extends StatefulWidget {
  const NewsListPage({super.key});

  @override
  State<NewsListPage> createState() => _NewsListPageState();
}

class _NewsListPageState extends State<NewsListPage> {
  final ScrollController _scrollController = ScrollController();
  final List<News> _newsList = [];
  bool _isLoading = false;
  bool _hasMore = true;
  int _page = 1;

  @override
  void initState() {
    super.initState();
    _loadData();
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      _loadMore();
    }
  }

  Future<void> _loadData() async {
    setState(() {
      _isLoading = true;
      _page = 1;
    });

    await Future.delayed(const Duration(seconds: 1));

    setState(() {
      _newsList.clear();
      _newsList.addAll(_generateNews(1));
      _isLoading = false;
      _hasMore = true;
    });
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() {
      _isLoading = true;
    });

    await Future.delayed(const Duration(seconds: 1));

    _page++;
    setState(() {
      _newsList.addAll(_generateNews(_page));
      _isLoading = false;
      _hasMore = _page < 5;
    });
  }

  List<News> _generateNews(int page) {
    return List.generate(10, (index) {
      final id = (page - 1) * 10 + index + 1;
      return News(
        id: id,
        title: '新闻标题 $id:这是一条测试新闻的标题内容',
        summary: '这是新闻 $id 的摘要内容,用于展示在列表中的简短描述...',
        imageUrl: 'https://picsum.photos/200/100?random=$id',
        date: '2024-01-${(id % 28 + 1).toString().padLeft(2, '0')}',
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('新闻列表'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _loadData,
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: _loadData,
        child: _newsList.isEmpty && _isLoading
            ? const Center(child: CircularProgressIndicator())
            : ListView.builder(
                controller: _scrollController,
                itemCount: _newsList.length + 1,
                itemBuilder: (context, index) {
                  if (index == _newsList.length) {
                    return _buildLoadMoreWidget();
                  }
                  return _buildNewsItem(_newsList[index]);
                },
              ),
      ),
    );
  }

  Widget _buildNewsItem(News news) {
    return Dismissible(
      key: Key('news_${news.id}'),
      direction: DismissDirection.endToStart,
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (direction) {
        setState(() {
          _newsList.removeWhere((n) => n.id == news.id);
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('已删除: ${news.title}')),
        );
      },
      child: Card(
        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: InkWell(
          onTap: () {
            // 跳转到详情页
          },
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                // 图片
                ClipRRect(
                  borderRadius: BorderRadius.circular(8),
                  child: Image.network(
                    news.imageUrl,
                    width: 100,
                    height: 80,
                    fit: BoxFit.cover,
                    errorBuilder: (context, error, stackTrace) {
                      return Container(
                        width: 100,
                        height: 80,
                        color: Colors.grey[200],
                        child: const Icon(Icons.image),
                      );
                    },
                  ),
                ),
                const SizedBox(width: 12),
                // 内容
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        news.title,
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        news.summary,
                        style: TextStyle(
                          color: Colors.grey[600],
                          fontSize: 14,
                        ),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        news.date,
                        style: TextStyle(
                          color: Colors.grey[400],
                          fontSize: 12,
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildLoadMoreWidget() {
    if (!_hasMore) {
      return Container(
        padding: const EdgeInsets.all(16),
        alignment: Alignment.center,
        child: const Text(
          '— 没有更多了 —',
          style: TextStyle(color: Colors.grey),
        ),
      );
    }

    return Container(
      padding: const EdgeInsets.all(16),
      alignment: Alignment.center,
      child: const CircularProgressIndicator(),
    );
  }
}

📝 小结

这一章我们学习了:

  • ✅ 下拉刷新(RefreshIndicator)
  • ✅ 上拉加载更多(无限滚动)
  • ✅ 滑动删除(Dismissible)
  • ✅ 拖拽排序(ReorderableListView)
  • ✅ 粘性头部(SliverPersistentHeader)
  • ✅ 分组列表

列表性能优化

  1. 使用 ListView.builder - 只渲染可见项
  2. 提供 itemExtent - 固定高度时性能更好
  3. 使用 const - 减少不必要的重建
  4. 图片懒加载 - 使用 cached_network_image
  5. 避免在 itemBuilder 中创建对象

💪 练习题

  1. 实现一个带下拉刷新和上拉加载的商品列表
  2. 实现一个可以滑动删除的待办事项列表
  3. 实现一个可以拖拽排序的收藏列表

🚀 下一步

学会了列表进阶后,下一章我们来学习 主题定制,让应用有独特的视觉风格!


由 编程指南 提供

最近更新: 2026/2/3 16:24
Contributors: 王长安
Prev
第12章 - 对话框与反馈
Next
第14章 - 主题定制