用 Flutter 实现一个「类 Instagram」Feed 列表页

基于 Flutter 的 BLoC 状态管理、Repository 数据源、NestedScrollView + SliverAppBar 的页面骨架、CarouselSlider + OctoImage + CachedNetworkImage 的媒体呈现,以及 Lottieflutter_animate 带来的细腻动效。


目录


目标与特性

  • 仿 Instagram 的 Feed 信息流体验
  • BLoC 管理状态流转,Repository 提供数据
  • 顶部浮动/吸附式 SliverAppBar
  • 单图/多图轮播与页码指示
  • 点赞/评论/分享/收藏等底部交互位
  • 列表尾部的"已看完"动画提示
  • 统一的 App 主题与系统栏样式

---

主要依赖

  • 状态与架构flutter_blocequatable
  • 滚动与媒体carousel_slidercached_network_imageocto_image
  • 动画与可见性flutter_animatelottievisibility_detector
  • 主题flex_color_scheme

项目结构

bash 复制代码
lib/
├─ App.dart
├─ Feed/
│  ├─ bloc/
│  │  ├─ feed_bloc.dart
│  │  ├─ feed_event.dart
│  │  └─ feed_state.dart
│  ├─ model/
│  │  ├─ PostBlock.dart
│  │  └─ posts_repository.dart
│  ├─ view/
│  │  ├─ feed_page.dart
│  │  ├─ post_large.dart
│  │  ├─ post_header.dart
│  │  ├─ post_media.dart
│  │  ├─ post_footer.dart
│  │  ├─ media_carousel.dart
│  │  ├─ carousel_dot_indicator.dart
│  │  └─ widgets/
│  │     ├─ divider_block.dart
│  │     └─ feed_page_controller.dart
│  └─ widgets/
│     └─ feed_app_bar.dart
└─ packages/app_ui/
   ├─ app_theme.dart
   └─ viewable.dart

应用入口与依赖注入

应用在 App 中完成 Repository 与 BLoC 的注入,FeedPage 作为首页:

dart 复制代码
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider(
      create: (context) => PostsRepository(),
      child: MaterialApp(
        theme: const AppTheme().theme,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
        home: BlocProvider(
          create: (context) => FeedBloc(
            postsRepository: context.read<PostsRepository>(),
          ),
          child: const FeedPage(),
        ),
      ),
    );
  }
}

数据层:模型与 Repository

数据模型

PostBlock 表示一条帖子,包含作者、媒体列表与文案;PostAuthor/User 用来模拟已认证用户:

dart 复制代码
class PostBlock {
  const PostBlock({
    required this.id,
    required this.author,
    required this.media,
    required this.caption,
  });

  final String id;
  final PostAuthor author;
  final List<String> media;
  final String caption;
}

Repository

PostsRepository 提供一组静态的推荐帖子,并带有多图/单图的混合:

dart 复制代码
class PostsRepository {
  static final recommendedPosts = <PostBlock>[
    PostBlock(
      id: "1",
      author: PostAuthor.randomConfirmed(),
      media: [
        'https://.../dolomite-mountains.jpg',
        'https://.../morskie-oko.jpg',
      ],
      caption: '送你一朵小红花🌹,奖励你有勇气主动来和我说话',
    ),
    // ...
  ];
}

状态层:FeedBloc、状态与事件

FeedBloc 接收"请求推荐流"的事件,从 Repository 取数并发出状态:

dart 复制代码
class FeedBloc extends Bloc<FeedEvent, FeedState> {
  FeedBloc({required PostsRepository postsRepository})
      : _postsRepository = postsRepository,
        super(const FeedState.initial()) {
    on<FeedRecommendedPostsPageRequested>(_onFeedRecommendedPostsPageRequested);
  }

  final PostsRepository _postsRepository;

  Future<void> _onFeedRecommendedPostsPageRequested(
    FeedRecommendedPostsPageRequested event,
    Emitter<FeedState> emit,
  ) async {
    emit(state.loading());
    final recommendedBlocks = [...PostsRepository.recommendedPosts..shuffle()];
    emit(state.populated(blocks: recommendedBlocks));
  }
}

FeedState 使用 Equatable 进行对比,状态枚举包括 initial / loading / populated / failure

FeedRecommendedPostsPageRequested 是触发加载的事件类型。


页面骨架:NestedScrollView 与 SliverAppBar

