📜 12 - 滚动列表

阶段二:Widget基础 · 第12课 · 预计学习时间:55分钟
✅ 验证通过

📖 ListView

// ✅ 验证通过:Flutter 3.22

/// ListView三种构造方式

// 1. ListView() — 适合少量子项(一次性创建所有)
ListView(
  padding: const EdgeInsets.all(16),
  children: [
    ListTile(title: Text('Item 1')),
    ListTile(title: Text('Item 2')),
    ListTile(title: Text('Item 3')),
  ],
)

// 2. ListView.builder() — 适合大量/无限列表(懒加载)
ListView.builder(
  itemCount: 1000,  // 可选,不设则无限
  itemBuilder: (context, index) {
    return ListTile(
      title: Text('Item $index'),
      leading: CircleAvatar(child: Text('$index')),
    );
  },
)

// 3. ListView.separated() — 带分隔符
ListView.separated(
  itemCount: 20,
  itemBuilder: (context, index) => ListTile(
    title: Text('Item $index'),
  ),
  separatorBuilder: (context, index) => const Divider(),
)

/// ListView进阶
class AdvancedListView extends StatelessWidget {
  const AdvancedListView({super.key});
  
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 100,
      // 预加载区域(默认0.5倍视口)
      cacheExtent: 500,
      // 滚动物理特性
      physics: const BouncingScrollPhysics(),
      // 反向滚动
      reverse: false,
      // 滚动方向
      scrollDirection: Axis.vertical,
      // 控制器
      controller: ScrollController(),
      itemBuilder: (context, index) {
        // ✅ 根据类型返回不同Widget
        if (index == 0) {
          return const _HeaderWidget();
        }
        if (index.isOdd) {
          return _FeaturedItem(index: index);
        }
        return _NormalItem(index: index);
      },
    );
  }
}

/// Pull-to-Refresh 下拉刷新
class RefreshListDemo extends StatefulWidget {
  const RefreshListDemo({super.key});
  
  @override
  State<RefreshListDemo> createState() => _RefreshListDemoState();
}

class _RefreshListDemoState extends State<RefreshListDemo> {
  List<String> _items = List.generate(20, (i) => 'Item $i');
  
  Future<void> _refresh() async {
    await Future.delayed(const Duration(seconds: 1));
    setState(() {
      _items = List.generate(20, (i) => '刷新后 Item ${DateTime.now().second}-$i');
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _refresh,
      color: Theme.of(context).colorScheme.primary,
      child: ListView.builder(
        itemCount: _items.length,
        itemBuilder: (context, index) => ListTile(title: Text(_items[index])),
      ),
    );
  }
}

📖 GridView

// ✅ 验证通过

/// GridView三种构造方式

// 1. GridView() — 适合少量子项
GridView(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,        // 列数
    mainAxisSpacing: 8,       // 主轴间距
    crossAxisSpacing: 8,      // 交叉轴间距
    childAspectRatio: 1.0,    // 子项宽高比
  ),
  children: [
    Container(color: Colors.red),
    Container(color: Colors.green),
    Container(color: Colors.blue),
  ],
)

// 2. GridView.builder() — 懒加载
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    mainAxisSpacing: 12,
    crossAxisSpacing: 12,
    childAspectRatio: 0.75,   // 宽:高 = 0.75:1
  ),
  itemCount: 50,
  itemBuilder: (context, index) => _ProductCard(index: index),
)

// 3. GridView.extent() — 按最大宽度自动计算列数
GridView.extent(
  maxCrossAxisExtent: 150,  // 每项最大宽度150
  children: [...],
)

// 4. GridView.count() — 指定列数
GridView.count(
  crossAxisCount: 3,
  children: [...],
)

📖 CustomScrollView与Sliver

// ✅ 验证通过
/// CustomScrollView — 自定义滚动视图
/// 通过组合Sliver实现复杂的滚动效果

class CustomScrollDemo extends StatelessWidget {
  const CustomScrollDemo({super.key});
  
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        // 1. SliverAppBar — 可折叠的AppBbar
        SliverAppBar(
          expandedHeight: 200,
          pinned: true,           // 折叠后是否固定
          floating: false,        // 是否在下滑时立即显示
          snap: false,            // 配合floating使用
          stretch: true,          // 允许过度拉伸
          flexibleSpace: FlexibleSpaceBar(
            title: const Text('Sliver Demo'),
            background: Container(
              decoration: const BoxDecoration(
                gradient: LinearGradient(
                  colors: [Colors.purple, Colors.blue],
                ),
              ),
            ),
          ),
        ),
        
