基于 Flutter 的 BLoC 状态管理、Repository 数据源、
NestedScrollView + SliverAppBar
的页面骨架、CarouselSlider + OctoImage + CachedNetworkImage
的媒体呈现,以及Lottie
与flutter_animate
带来的细腻动效。
目录
- 目标与特性
- 主要依赖
- 项目结构
- 应用入口与依赖注入
- [数据层:模型与 Repository](#数据层:模型与 Repository "#%E6%95%B0%E6%8D%AE%E5%B1%82%E6%A8%A1%E5%9E%8B%E4%B8%8E-repository")
- 状态层:FeedBloc、状态与事件
- [页面骨架:NestedScrollView 与 SliverAppBar](#页面骨架:NestedScrollView 与 SliverAppBar "#%E9%A1%B5%E9%9D%A2%E9%AA%A8%E6%9E%B6nestedscrollview-%E4%B8%8E-sliverappbar")
- [Feed 列表与卡片:Header / Media / Footer](#Feed 列表与卡片:Header / Media / Footer "#feed-%E5%88%97%E8%A1%A8%E4%B8%8E%E5%8D%A1%E7%89%87header--media--footer")
- 媒体轮播与图片解码策略
- 分隔块动画与"已读完"提示
- 主题与系统栏样式
- 完整运行效果
目标与特性
- 仿 Instagram 的 Feed 信息流体验
- BLoC 管理状态流转,Repository 提供数据
- 顶部浮动/吸附式
SliverAppBar
- 单图/多图轮播与页码指示
- 点赞/评论/分享/收藏等底部交互位
- 列表尾部的"已看完"动画提示
- 统一的 App 主题与系统栏样式
---
主要依赖
- 状态与架构 :
flutter_bloc
、equatable
- 滚动与媒体 :
carousel_slider
、cached_network_image
、octo_image
- 动画与可见性 :
flutter_animate
、lottie
、visibility_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: [],
),
);
}
}
Feed 列表与卡片:Header / Media / Footer
列表
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,
),
],
);
}
}
Header
包含头像、用户名、认证标识、赞助标记、关注按钮与更多菜单:
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),
]),
],
),
);
}
}
Footer
底部交互图标 + 点赞信息 + 文案 + 时间;多图时在中部展示圆点指示器:
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/...