FeedView 采用 NestedScrollView,头部是 SliverAppBar,主体是 FeedBody

dart 复制代码
class FeedView extends StatefulWidget { /* ... */ }

class _FeedViewState extends State<FeedView> {
  @override
  void initState() {
    super.initState();
    context.read<FeedBloc>().add(const FeedRecommendedPostsPageRequested());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: NestedScrollView(
          floatHeaderSlivers: true,
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: FeedAppBar(innerBoxIsScrolled: innerBoxIsScrolled),
            ),
          ],
          body: const FeedBody(),
        ),
      ),
    ).withSystemNavigationBarTheme();
  }
}

FeedAppBar 使用项目内置的 SVG 字标,支持 floating + snap 行为,滚动体验轻巧:

dart 复制代码
class FeedAppBar extends StatelessWidget {
  const FeedAppBar({required this.innerBoxIsScrolled, super.key});

  final bool innerBoxIsScrolled;

  @override
  Widget build(BuildContext context) {
    return SliverPadding(
      padding: const EdgeInsets.only(right: 12),
      sliver: SliverAppBar(
        centerTitle: false,
        forceElevated: innerBoxIsScrolled,
        title: Assets.images.instagramTextLogo.svg(
          height: 50, width: 50, fit: BoxFit.contain,
          colorFilter: const ColorFilter.mode(Colors.black, BlendMode.srcIn),
        ),
        floating: true,
        snap: true,
        actions: [],
      ),
    );
  }
}

列表

FeedBody 订阅 FeedBloc 的状态,渲染帖子卡片,并在列表尾部追加一个"分隔块":

dart 复制代码
class FeedBody extends StatelessWidget {
  const FeedBody({super.key});

  @override
  Widget build(BuildContext context) {
    final feedPageController = FeedPageController();

    return BlocBuilder<FeedBloc, FeedState>(
      builder: (context, state) {
        return ListView.builder(
          itemCount: state.blocks.length + 1,
          itemBuilder: (context, index) {
            if (index >= state.blocks.length) {
              return DividerBlock(feedPageController: feedPageController);
            } else {
              return PostLarge(block: state.blocks[index]);
            }
          },
        );
      },
    );
  }
}

卡片骨架

PostLarge 由 Header、Media、Footer 三段组成:

dart 复制代码
class PostLarge extends StatefulWidget {
  const PostLarge({required this.block, super.key});
  final PostBlock block;

  @override
  State<PostLarge> createState() => _PostLargeState();
}

class _PostLargeState extends State<PostLarge> {
  late ValueNotifier<int> _indicatorValue = ValueNotifier(0);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        PostHeader(block: widget.block),
        PostMedia(
          media: widget.block.media,
          onPageChanged: (i) => _indicatorValue.value = i,
        ),
        PostFooter(
          block: widget.block,
          indicatorValue: _indicatorValue,
        ),
      ],
    );
  }
}

包含头像、用户名、认证标识、赞助标记、关注按钮与更多菜单:

dart 复制代码
class PostHeader extends StatelessWidget {
  const PostHeader({required this.block, this.color, super.key});
  final PostBlock block;
  final Color? color;

  @override
  Widget build(BuildContext context) {
    final author = block.author;
    final color = this.color ?? Colors.black;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Row(
            children: [
              CircleAvatar(
                radius: 27,
                backgroundColor: Colors.white,
                foregroundImage: ResizeImage.resizeIfNeeded(
                  54, 54, NetworkImage(author.avatarUrl),
                ),
              ),
              const SizedBox(width: 12),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(children: [
                    Text('${author.username}',
                        style: context.textTheme.titleMedium?.apply(color: color)),
                    Assets.icons.verifiedUser.svg(width: 18, height: 18),
                  ]),
                  Text('赞助', style: context.textTheme.bodyMedium?.apply(color: color)),
                ],
              ),
            ],
          ),
          Row(children: [
            DecoratedBox(
              decoration: BoxDecoration(
                border: Border.all(color: const Color(0xFFE0E0E0)),
                borderRadius: const BorderRadius.all(Radius.circular(6)),
              ),
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
                child: Text('关注', style: context.textTheme.labelLarge?.apply(color: Colors.black)),
              ),
            ),
            const SizedBox(width: 12),
            Icon(Icons.more_vert, size: 24, color: color),
          ]),
        ],
      ),
    );
  }
}