        // 2. SliverList — 列表
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) => ListTile(title: Text('Item $index')),
            childCount: 20,
          ),
        ),
        
        // 3. SliverGrid — 网格
        SliverGrid(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3,
            mainAxisSpacing: 8,
            crossAxisSpacing: 8,
          ),
          delegate: SliverChildBuilderDelegate(
            (context, index) => Container(
              color: Colors.primaries[index % Colors.primaries.length],
              child: Center(child: Text('$index')),
            ),
            childCount: 12,
          ),
        ),
        
        // 4. SliverToBoxAdapter — 包裹普通Widget
        SliverToBoxAdapter(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Text('分节标题', style: Theme.of(context).textTheme.titleLarge),
          ),
        ),
        
        // 5. SliverPadding — Sliver的内边距
        const SliverPadding(
          padding: EdgeInsets.all(16),
          sliver: SliverToBoxAdapter(child: Text('带Padding的内容')),
        ),
        
        // 6. SliverPersistentHeader — 持久化头部
        SliverPersistentHeader(
          pinned: true,
          delegate: _SectionHeaderDelegate('固定标题'),
        ),
        
        // 7. SliverOpacity — 透明度
        const SliverOpacity(
          opacity: 0.5,
          sliver: SliverToBoxAdapter(child: Text('半透明内容')),
        ),
      ],
    );
  }
}

class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
  final String title;
  
  _SectionHeaderDelegate(this.title);
  
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      color: Theme.of(context).colorScheme.surface,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      alignment: Alignment.centerLeft,
      child: Text(title, style: Theme.of(context).textTheme.titleMedium),
    );
  }
  
  @override
  double get maxExtent => 50;
  
  @override
  double get minExtent => 50;
  
  @override
  bool shouldRebuild(covariant _SectionHeaderDelegate oldDelegate) {
    return title != oldDelegate.title;
  }
}

💻 实战:电商首页

// ✅ 验证通过:Flutter 3.22 + Dart 3.4
// 文件:lib/shop_home.dart

import 'package:flutter/material.dart';

void main() => runApp(const ShopApp());

class ShopApp extends StatelessWidget {
  const ShopApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '电商首页',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const ShopHomePage(),
    );
  }
}

// 数据模型
class Product {
  final String id;
  final String name;
  final double price;
  final String imageUrl;
  final double rating;
  final int reviews;
  final String category;
  final bool isNew;
  
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
    this.rating = 4.5,
    this.reviews = 0,
    required this.category,
    this.isNew = false,
  });
}

class BannerItem {
  final String title;
  final String subtitle;
  final List<Color> gradient;
  
  const BannerItem({required this.title, required this.subtitle, required this.gradient});
}

// 示例数据
const banners = [
  BannerItem(title: '新品首发', subtitle: '限时8折优惠', gradient: [Colors.purple, Colors.blue]),
  BannerItem(title: '夏日特卖', subtitle: '满199减50', gradient: [Colors.orange, Colors.red]),
  BannerItem(title: '会员日', subtitle: '专属福利等你来', gradient: [Colors.teal, Colors.green]),
];

const categories = ['全部', '数码', '服饰', '食品', '家居', '运动'];

const products = [
  Product(id: '1', name: '无线蓝牙耳机', price: 299, imageUrl: '', category: '数码', rating: 4.8, reviews: 256, isNew: true),
  Product(id: '2', name: '轻薄笔记本', price: 5999, imageUrl: '', category: '数码', rating: 4.6, reviews: 128),
  Product(id: '3', name: '运动跑鞋', price: 399, imageUrl: '', category: '运动', rating: 4.7, reviews: 512),
  Product(id: '4', name: '有机零食礼盒', price: 89, imageUrl: '', category: '食品', rating: 4.3, reviews: 89, isNew: true),
  Product(id: '5', name: '智能手表', price: 1299, imageUrl: '', category: '数码', rating: 4.5, reviews: 340),
  Product(id: '6', name: '棉麻T恤', price: 159, imageUrl: '', category: '服饰', rating: 4.4, reviews: 198),
  Product(id: '7', name: '北欧台灯', price: 269, imageUrl: '', category: '家居', rating: 4.6, reviews: 67),
  Product(id: '8', name: '瑜伽垫', price: 129, imageUrl: '', category: '运动', rating: 4.2, reviews: 445),
];

// 主页面
class ShopHomePage extends StatefulWidget {
  const ShopHomePage({super.key});
  
  @override
  State<ShopHomePage> createState() => _ShopHomePageState();
}

class _ShopHomePageState extends State<ShopHomePage> {
  String _selectedCategory = '全部';
  
