学习Flutter ,列表的布局是一个很重要的点,大部分常见的APP 都离不开列表,尤其是瀑布Feed流,其中,很多app 会在列表中嵌套图片、视频和AD 广告,所以今天用MasonryGridView实现了一个列表样式
先看效果:
1、配置
dart
video_player: ^2.8.0
chewie: ^1.5.0
flutter_staggered_grid_view: ^0.7.0
2、卡片widget
dart
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../modules/main/tabs/home/feed_item.dart';
import '../../modules/main/tabs/home/home_controller.dart';
class FeedItemCard extends StatelessWidget {
final FeedItem item;
const FeedItemCard({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMediaContent(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.description,
style: const TextStyle(fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildInteractionRow(),
],
),
),
],
),
);
}
Widget _buildMediaContent() {
final controller = Get.find<HomeController>();
return AspectRatio(
aspectRatio: item.aspectRatio,
child: Stack(
children: [
if (item.type == 'video')
_buildVideoPlayer(controller)
else
Image.network(
item.url,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
if (item.type == 'video')
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 16,
),
),
)
],
),
);
}
Widget _buildVideoPlayer(HomeController ctrl) {
return GetBuilder<HomeController>(
builder: (context) {
final chewieController = ctrl.videoControllers[item.id];
if (chewieController == null) {
ctrl.initializeVideoPlayer(item.url, item.id);
return Container(
color: Colors.black,
child: const Center(child: CircularProgressIndicator(color: Colors.white)),
);
}
return GestureDetector(
onTap: () {
if (chewieController.isPlaying) {
ctrl.pauseVideo(item.id);
} else {
ctrl.setActiveVideo(item.id);
}
},
child: Chewie(controller: chewieController),
);
},
);
}
Widget _buildInteractionRow() {
return Row(
children: [
Obx(() {
final controller = Get.find<HomeController>();
final itemIndex = controller.feedItems.indexWhere((i) => i.id == item.id);
if (itemIndex == -1) return const SizedBox();
final currentItem = controller.feedItems[itemIndex];
return Row(
children: [
IconButton(
icon: Icon(
currentItem.isLiked ? Icons.favorite : Icons.favorite_border,
color: currentItem.isLiked ? Colors.red : Colors.grey[700],
size: 20,
),
onPressed: () => controller.toggleLike(item.id),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
Text(
'${currentItem.likes}',
style: const TextStyle(fontSize: 12),
),
],
);
}),
const SizedBox(width: 16),
const Icon(Icons.mode_comment_outlined, size: 20, color: Colors.grey),
const SizedBox(width: 16),
const Icon(Icons.send, size: 20, color: Colors.grey),
],
);
}
}
3、逻辑加载controller
dart
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:video_player/video_player.dart';
import 'feed_item.dart';
class HomeController extends GetxController {
var welcomeMessage = ''.obs;
final feedItems = <FeedItem>[].obs;
final activeVideoId = RxString('');
final videoControllers = <String, ChewieController>{};
final scrollController = ScrollController();
@override
void onInit() {
super.onInit();
Future.delayed(const Duration(seconds: 1), () {
welcomeMessage.value = 'Welcome to Xiaohongshu!';
_loadFeedData();
});
scrollController.addListener(_handleScroll);
}
@override
void onClose() {
for (var controller in videoControllers.values) {
controller.dispose();
}
videoControllers.clear();
scrollController.dispose();
super.onClose();
}
void _loadFeedData() {
feedItems.assignAll([
FeedItem(
id: '1',
type: 'image',
url: 'https://picsum.photos/400/600?random=1',
aspectRatio: 400 / 600,
description: '夏日海滩度假照片 #旅行 #夏天',
likes: 243,
),
FeedItem(
id: '2',
type: 'video',
url: 'http://vjs.zencdn.net/v/oceans.mp4',
aspectRatio: 16 / 9,
description: '蝴蝶飞舞的美丽瞬间 #自然 #动物',
likes: 512,
),
FeedItem(
id: '3',
type: 'image',
url: 'https://picsum.photos/400/800?random=2',
aspectRatio: 400 / 800,
description: '城市夜景 #摄影 #城市',
likes: 187,
),
FeedItem(
id: '4',
type: 'image',
url: 'https://picsum.photos/500/700?random=3',
aspectRatio: 500 / 700,
description: '美食探店 #美食 #周末',
likes: 324,
),
FeedItem(
id: '5',
type: 'video',
url: 'http://vjs.zencdn.net/v/oceans.mp4',
aspectRatio: 16 / 9,
description: '周末电影时光 #电影 #休闲',
likes: 421,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
FeedItem(
id: '6',
type: 'image',
url: 'https://picsum.photos/450/650?random=4',
aspectRatio: 450 / 650,
description: '健身打卡 #健身 #健康生活',
likes: 278,
),
]);
}
void toggleLike(String itemId) {
final index = feedItems.indexWhere((item) => item.id == itemId);
if (index != -1) {
final item = feedItems[index];
feedItems[index] = FeedItem(
id: item.id,
type: item.type,
url: item.url,
aspectRatio: item.aspectRatio,
description: item.description,
likes: item.isLiked ? item.likes - 1 : item.likes + 1,
isLiked: !item.isLiked,
);
}
}
void setActiveVideo(String videoId) {
if (activeVideoId.value != videoId) {
if (activeVideoId.isNotEmpty) {
final currentController = videoControllers[activeVideoId.value];
currentController?.pause();
}
activeVideoId.value = videoId;
final controller = videoControllers[videoId];
controller?.play();
}
}
void pauseVideo(String videoId) {
if (activeVideoId.value == videoId) {
activeVideoId.value = '';
}
final controller = videoControllers[videoId];
controller?.pause();
}
Future<void> initializeVideoPlayer(String videoUrl, String videoId) async {
if (videoControllers.containsKey(videoId)) return;
final videoController = VideoPlayerController.network(videoUrl);
await videoController.initialize();
final chewieController = ChewieController(
videoPlayerController: videoController,
autoPlay: false,
looping: true,
showControls: false,
allowFullScreen: true,
materialProgressColors: ChewieProgressColors(
playedColor: Colors.red,
handleColor: Colors.red,
backgroundColor: Colors.grey,
bufferedColor: Colors.grey.withOpacity(0.5),
));
videoControllers[videoId] = chewieController;
}
void _handleScroll() {
for (var item in feedItems) {
if (item.type == 'video') {
pauseVideo(item.id);
}
}
}
}
5、页面
dart
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:get/get.dart';
import '../../../../view/widgets/feed_item_card.dart';
import 'home_controller.dart';
class HomePage extends GetView<HomeController> {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(() => Text(controller.welcomeMessage.value)),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.notifications_none),
onPressed: () {},
),
],
),
body: Obx(() {
if (controller.feedItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return MasonryGridView.count(
controller: controller.scrollController,
crossAxisCount: 2,
itemCount: controller.feedItems.length,
itemBuilder: (context, index) {
final item = controller.feedItems[index];
return FeedItemCard(item: item);
},
mainAxisSpacing: 8,
crossAxisSpacing: 8,
padding: const EdgeInsets.all(8),
);
}),
);
}
}