底部交互图标 + 点赞信息 + 文案 + 时间;多图时在中部展示圆点指示器:

dart 复制代码
class PostFooter extends StatelessWidget {
  const PostFooter({required this.block, required this.indicatorValue, super.key});
  final PostBlock block;
  final ValueNotifier<int> indicatorValue;

  @override
  Widget build(BuildContext context) {
    final author = block.author;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 顶部操作区
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              const Row(children: [
                Icon(Icons.favorite_outline, size: 30),
                SizedBox(width: 16),
                Icon(Icons.chat_bubble_outline, size: 30),
                SizedBox(width: 16),
                Icon(Icons.near_me_outlined, size: 30),
              ]),
              if (block.media.length > 1)
                ValueListenableBuilder(
                  valueListenable: indicatorValue,
                  builder: (_, index, __) => CarouselDotIndicator(
                    mediaCount: block.media.length,
                    activeMediaIndex: index as int,
                  ),
                ),
              const Icon(Icons.bookmark_outline_rounded, size: 30),
            ],
          ),
        ),
        const SizedBox(height: 8),
        // 点赞与文案
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text.rich(TextSpan(children: [
                const TextSpan(text: '被 '),
                TextSpan(
                  text: '美国队长',
                  style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
                ),
                const TextSpan(text: ' 和 '),
                TextSpan(
                  text: '3个其他人',
                  style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
                ),
                const TextSpan(text: ' 喜欢'),
              ])),
              Text.rich(
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                TextSpan(children: [
                  TextSpan(text: '${author.username} ', style: context.textTheme.titleMedium),
                  TextSpan(text: block.caption, style: context.textTheme.bodyMedium),
                ]),
              ),
              Text('1小时前', style: context.textTheme.bodyMedium?.copyWith(color: Colors.grey)),
              const SizedBox(height: 8),
            ],
          ),
        ),
      ],
    );
  }
}

媒体轮播与图片解码策略

轮播

使用 carousel_slider 组件实现 1:1 轮播区域,关闭无限滚动,翻页时同步 ValueNotifier

dart 复制代码
class MediaCarousel extends StatefulWidget {
  const MediaCarousel({required this.media, this.onPageChanged, this.currentIndex, super.key});
  final List<String> media;
  final ValueSetter<int>? onPageChanged;
  final ValueNotifier<int>? currentIndex;

  @override
  State<MediaCarousel> createState() => _MediaCarouselState();
}

class _MediaCarouselState extends State<MediaCarousel> {
  @override
  Widget build(BuildContext context) {
    return CarouselSlider.builder(
      itemCount: widget.media.length,
      itemBuilder: (context, index, realIndex) =>
          _MediaCarouseImage(url: widget.media[index]),
      options: CarouselOptions(
        aspectRatio: 1,
        viewportFraction: 1.0,
        enableInfiniteScroll: false,
        onPageChanged: (index, reason) {
          widget.currentIndex?.value = index;
          widget.onPageChanged?.call(index);
        },
      ),
    );
  }
}

图片加载与内存控制

利用 OctoImage + CachedNetworkImageProvider,按屏幕宽度与像素比动态计算缓存解码尺寸:

dart 复制代码
class _MediaCarouseImage extends StatelessWidget {
  const _MediaCarouseImage({required this.url, super.key});
  final String url;

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.sizeOf(context).width;
    final pixelRatio = MediaQuery.devicePixelRatioOf(context);
    final thumbnailWidth = min((screenWidth * pixelRatio) ~/ 1, 1920);
    final thumbnailHeight = min((thumbnailWidth * (16 / 9)).toInt(), 1080);

    return OctoImage(
      image: CachedNetworkImageProvider(url),
      fit: BoxFit.cover,
      memCacheWidth: thumbnailWidth,
      memCacheHeight: thumbnailHeight,
    );
  }
}

多图计数与圆点指示

右上角的"当前/总数"通过 AnimatedOpacity 控制显隐;中部圆点指示器 CarouselDotIndicator 则根据活跃索引高亮:

dart 复制代码
class CarouselDotIndicator extends StatelessWidget {
  const CarouselDotIndicator({required this.mediaCount, required this.activeMediaIndex, super.key});
  final int mediaCount;
  final int activeMediaIndex;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: List.generate(mediaCount, (i) => i)
          .map((i) => _DotIndicator(isActive: i == activeMediaIndex))
          .toList(growable: false),
    );
  }
}

