Flutter for OpenHarmony 实战:打造高颜值运动排行榜(下)— 动效交互与性能优化
这是实战系列的终章。我们将一步步创建三个核心文件,通过 Matrix4 透视变换与 AnimationController 手势联动,亲手打造一个高度仿真的 3D 翻书组件,并针对鸿蒙折叠屏设备实现优化的交互体验
Flutter for OpenHarmony 实战:打造高颜值运动排行榜(下)— 动效交互与性能优化

前言
在上篇中,我们使用 SliverPersistentHeader 自定义 Delegate 打造了一个极具现代感的运动排行榜骨架。但如果步数直接显示"12,504",用户会觉得索然无味。如果它能像里程表一样从 0 飞速滚到 12,504,成就感瞬间翻倍。
此外,当用户给好友点赞时,如果只是图标变红,未免太单调了。我们希望它能跳动一下,甚至迸发出一些小爱心。
本文你将学到:
- 手写
CountUpAnimation数字滚动组件 - 实现
ScaleTransition缩放点赞动效 Sliver体系下的下拉刷新 (RefreshIndicator)- 鸿蒙上的滚动物理效果优化
- 完整的动效组件集成方案
一、数字滚动动画 (Count Up)
1.1 为什么需要数字滚动?
在运动类 App 中,数字的变化往往代表着用户的成就。如果数字能"滚动"起来,而不是生硬地跳变,用户的成就感会显著提升。
设计原则:
- 速度曲线:先快后慢(
Curves.easeOutExpo),模拟机械表的惯性。 - 格式化:大数字需要千分位分隔符(12,504),提升可读性。
- 时长控制:2 秒左右最佳,过快看不清,过慢显得拖沓。
1.2 实现原理
Flutter 中没有直接的 CountUp 组件,但通过 TweenAnimationBuilder 就能轻松实现。它的核心思路是:
- 定义起始值(0)和结束值(实际步数)。
- 使用
Tween在两者之间插值。 - 在
builder中实时格式化数字并渲染。
1.3 封装 CountUpText 组件
新建 lib/widgets/count_up_text.dart:
import 'package:flutter/material.dart';
class CountUpText extends StatelessWidget {
final int value;
final TextStyle? style;
final Duration duration;
final Curve curve;
const CountUpText({
super.key,
required this.value,
this.style,
this.duration = const Duration(seconds: 2), // 默认 2 秒
this.curve = Curves.easeOutExpo, // 先快后慢
});
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: value.toDouble()),
duration: duration,
curve: curve,
builder: (context, val, child) {
// 格式化数字:12,504
final formatted = val.toInt().toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},');
return Text(formatted, style: style);
},
);
}
}
代码解析:
TweenAnimationBuilder<double>:自动管理动画状态,无需手动创建AnimationController。Tween(begin: 0, end: value.toDouble()):定义插值范围。RegExp正则表达式:为数字添加千分位分隔符。
1.4 集成到 RankListItem
在上篇的 RankListItem 中替换静态文本:
// 在 RankListItem 的 build 方法中,将步数部分替换为:
CountUpText(
value: steps,
style: const TextStyle(
color: Color(0xFFFF6B00),
fontWeight: FontWeight.w900, // 极粗
fontSize: 22,
fontStyle: FontStyle.italic, // 斜体增加速度感
fontFamily: 'Barlow',
),
),
效果:当列表项首次渲染时,步数会从 0 快速滚动到目标值,给用户带来强烈的视觉冲击。
💡 优化技巧:如果列表项过多,可以使用 ListView.builder 的 addAutomaticKeepAlives: false 配合 AutomaticKeepAliveClientMixin,确保只有可见区域的数字才会滚动,避免性能浪费。
二、Q 弹的点赞交互
2.1 交互设计分析
点赞是社交类功能的核心。一个优秀的点赞交互应该具备:
- 即时反馈:点击瞬间图标变色。
- 弹性动画:图标先放大再缩小,模拟"弹跳"效果。
- 状态持久化:点赞状态需要保存(本文暂不涉及后端)。

