Flutter适配鸿蒙三方库(piu_animation)实战-正常动画效果
写在前面
最近在做 Flutter 鸿蒙适配的时候,遇到一个需求:实现商品加入购物车的飞入动画。你知道的,就是那种点击"加入购物车"按钮后,商品图标嗖的一下飞到购物车图标的效果。之前在 Android 和 iOS 上用原生实现过,但在 Flutter 里想找个现成的轮子。
搜了一圈,发现了 piu_animation 这个库。试用了一下,效果还不错,而且关键是支持鸿蒙!今天就来分享一下使用心得。
为什么选择 piu_animation
说实话,Flutter 生态里做动画的库不少,但 piu_animation 有几个让我眼前一亮的地方:
代码量真的很少。不像某些动画库,光配置就要写一堆代码。piu_animation 几行代码就能搞定一个完整的飞入动画。
跨平台支持很全面。Android、iOS 自不必说,Web、Windows、macOS 都支持,最重要的是支持鸿蒙系统。现在做 Flutter 项目,鸿蒙适配是绕不过去的坎。
动画效果可定制。虽然是轻量级库,但该有的参数都有:动画时长、起点终点、缩放大小等等,基本能满足大部分需求。
这个库特别适合这些场景:
- 电商 App 的加入购物车动画(这是最常见的)
- 点赞、收藏这类操作的视觉反馈
- 文件保存、下载完成的提示动画
- 任何需要"从 A 点飞到 B 点"的交互效果
快速开始
第一步:添加依赖
打开项目的 pubspec.yaml 文件,在 dependencies 下面加上这一行:
dependencies:
flutter:
sdk: flutter
piu_animation: ^0.0.5
💡 小提示:写这篇文章的时候,最新版本是 0.0.5。你可以去 pub.dev 上看看有没有更新的版本。
保存文件后,在终端执行:
flutter pub get
等待依赖下载完成。如果你用的是 Android Studio 或 VS Code,保存 pubspec.yaml 后编辑器会自动帮你执行这个命令。
第二步:导入包
在你需要使用动画的 Dart 文件顶部,加上这行导入语句:
import 'package:piu_animation/piu_animation.dart';
📝 注意:如果你要用到 loading 动画功能(后面会讲到),还需要额外导入:
import 'package:piu_animation/piu_loading_animation_widget.dart';不过对于基础的飞入动画,只导入
piu_animation.dart就够了。
动手实现第一个动画
理解 GlobalKey 的作用
在开始写代码之前,先说说 GlobalKey。这个东西在 piu_animation 里很重要。
简单来说,GlobalKey 就像是给 Widget 贴了个标签,让我们能在代码里找到这个 Widget 的位置和大小。piu_animation 需要知道动画的起点和终点在屏幕上的具体位置,所以必须用 GlobalKey。
我们需要两个 GlobalKey:
- 一个给根容器(通常是整个页面的容器)
- 一个给目标位置(比如购物车图标)
搭建基础页面结构
先来写个简单的页面框架:
class PiuAnimationDemo extends StatefulWidget {
const PiuAnimationDemo({Key? key}) : super(key: key);
State<PiuAnimationDemo> createState() => _PiuAnimationDemoState();
}
class _PiuAnimationDemoState extends State<PiuAnimationDemo> {
// 根容器的 GlobalKey
GlobalKey rootKey = GlobalKey();
// 目标按钮的 GlobalKey
GlobalKey targetButtonKey = GlobalKey();
Widget build(BuildContext context) {
return Scaffold(
body: Container(
key: rootKey, // 注意这里绑定了 rootKey
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
triggerAnimation();
},
child: const Text("点我触发动画"),
),
const SizedBox(height: 200),
ElevatedButton(
key: targetButtonKey, // 目标位置绑定 key
onPressed: () {},
child: const Text("飞到这里"),
),
],
),
),
);
}
void triggerAnimation() {
// 动画触发逻辑,马上就写
}
}
🎯 关键点:
rootKey必须绑定到一个包含动画起点和终点的共同父容器上targetButtonKey绑定到你希望动画飞向的目标 Widget- 这两个 Key 的位置关系很重要,如果绑定错了,动画可能不显示或位置不对
实现动画触发逻辑
现在来实现 triggerAnimation 方法,这是整个动画的核心:
void triggerAnimation() {
// 第一步:定义要飞的 Widget
Widget piuWidget = Container(
color: Colors.redAccent,
child: const FlutterLogo(size: 50),
);
这里我用了 FlutterLogo 作为演示,你可以换成任何 Widget。比如商品图片、图标、文字等等。
// 第二步:计算目标位置的坐标
RenderBox box = targetButtonKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
Offset endOffset = Offset(
offset.dx + box.size.width / 2,
offset.dy + box.size.height / 2,
);
🔍 这段代码在干什么?
findRenderObject()获取 Widget 的渲染对象localToGlobal(Offset.zero)把局部坐标转换成屏幕坐标- 加上
width / 2和height / 2是为了让动画飞到按钮的中心点,而不是左上角这个计算方式是固定套路,直接复制用就行。
// 第三步:触发动画
PiuAnimation.addAnimation(
rootKey, // 根容器的 Key
piuWidget, // 要飞的 Widget
endOffset, // 终点坐标
maxWidth: MediaQuery.of(context).size.width, // Widget 最大宽度
millisecond: 1500, // 动画时长 1.5 秒
doSomethingBeginCallBack: () {
print("动画开始了");
},
doSomethingFinishCallBack: (success) {
print("动画结束了");
},
);
}
⚙️ 参数说明:
maxWidth:设置成屏幕宽度是个保险做法,防止 Widget 太大超出屏幕millisecond:1500 毫秒(1.5 秒)是个比较舒服的时长,太快会显得仓促,太慢又拖沓- 两个回调函数是可选的,用来在动画开始和结束时做一些额外操作
到这里,一个完整的飞入动画就实现了。运行项目,点击"点我触发动画"按钮,你会看到 Flutter Logo 从按钮位置飞向下面的目标按钮。
深入理解参数配置
必需参数
PiuAnimation.addAnimation 有三个必须传的参数,少一个都不行:
rootGlobalKey
这是根容器的 GlobalKey。piu_animation 会在这个容器上创建一个 Overlay 层来显示动画。如果你不太理解 Overlay,可以把它想象成一个浮在页面最上层的透明画布,动画就在这个画布上绘制。
piuWidget
这是要执行动画的 Widget。可以是任何 Widget:图片、图标、文字、甚至是复杂的组合 Widget。不过建议不要太复杂,因为动画过程中会不断重绘,太复杂会影响性能。
endOffset
动画的终点坐标。这是一个 Offset 对象,包含 x 和 y 两个值,表示屏幕上的绝对位置。前面我们用 localToGlobal 方法计算出来的就是这个。
可选参数详解
除了必需参数,还有一些可选参数可以让动画更符合你的需求:
maxWidth - 默认值 500
Widget 的最大宽度。动画开始时,Widget 会以这个宽度显示,然后逐渐缩小。
maxWidth: MediaQuery.of(context).size.width * 0.8, // 屏幕宽度的 80%
💭 我的经验:设置成屏幕宽度或者屏幕宽度的一定比例比较保险。如果设置太大,在小屏手机上可能会超出屏幕。
minWidth - 默认值 90
Widget 缩放到的最小宽度。动画飞到终点时,Widget 会缩小到这个宽度。
minWidth: 40, // 缩小到 40 像素
millisecond - 默认值 2000
动画持续时间,单位是毫秒。
millisecond: 1200, // 1.2 秒
⏱️ 时长选择建议:
- 800-1000ms:快速、干脆,适合简单的点击反馈
- 1200-1500ms:适中,大部分场景都合适
- 1800-2000ms:较慢,适合需要用户注意的重要操作
超过 2 秒会让用户觉得拖沓,低于 800ms 可能看不清动画细节。
doSomethingBeginCallBack
动画开始时的回调函数。可以在这里做一些准备工作,比如禁用按钮、显示 loading 等。
doSomethingBeginCallBack: () {
setState(() {
isAnimating = true;
});
print("动画开始");
},
doSomethingFinishCallBack
动画结束时的回调函数。这个回调会接收一个 bool 参数,表示动画是否成功完成。
doSomethingFinishCallBack: (success) {
setState(() {
isAnimating = false;
});
if (success) {
// 更新购物车数量等操作
updateCartCount();
}
},
🎪 回调函数的妙用:
- 在开始回调里禁用按钮,防止用户重复点击
- 在结束回调里更新 UI 状态,比如购物车数量
- 在结束回调里触发下一个动画,实现连续动画效果
实战案例:九宫格飞入效果
前面的例子比较简单,现在来做个更实用的:九宫格布局,点击任意位置都能飞向中心的购物车按钮。这个效果在电商 App 里很常见。
页面布局设计
先定义需要的 GlobalKey:
class MultiDirectionDemo extends StatefulWidget {
const MultiDirectionDemo({Key? key}) : super(key: key);
State<MultiDirectionDemo> createState() => _MultiDirectionDemoState();
}
class _MultiDirectionDemoState extends State<MultiDirectionDemo> {
GlobalKey rootKey = GlobalKey();
GlobalKey floatingKey = GlobalKey();
// 用 List.generate 批量创建 9 个 Key
List<GlobalKey> buttonKeys = List.generate(9, (index) => GlobalKey());
🎨 设计思路:
- 用
List.generate批量创建 Key,比手动写 9 个变量优雅多了floatingKey绑定到悬浮按钮(购物车图标)- 所有商品按钮都飞向这个悬浮按钮
构建九宫格界面
Widget build(BuildContext context) {
return Scaffold(
body: Container(
key: rootKey,
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
buildButtonRow([0, 1, 2], ["左上", "上", "右上"]),
buildButtonRow([3, 4, 5], ["左", "中", "右"]),
buildButtonRow([6, 7, 8], ["左下", "下", "右下"]),
],
),
),
floatingActionButton: FloatingActionButton(
key: floatingKey,
onPressed: () {},
child: const Icon(Icons.shopping_cart),
),
);
}
这里用了 spaceAround 让三行按钮均匀分布在屏幕上,这样从不同位置飞向购物车的效果会更明显。
封装按钮行组件
为了避免重复代码,把每一行按钮封装成一个方法:
Widget buildButtonRow(List<int> indexes, List<String> labels) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(3, (i) {
return ElevatedButton(
key: buttonKeys[indexes[i]],
onPressed: () => addToCart(buttonKeys[indexes[i]]),
child: Text(labels[i]),
);
}),
);
}
🔧 代码技巧:
- 用
List.generate动态生成按钮,代码更简洁- 通过
indexes参数传入 Key 的索引,灵活性更高labels参数让按钮文字可配置
实现加入购物车动画
现在来写核心的动画逻辑:
void addToCart(GlobalKey sourceKey) {
// 自定义飞入的 Widget 样式
Widget piuWidget = Container(
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.add_shopping_cart,
color: Colors.white,
size: 40
),
);
这里我用了一个橙色的圆角容器包裹购物车图标,看起来更有质感。你可以根据自己的 UI 风格调整颜色和样式。
// 计算悬浮按钮的中心坐标
RenderBox box = floatingKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
Offset endOffset = Offset(
offset.dx + box.size.width / 2,
offset.dy + box.size.height / 2,
);
这段坐标计算和前面一样,不过这次的目标是悬浮按钮。
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
maxWidth: 80,
minWidth: 30,
millisecond: 1200,
doSomethingBeginCallBack: () {
print("商品飞入购物车");
},
doSomethingFinishCallBack: (success) {
print("已加入购物车");
// 这里可以更新购物车数量
// setState(() { cartCount++; });
},
);
}
}
🎯 参数调整说明:
maxWidth: 80和minWidth: 30:因为是图标,不需要太大millisecond: 1200:1.2 秒的时长,快速但不仓促- 从屏幕四个角飞向中心的距离不同,但动画时长一样,所以速度会有差异,这反而让动画更自然
运行效果
现在运行项目,你会看到:
- 点击任意按钮,都会有一个购物车图标从按钮位置飞向右下角的悬浮按钮
- 从不同位置点击,飞行的轨迹和速度都不一样
- 动画过程中有缩放效果,从大变小
这个效果可以直接用在实际项目里,只需要把按钮换成商品卡片,把图标换成商品图片就行。
鸿蒙平台适配实战
环境检查
在开始鸿蒙适配之前,先确认一下开发环境。打开终端,执行:
flutter --version
你应该能看到 Flutter 版本信息。如果你用的是支持鸿蒙的 Flutter SDK,会在输出里看到相关信息。
然后检查鸿蒙设备连接:
flutter devices
如果鸿蒙设备或模拟器连接正常,会在列表里显示出来。
运行到鸿蒙设备
确认设备连接后,运行项目:
flutter run -d <device-id>
把 <device-id> 替换成你的设备 ID(就是 flutter devices 命令输出的那个)。
📱 真机调试建议:
- 第一次运行建议用真机,模拟器可能有性能问题
- 动画效果在真机上看起来更流畅
- 如果遇到签名问题,检查一下鸿蒙开发者账号配置
性能优化技巧
在鸿蒙设备上运行时,我发现几个优化点:
1. 控制动画时长
鸿蒙设备的性能差异比较大,建议动画时长不要太长:
millisecond: 1200, // 推荐 1000-1500ms
太长的动画在低端设备上可能会卡顿。
2. 简化飞入的 Widget
避免在 piuWidget 里使用复杂的嵌套结构:
// ❌ 不推荐:太复杂
Widget piuWidget = Container(
decoration: BoxDecoration(
gradient: LinearGradient(...),
boxShadow: [...],
),
child: Column(
children: [
Image.network(...), // 网络图片
Text(...),
// 更多嵌套...
],
),
);
// ✅ 推荐:简洁高效
Widget piuWidget = Container(
color: Colors.orange,
child: const Icon(Icons.shopping_cart, size: 40),
);
⚡ 性能提示:
- 优先使用纯色背景,避免渐变和阴影
- 用本地图标代替网络图片
- 减少 Widget 嵌套层级
3. 防止重复触发
用户可能会快速连续点击,导致多个动画同时执行。加个开关控制一下:
bool _isAnimating = false;
void triggerAnimation() {
if (_isAnimating) return; // 如果正在动画,直接返回
setState(() {
_isAnimating = true;
});
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
doSomethingFinishCallBack: (success) {
setState(() {
_isAnimating = false; // 动画结束,解除锁定
});
},
);
}
这样可以避免多个动画叠加导致的性能问题。
鸿蒙特有的注意事项
权限配置
如果你的 piuWidget 里用到了网络图片,需要在鸿蒙项目配置文件里添加网络权限。
找到 ohos/entry/src/main/module.json5 文件,确保有这个配置:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
屏幕适配
鸿蒙设备的屏幕尺寸和分辨率差异很大,建议用相对尺寸:
maxWidth: MediaQuery.of(context).size.width * 0.8, // 屏幕宽度的 80%
minWidth: MediaQuery.of(context).size.width * 0.1, // 屏幕宽度的 10%
这样在不同设备上都能有比较好的效果。
性能分析
如果觉得动画不够流畅,可以用 Flutter 的性能分析工具:
flutter run --profile
这个命令会启动性能分析模式,可以看到帧率、渲染时间等信息。如果发现掉帧,就需要优化 Widget 结构了。
踩过的坑和解决方案
坑一:动画不显示
刚开始用的时候,我遇到过动画完全不显示的情况。点击按钮没有任何反应。
原因分析:
rootKey 绑定的位置不对。我一开始把 rootKey 绑定到了 Scaffold 上,但动画的起点和终点都在 Scaffold 的 body 里。这样 piu_animation 找不到正确的坐标系。
解决方法:
把 rootKey 绑定到一个包含所有动画元素的容器上。比如:
Scaffold(
body: Container(
key: rootKey, // 绑定在这里
child: Column(
children: [
// 起点按钮
// 终点按钮
],
),
),
)
🔍 判断方法:如果动画不显示,先检查 rootKey 是否绑定到了正确的位置。一个简单的判断标准:rootKey 绑定的 Widget 必须是起点和终点的共同祖先。
坑二:终点位置不准
有时候动画能显示,但飞到的位置不对,偏离了目标位置。
原因分析:
在 Widget 还没渲染完成的时候就计算坐标了。比如在 initState 或 build 方法里直接计算坐标,这时候 Widget 的位置信息还不准确。
解决方法:
一定要在用户交互事件(比如 onPressed)里计算坐标:
ElevatedButton(
onPressed: () {
// 在这里计算坐标,确保 Widget 已经渲染完成
RenderBox box = targetKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
// ...
},
child: const Text("点击"),
)
如果确实需要在页面加载时自动触发动画,可以用 WidgetsBinding.instance.addPostFrameCallback:
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// 这里可以安全地计算坐标
triggerAnimation();
});
}
坑三:动画卡顿
在一些低端设备上,动画会有明显的卡顿感。
原因分析:
piuWidget 太复杂了。我之前用了一个包含网络图片、渐变背景、阴影效果的复杂 Widget,在动画过程中不断重绘,导致性能问题。
解决方法:
简化 piuWidget 的结构,能用图标就不用图片,能用纯色就不用渐变:
// 简化前
Widget piuWidget = Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue, Colors.purple],
),
boxShadow: [
BoxShadow(blur: 10, color: Colors.black26),
],
),
child: Image.network('https://...'),
);
// 简化后
Widget piuWidget = Container(
color: Colors.blue,
child: const Icon(Icons.shopping_cart, color: Colors.white),
);
性能提升非常明显。
坑四:多次点击导致动画叠加
用户快速点击按钮时,会同时触发多个动画,看起来很乱。
解决方法:
前面提到过,用一个布尔变量控制:
bool _isAnimating = false;
void addToCart() {
if (_isAnimating) {
// 可以给用户一个提示
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请稍候...')),
);
return;
}
setState(() => _isAnimating = true);
PiuAnimation.addAnimation(
// ...
doSomethingFinishCallBack: (success) {
setState(() => _isAnimating = false);
},
);
}
坑五:在 ListView 中使用时的问题
如果在 ListView 的 item 里触发动画,有时候会遇到坐标计算错误的问题。
原因分析:
ListView 的 item 可能还没有完全渲染,或者在滚动过程中位置发生了变化。
解决方法:
确保在点击事件里实时计算坐标,不要提前缓存:
ListView.builder(
itemBuilder: (context, index) {
return ListTile(
onTap: () {
// 每次点击都重新计算坐标
RenderBox box = cartKey.currentContext!.findRenderObject() as RenderBox;
var offset = box.localToGlobal(Offset.zero);
Offset endOffset = Offset(
offset.dx + box.size.width / 2,
offset.dy + box.size.height / 2,
);
PiuAnimation.addAnimation(rootKey, piuWidget, endOffset);
},
);
},
)
进阶技巧
自定义动画 Widget 样式
除了简单的图标,你还可以做一些更有创意的效果:
带数字的徽章
Widget piuWidget = Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Center(
child: Text(
'+1',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
);
这个效果适合显示"加入购物车 +1"这样的提示。
商品缩略图
Widget piuWidget = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/product_thumb.png',
width: 80,
height: 80,
fit: BoxFit.cover,
),
);
💡 注意:如果用图片,建议用本地资源(
Image.asset),不要用网络图片(Image.network),因为网络图片加载慢,可能导致动画开始时图片还没加载出来。
连续动画效果
有时候需要连续触发多个动画,比如批量加入购物车:
void addMultipleToCart(List<int> productIds) {
for (int i = 0; i < productIds.length; i++) {
Future.delayed(Duration(milliseconds: i * 300), () {
addToCart(productIds[i]);
});
}
}
每个动画间隔 300ms,看起来像是依次飞入,效果很酷。
结合音效
动画配合音效会更有沉浸感:
import 'package:audioplayers/audioplayers.dart';
final player = AudioPlayer();
void addToCartWithSound() {
player.play(AssetSource('sounds/pop.mp3'));
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
// ...
);
}
一个轻快的"pop"音效配合飞入动画,用户体验会好很多。
动态调整动画参数
根据距离动态调整动画时长,距离远的飞得慢一点,距离近的飞得快一点:
void addToCart(GlobalKey sourceKey) {
// 计算起点和终点的距离
RenderBox sourceBox = sourceKey.currentContext!.findRenderObject() as RenderBox;
RenderBox targetBox = cartKey.currentContext!.findRenderObject() as RenderBox;
var sourceOffset = sourceBox.localToGlobal(Offset.zero);
var targetOffset = targetBox.localToGlobal(Offset.zero);
double distance = (targetOffset - sourceOffset).distance;
// 根据距离计算动画时长(距离越远,时长越长)
int duration = (distance * 2).toInt().clamp(800, 2000);
PiuAnimation.addAnimation(
rootKey,
piuWidget,
endOffset,
millisecond: duration,
);
}
这样动画速度会更统一,看起来更自然。
写在最后
用了 piu_animation 一段时间,整体感觉还是很不错的。代码简洁,效果流畅,关键是支持鸿蒙,省去了很多适配的麻烦。
适合使用的场景:
- 电商类 App 的购物车动画(这是最常见的)
- 社交类 App 的点赞、收藏动效
- 工具类 App 的操作反馈动画
- 任何需要"飞入"效果的场景
不太适合的场景:
- 需要复杂路径动画的场景(piu_animation 只支持直线飞入)
- 需要精确控制动画曲线的场景(库内置的曲线不可自定义)
- 需要暂停、恢复动画的场景(不支持动画控制)
如果你的需求比较简单,就是想要一个从 A 飞到 B 的效果,那 piu_animation 绝对是个好选择。如果需求比较复杂,可能需要考虑用 Flutter 原生的 AnimationController 自己实现。
一些使用建议
1. 动画时长的选择
根据我的实践经验,1000-1500ms 是最舒服的时长。太快看不清,太慢又显得拖沓。你可以根据实际情况微调,但不建议超过 2 秒。
2. Widget 样式的设计
飞入的 Widget 不要太复杂,简单的图标或图片就够了。如果想要更丰富的效果,可以加个圆角、背景色,但不要加太多阴影和渐变,会影响性能。
3. 防止重复触发
一定要加个开关控制,防止用户快速点击导致多个动画叠加。这不仅影响视觉效果,还可能导致性能问题。
4. 鸿蒙适配注意事项
在鸿蒙设备上测试时,多试几个不同配置的设备。低端设备可能需要降低动画时长或简化 Widget 结构。
后续计划
piu_animation 还有个 loading 动画功能,可以在飞入过程中暂停并执行异步任务。这个功能特别适合需要网络请求的场景,比如真正的加入购物车操作。
下次有机会再写一篇文章,专门讲讲 loading 动画的使用,包括成功和失败两种情况的处理。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)