如何在Flutter中实现支持无限滚动的瀑布流网格视图?
嘿,我刚好折腾过类似的Flutter瀑布流需求,给你一套完美适配的解决方案!完全满足你要的无限滚动、动态高度item(不用预先知道尺寸)、包含图片+可变长文本+图标/金额这些要求。
解决方案:动态高度的Pinterest风格瀑布流
核心思路
因为每个item的图片高度、文本长度都是不确定的,我们需要先加载图片拿到真实尺寸,再计算文本的高度,最终算出整个item的总高度,然后通知瀑布流布局更新——这样就不用预先硬写高度啦。
具体实现步骤
1. 依赖准备
你之前提到的flutter_staggered_grid_view其实现在已经支持动态更新item高度了,用它的StaggeredGridView.countBuilder可以轻松实现无限滚动,刚好匹配我们的需求。
2. 定义数据模型
先给每个瀑布流item定义一个数据类,用来存内容和计算后的高度:
class PinItem { final String imageUrl; final String title; // 可变长文本 final String price; // 金额 double? itemHeight; // 动态计算的item总高度 PinItem({ required this.imageUrl, required this.title, required this.price, this.itemHeight, }); }
3. 实现动态高度的Item Widget
这个widget会负责加载图片、计算文本高度,最终算出整个item的高度,并通知父组件刷新:
class PinItemWidget extends StatelessWidget { final PinItem item; final VoidCallback onHeightCalculated; final GlobalKey itemKey; const PinItemWidget({ super.key, required this.item, required this.onHeightCalculated, required this.itemKey, }); @override Widget build(BuildContext context) { final columnWidth = MediaQuery.of(context).size.width / 2; // 假设是2列布局 return Container( key: itemKey, margin: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 图片部分:加载完成后获取真实尺寸 Image.network( item.imageUrl, width: columnWidth, fit: BoxFit.fitWidth, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (frame != null) { // 计算图片高度(按列宽等比例缩放) final imageHeight = (frame.image.height / frame.image.width) * columnWidth; // 计算文本高度(支持多行) final textPainter = TextPainter( text: TextSpan( text: item.title, style: Theme.of(context).textTheme.bodyMedium, ), maxLines: null, textDirection: TextDirection.ltr, )..layout(maxWidth: columnWidth); final textHeight = textPainter.height; // 加上价格、内边距等固定高度 final totalHeight = imageHeight + textHeight + 40; // 如果高度有变化,更新并通知父组件刷新 if (item.itemHeight != totalHeight) { item.itemHeight = totalHeight; onHeightCalculated(); } } return child; }, ), // 文本+金额区域 Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.title, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 4), Row( children: [ const Icon(Icons.attach_money, size: 16), Text( item.price, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), ], ), ), ], ), ); } }
4. 实现无限滚动的瀑布流主页面
这里用StaggeredGridView.countBuilder结合ScrollController来实现无限滚动,同时处理加载更多的逻辑:
class PinterestStyleGrid extends StatefulWidget { const PinterestStyleGrid({super.key}); @override State<PinterestStyleGrid> createState() => _PinterestStyleGridState(); } class _PinterestStyleGridState extends State<PinterestStyleGrid> { final List<PinItem> _items = []; bool _isLoading = false; final ScrollController _scrollController = ScrollController(); final Map<int, GlobalKey> _itemKeys = {}; // 存每个item的key,方便布局更新 @override void initState() { super.initState(); _loadInitialData(); _scrollController.addListener(_checkLoadMore); } // 加载初始数据(模拟API请求) Future<void> _loadInitialData() async { setState(() => _isLoading = true); await Future.delayed(const Duration(seconds: 1)); final newItems = List.generate(10, (index) => PinItem( imageUrl: "https://picsum.photos/600/${700 + index * 60}", // 模拟不同高度的图片 title: "这是一个可变长度的标题,可能会跨多行显示${index % 3 == 0 ? ',再加一些内容让文本更长' : ''}", price: "\$${(12 + index * 2.3).toStringAsFixed(2)}", )); setState(() { _items.addAll(newItems); _isLoading = false; // 给新item分配GlobalKey for (int i = _items.length - newItems.length; i < _items.length; i++) { _itemKeys[i] = GlobalKey(); } }); } // 加载更多数据 Future<void> _loadMoreData() async { if (_isLoading) return; setState(() => _isLoading = true); await Future.delayed(const Duration(seconds: 1)); final newItems = List.generate(10, (index) => PinItem( imageUrl: "https://picsum.photos/600/${650 + index * 80}", title: "加载更多的可变标题${index % 2 == 0 ? ',这里测试多行文本的高度计算' : ''}", price: "\$${(20 + index * 3.1).toStringAsFixed(2)}", )); setState(() { _items.addAll(newItems); _isLoading = false; // 给新item分配GlobalKey for (int i = _items.length - newItems.length; i < _items.length; i++) { _itemKeys[i] = GlobalKey(); } }); } // 监听滚动,触发加载更多 void _checkLoadMore() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200 && !_isLoading) { _loadMoreData(); } } // 刷新item高度 void _refreshItemHeight(int index) { setState(() {}); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Pinterest 风格瀑布流")), body: StaggeredGridView.countBuilder( controller: _scrollController, crossAxisCount: 2, // 2列布局,可按需修改 itemCount: _items.length + (_isLoading ? 1 : 0), itemBuilder: (context, index) { if (index == _items.length) { // 加载更多的占位指示器 return const Center(child: CircularProgressIndicator()); } final item = _items[index]; return PinItemWidget( item: item, onHeightCalculated: () => _refreshItemHeight(index), itemKey: _itemKeys[index]!, ); }, staggeredTileBuilder: (index) { if (index == _items.length) { return const StaggeredTile.fit(2); // 加载项占满两列 } // 用计算后的高度,没计算出来时用默认高度占位 return StaggeredTile.fit(1, item.itemHeight ?? 300); }, mainAxisSpacing: 8, crossAxisSpacing: 8, ), ); } @override void dispose() { _scrollController.dispose(); super.dispose(); } }
关键细节说明
- 动态高度计算:通过
Image.network的frameBuilder监听图片加载完成,获取真实尺寸后结合文本高度算出总高度,避免预先硬写高度。 - 无限滚动:用
ScrollController监听滚动位置,当距离底部200px时自动加载更多数据,和ListView.builder的逻辑一致。 - 流畅性优化:首次加载用默认高度占位,图片加载完成后再更新真实高度,减少布局抖动。
内容的提问来源于stack exchange,提问作者Piercarlo Slavazza
相关产品推荐
相关产品推荐