2.2 实现原理
我们使用 ScaleTransition 配合 TweenSequence 来实现"放大-缩小"的序列动画。
动画曲线设计:
- 阶段 1:从 1.0 缩放到 1.3(放大)
- 阶段 2:从 1.3 缩放回 1.0(复原)
- 总时长:200ms(快速反馈)
2.3 封装 LikeButton 组件
新建 lib/widgets/like_button.dart:
import 'package:flutter/material.dart';
class LikeButton extends StatefulWidget {
final bool initialLiked;
final int count;
final ValueChanged<bool>? onLikeChanged; // 回调函数,用于后续集成后端
const LikeButton({
super.key,
required this.initialLiked,
required this.count,
this.onLikeChanged,
});
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late bool _isLiked;
late int _count;
void initState() {
super.initState();
_isLiked = widget.initialLiked;
_count = widget.count;
// 创建动画控制器
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 200));
// 缩放曲线:1.0 -> 1.3 -> 1.0 (瞬间变大再复原)
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 50),
TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 50),
]).animate(_controller);
}
void _toggleLike() {
_controller.forward().then((_) => _controller.reset()); // 播放动画
setState(() {
_isLiked = !_isLiked;
_count += _isLiked ? 1 : -1;
});
// 触发回调(用于后续集成后端)
widget.onLikeChanged?.call(_isLiked);
}
void dispose() {
_controller.dispose(); // 释放资源
super.dispose();
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: _toggleLike,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('$_count', style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(width: 4),
ScaleTransition(
scale: _scaleAnimation,
child: Icon(
_isLiked ? Icons.favorite : Icons.favorite_border,
size: 20, // 稍微调大一点以便手指点击
color: _isLiked ? Colors.red : Colors.grey,
),
),
],
),
);
}
}
代码解析:
SingleTickerProviderStateMixin:为AnimationController提供vsync。TweenSequence:定义多阶段动画序列。dispose():释放动画控制器,避免内存泄漏。
2.4 集成到 RankListItem
在 RankListItem 中替换原有的点赞部分:
// 在 RankListItem 的 build 方法中,将点赞部分替换为:
LikeButton(
initialLiked: false,
count: likes,
onLikeChanged: (isLiked) {
// TODO: 调用后端 API 更新点赞状态
print('点赞状态变更: $isLiked');
},
),
⚠️ 注意:在实际项目中,需要将点赞状态同步到后端,并在列表刷新时恢复状态。
三、下拉刷新与 Sliver 结合
3.1 为什么需要下拉刷新?
运动排行榜的数据是实时变化的,用户需要能够手动刷新以获取最新排名。