  List<Product> get _filteredProducts => _selectedCategory == '全部'
      ? products
      : products.where((p) => p.category == _selectedCategory).toList();
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // 搜索AppBbar
          SliverAppBar(
            pinned: true,
            title: TextField(
              decoration: InputDecoration(
                hintText: '搜索商品...',
                prefixIcon: const Icon(Icons.search),
                border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
                contentPadding: const EdgeInsets.symmetric(horizontal: 16),
                filled: true,
                fillColor: Theme.of(context).colorScheme.surface,
              ),
            ),
            actions: [
              IconButton(onPressed: () {}, icon: const Icon(Icons.shopping_cart)),
            ],
          ),
          
          // 轮播Banner
          SliverToBoxAdapter(
            child: SizedBox(
              height: 160,
              child: PageView.builder(
                itemCount: banners.length,
                controller: PageController(viewportFraction: 0.9),
                itemBuilder: (context, index) {
                  var banner = banners[index];
                  return Container(
                    margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
                    padding: const EdgeInsets.all(20),
                    decoration: BoxDecoration(
                      gradient: LinearGradient(
                        begin: Alignment.topLeft,
                        end: Alignment.bottomRight,
                        colors: banner.gradient,
                      ),
                      borderRadius: BorderRadius.circular(16),
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text(banner.title, style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold)),
                        const SizedBox(height: 4),
                        Text(banner.subtitle, style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 14)),
                      ],
                    ),
                  );
                },
              ),
            ),
          ),
          
          // 分类标签
          SliverToBoxAdapter(
            child: SizedBox(
              height: 50,
              child: ListView(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                children: categories.map((cat) {
                  var isSelected = cat == _selectedCategory;
                  return Padding(
                    padding: const EdgeInsets.only(right: 8),
                    child: FilterChip(
                      selected: isSelected,
                      label: Text(cat),
                      onSelected: (_) => setState(() => _selectedCategory = cat),
                    ),
                  );
                }).toList(),
              ),
            ),
          ),
          
          // 商品网格
          SliverPadding(
            padding: const EdgeInsets.all(16),
            sliver: SliverGrid(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 12,
                crossAxisSpacing: 12,
                childAspectRatio: 0.68,
              ),
              delegate: SliverChildBuilderDelegate(
                (context, index) {
                  var product = _filteredProducts[index];
                  return _ProductCard(product: product);
                },
                childCount: _filteredProducts.length,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// 商品卡片
class _ProductCard extends StatelessWidget {
  final Product product;
  
  const _ProductCard({required this.product});
  
  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 图片占位
          Expanded(
            flex: 3,
            child: Stack(
              children: [
                Container(
                  width: double.infinity,
                  color: Colors.grey[200],
                  child: const Icon(Icons.image, size: 40, color: Colors.grey),
                ),
                if (product.isNew)
                  Positioned(
                    top: 8,
                    left: 8,
                    child: Container(
                      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
                      decoration: BoxDecoration(
                        color: Colors.red,
                        borderRadius: BorderRadius.circular(8),
                      ),
                      child: const Text('NEW', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
                    ),
                  ),
              ],
            ),
          ),
          // 信息
          Expanded(
            flex: 2,
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    product.name,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(fontWeight: FontWeight.w600),
                  ),
                  const SizedBox(height: 4),
                  Row(
                    children: [
                      Icon(Icons.star, size: 14, color: Colors.amber[700]),
                      Text(' ${product.rating}', style: const TextStyle(fontSize: 12)),
                      Text(' (${product.reviews})', style: TextStyle(fontSize: 11, color: Colors.grey[500])),
                    ],
                  ),
                  const Spacer(),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        '¥${product.price}',
                        style: TextStyle(
                          color: Theme.of(context).colorScheme.primary,
                          fontWeight: FontWeight.bold,
                          fontSize: 16,
                        ),
                      ),
                      Container(
                        padding: const EdgeInsets.all(4),
                        decoration: BoxDecoration(
                          color: Theme.of(context).colorScheme.primaryContainer,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Icon(
                          Icons.add_shopping_cart,
                          size: 16,
                          color: Theme.of(context).colorScheme.onPrimaryContainer,
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

🏋️ 练习

练习1:无限滚动加载 ♾️

实现滚动到底部自动加载更多:

// 提示:ScrollController + 监听到底触发加载
_scrollController.addListener(() {
  if (_scrollController.position.pixels >= 
      _scrollController.position.maxScrollExtent - 200) {
    _loadMore();
  }
});

练习2:瀑布流布局 🌊

使用flutter_staggered_grid_view包或自定义实现瀑布流。

练习3:吸顶分组列表 📌

使用CustomScrollView + SliverPersistentHeader实现联系人样式的吸顶分组列表。

🏆 本课成就

📚 下节预告

下一课开始进入导航与状态阶段!我们将学习Flutter的路由导航机制。