《Flutter篇第二章》MasonryGridView瀑布流列表

学习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),
        );
      }),
    );
  }
}
相关推荐
Monkey-旭5 小时前
Android Bitmap 完全指南:从基础到高级优化
android·java·人工智能·计算机视觉·kotlin·位图·bitmap
Mike_Wuzy11 小时前
【Android】发展历程
android
开酒不喝车11 小时前
安卓Gradle总结
android
喝拿铁写前端11 小时前
Flutter 学习笔记 - 搭建(macOS 版)
前端·flutter
ALLIN12 小时前
Mac Flutter fvm 多版本管理安装与常用指令(详细使用)
flutter
阿华的代码王国12 小时前
【Android】PopupWindow实现长按菜单
android·xml·java·前端·后端
稻草人不怕疼13 小时前
Android 15 全屏模式适配:A15TopView 自定义组件分享
android
静默的小猫13 小时前
LiveDataBus消息事件总线之二-(不含反射和hook)
android
~央千澈~14 小时前
05百融云策略引擎项目交付-laravel实战完整交付定义常量分文件配置-独立建立lib类处理-成功导出pdf-优雅草卓伊凡
android·laravel·软件开发·金融策略
_一条咸鱼_14 小时前
Android Runtime冷启动与热启动差异源码级分析(99)
android·面试·android jetpack