分隔块动画与"已读完"提示

列表末尾的 DividerBlock 使用 Lottie 播放一个"勾选完成"的动画,并在上下各放一条带有彩色高亮掠过效果的分割线:

dart 复制代码
class AnimatedShimmerDivider extends StatefulWidget {
  const AnimatedShimmerDivider({required this.hasPlayedAnimation, super.key});
  final bool hasPlayedAnimation;

  @override
  State<AnimatedShimmerDivider> createState() => _AnimatedShimmerDividerState();
}

class _AnimatedShimmerDividerState extends State<AnimatedShimmerDivider>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  static const primaryGradient = <Color>[
    Color(0xFF833AB4),
    Color(0xFFF77737),
    Color(0xFFE1306C),
    Color(0xFFC13584),
    Color(0xFF833AB4),
  ];

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);
  }

  @override
  void didUpdateWidget(covariant AnimatedShimmerDivider oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.hasPlayedAnimation != widget.hasPlayedAnimation) {
      _controller.loop(count: 1);
    }
  }

  @override
  Widget build(BuildContext context) {
    return const Divider(color: Color(0xFFE0E0E0))
        .animate(value: 1, controller: _controller, autoPlay: false)
        .shimmer(
          duration: 2200.ms,
          stops: [0.0, 0.2, 0.4, 0.6, 0.8, 1.0],
          colors: [Colors.grey, ...primaryGradient],
        );
  }
}

文字文案采用中文:"您都看完了 / 您已看完过去 3 天的所有新帖子",与上方头像/用户名语言保持一致。


主题与系统栏样式

应用主题集中在 packages/app_ui/app_theme.dart 中,基于 FlexColorScheme 的 Material 3 配置;文本与图标统一黑色;系统栏样式通过 AnnotatedRegion<SystemUiOverlayStyle> 扩展方法统一注入:

dart 复制代码
class AppTheme {
  const AppTheme();

  ThemeData get theme => FlexThemeData.light(
    scheme: FlexScheme.custom,
    colors: FlexSchemeColor.from(
      brightness: Brightness.light,
      primary: Colors.black,
      swapOnMaterial3: true,
    ),
    useMaterial3: true,
    useMaterial3ErrorColors: true,
  ).copyWith(
    textTheme: const TextTheme().apply(
      bodyColor: Colors.black,
      displayColor: Colors.black,
      decorationColor: Colors.black,
    ),
    iconTheme: const IconThemeData(color: Colors.black),
    appBarTheme: const AppBarTheme(
      elevation: 0,
      surfaceTintColor: Colors.white,
      backgroundColor: Colors.white,
    ),
  );
}

extension SystemNavigationBarTheme on Widget {
  Widget withSystemNavigationBarTheme() =>
      AnnotatedRegion<SystemUiOverlayStyle>(
        value: SystemUIOverlayTheme.iOSLightSystemBarTheme,
        child: this,
      );
}

完整运行效果

  • 首页进入即触发 FeedRecommendedPostsPageRequested,列表加载推荐内容
  • 顶部 SliverAppBar 随滚动浮动与吸附
  • 单图/多图卡片按 1:1 视觉排布,滑动时切换页码与圆点
  • 列表尾部出现"您都看完了"提示与勾选动画
  • 主题文字/图标色统一,系统状态栏风格一致

完整代码下载: github.com/wutao23yzd/...


相关推荐
叽哥13 小时前
flutter学习第 8 节:路由与导航
android·flutter·ios
叽哥13 小时前
flutter学习第 7 节:StatefulWidget 与状态管理基础
android·flutter·ios
落魄的Android开发1 天前
FLutter 如何在跨平台下实现国际化多语言开发
flutter
libo_20251 天前
HarmonyOS5原生开发 vs. Flutter:谁更适合你的项目?
flutter
libo_20251 天前
ArkTS还是Flutter?HarmonyOS5开发框架选型指南
flutter
libo_20251 天前
Flutter开发者在HarmonyOS5生态中的优势与局限
flutter
libo_20251 天前
HarmonyOS5 + Flutter:电商应用全栈开发实战
flutter
叽哥1 天前
flutter学习第 6 节:按钮与交互组件
android·flutter·ios
libo_20251 天前
从Flutter到HarmonyOS5:无缝迁移的技术路径
flutter