3.2 Sliver 体系下的刷新方案
在 CustomScrollView 中,普通的 RefreshIndicator 有时会与 SliverAppBar 冲突(例如下拉距离不够就触发)。
Flutter 提供了两种方案:
- RefreshIndicator 包裹整个 CustomScrollView(推荐)
- CupertinoSliverRefreshControl(iOS 风格)
我们选择方案 1,因为它在 Android/鸿蒙上的体验更好。
3.3 实现代码
修改 LeaderboardPage 的 build 方法:
Widget build(BuildContext context) {
return Scaffold(
body: RefreshIndicator(
onRefresh: _handleRefresh, // 刷新回调
edgeOffset: 120, // 调整刷新指示器的位置,以免被 AppBar 挡住
child: CustomScrollView(
// 鸿蒙/Android 为了拥有类似 iOS 的回弹效果,强行指定 physics
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(), // 即使内容不足一屏也能下拉
),
slivers: [
_buildCombinedHeader(context),
_buildRankList(),
],
),
),
);
}
// 刷新逻辑
Future<void> _handleRefresh() async {
// 模拟网络请求
await Future.delayed(const Duration(seconds: 2));
// TODO: 调用后端 API 获取最新排行榜数据
// setState(() {
// users = newData;
// });
print('排行榜数据已刷新');
}
代码解析:
edgeOffset: 120:由于我们的头部高度较高,需要调整刷新指示器的位置。BouncingScrollPhysics:iOS 风格的回弹效果,比 Android 默认的 Glow 更优雅。AlwaysScrollableScrollPhysics:即使内容不足一屏也能下拉刷新。
3.4 状态管理优化
在实际项目中,建议使用状态管理方案(如 Provider、Riverpod)来管理排行榜数据:
class LeaderboardPage extends StatefulWidget {
const LeaderboardPage({super.key});
State<LeaderboardPage> createState() => _LeaderboardPageState();
}
class _LeaderboardPageState extends State<LeaderboardPage> {
List<Map<String, dynamic>> _users = [];
bool _isLoading = false;
void initState() {
super.initState();
_loadData(); // 初始化加载数据
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
// TODO: 调用后端 API
await Future.delayed(const Duration(seconds: 1));
setState(() {
_users = List.generate(20, (index) => {
'name': 'Runner ${index + 88}',
'steps': 20000 - index * 500,
'likes': 10 + index,
'avatar': 'https://i.pravatar.cc/150?img=${index + 20}',
});
_isLoading = false;
});
}
Future<void> _handleRefresh() async {
await _loadData();
}
// ... 其他代码
}
四、鸿蒙特有优化
4.1 列表的 OverScroll 效果
在 Android/鸿蒙上,列表滑动到边缘默认会有"波纹" (Glow) 效果,但在这种沉浸式头部的页面上,顶部出现的蓝色波纹会破坏美感。
我们可以替换为 BouncingScrollPhysics(回弹),或者去除波纹。
方案 1:使用回弹效果(推荐)
CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
// ...
)
方案 2:完全去除波纹
ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(overscroll: false),
child: CustomScrollView(...),
)
💡 设计建议:对于运动 App,回弹 (Bouncing) 带来的果冻感通常更符合"活力"的设计语言,所以推荐使用方案 1。
4.2 性能优化建议
(1)列表项复用
使用 ListView.builder 或 SliverList 时,Flutter 会自动复用列表项。但如果列表项包含动画,需要注意:
// ❌ 错误做法:每次 build 都创建新的 CountUpText
Text('$steps')
// ✅ 正确做法:使用 const 或缓存
const CountUpText(value: 12504)
(2)图片缓存
头像图片应该使用缓存,避免重复加载:
CachedNetworkImage(
imageUrl: avatarUrl,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
)
(3)避免过度重建
使用 const 构造函数和 Key 来优化性能:
RankListItem(
key: ValueKey(user['id']), // 使用唯一 ID 作为 Key
rank: rank,
// ...
)
4.3 折叠屏适配
鸿蒙设备中有折叠屏,需要监听屏幕尺寸变化:
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
body: CustomScrollView(
slivers: [
_buildCombinedHeader(context),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: screenWidth > 600 ? 40 : 20, // 大屏增加边距
),
sliver: _buildRankList(),
),
],
),
);
}
五、完整集成示例
5.1 完整的 LeaderboardPage
import 'dart:ui';
import 'package:flutter/material.dart';
import '../widgets/count_up_text.dart';
import '../widgets/like_button.dart';
class LeaderboardPage extends StatefulWidget {
const LeaderboardPage({super.key});
State<LeaderboardPage> createState() => _LeaderboardPageState();
}
class _LeaderboardPageState extends State<LeaderboardPage> {
List<Map<String, dynamic>> _users = [];
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
await Future.delayed(const Duration(seconds: 1));
setState(() {
_users = List.generate(20, (index) => {
'name': 'Runner ${index + 88}',
'steps': 20000 - index * 500,
'likes': 10 + index,
'avatar': 'https://i.pravatar.cc/150?img=${index + 20}',
});
});
}
Future<void> _handleRefresh() async {
await _loadData();
}
Widget build(BuildContext context) {
return Scaffold(
body: RefreshIndicator(
onRefresh: _handleRefresh,
edgeOffset: 120,
child: CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
_buildCombinedHeader(context),
_buildRankList(),
],
),
),
);
}
// ... 其他方法(参考上篇)
}
5.2 优化后的 RankListItem
class RankListItem extends StatelessWidget {
final int rank;
final String name;
final int steps;
final String avatarUrl;
final int likes;
const RankListItem({
super.key,
required this.rank,
required this.name,
required this.steps,
required this.avatarUrl,
required this.likes,
});
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 16,
offset: const Offset(0, 4),
)
],
),
child: Row(
children: [
// 排名图标/数字
SizedBox(
width: 40,
child: rank <= 3
? Icon(Icons.emoji_events, color: _getRankColor(rank), size: 32)
: Text('#$rank', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900, fontStyle: FontStyle.italic, color: Colors.grey[400])),
),
const SizedBox(width: 12),
// 头像
CircleAvatar(radius: 24, backgroundImage: NetworkImage(avatarUrl)),
const SizedBox(width: 16),
// 姓名
Expanded(child: Text(name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
// 步数(使用 CountUpText)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
CountUpText(
value: steps,
style: const TextStyle(
color: Color(0xFFFF6B00),
fontWeight: FontWeight.w900,
fontSize: 22,
fontStyle: FontStyle.italic,
fontFamily: 'Barlow',
),
),
LikeButton(initialLiked: false, count: likes),
],
),
],
),
);
}
Color _getRankColor(int rank) {
switch (rank) {
case 1: return const Color(0xFFFFD700);
case 2: return const Color(0xFFC0C0C0);
case 3: return const Color(0xFFCD7F32);
default: return Colors.grey.withOpacity(0.5);
}
}
}
六、全系列总结
通过上下两篇的实战,我们完成了一个高水准的运动排行榜页面。
主要成就
- 架构:掌握了
CustomScrollView+SliverPersistentHeader的进阶布局。 - 设计:实现了视差滚动、动态卡片淡出、玻璃拟态等高级 UI 效果。
- 交互:通过数字滚动和点赞反馈,显著提升了用户的操作爽感。
- 性能:针对鸿蒙平台进行了滚动物理效果和列表复用优化。
技术要点回顾
| 技术点 | 实现方案 | 适用场景 |
|---|---|---|
| 数字滚动 | TweenAnimationBuilder |
步数、积分、金额等数值展示 |
| 点赞动效 | ScaleTransition + TweenSequence |
社交互动、收藏、关注等 |
| 下拉刷新 | RefreshIndicator + BouncingScrollPhysics |
列表数据更新 |
| 动态头部 | SliverPersistentHeaderDelegate |
个人主页、商品详情等 |
后续学习方向
- 粒子效果:使用
CustomPainter实现点赞时的爱心粒子喷射。 - 排名变化动画:当排名发生变化时,列表项平滑移动到新位置。
- 数据持久化:使用
shared_preferences或sqflite缓存排行榜数据。 - 实时更新:集成 WebSocket 实现排行榜的实时推送。
这套代码可以轻松复用到"财富榜单"、“积分排名”、"游戏排行"等任何需要展示列表的场景中。
🌐 欢迎加入开源鸿蒙跨平台社区:开源鸿蒙跨平台开发者社区
更多推荐




所有评论(0)