【译】Flutter:图像的爆炸动画
原文链接 :https://medium.com/mobile-development-group/flutter-explosion-animation-for-image-3dd5e2863427
本篇文章将展示如何使用 Flutter 完成如下动画效果,本文相关的 Demo 代码在 pub 上的 explode_view 项目可以找到。
首先我们从创建 ExplodeView
对象开始,该对象在 Widget
中主要保存 imagePath
和图像的位置。
class ExplodeView extends StatelessWidget {
final String imagePath;
final double imagePosFromLeft;
final double imagePosFromTop;
const ExplodeView({
@required this.imagePath,
@required this.imagePosFromLeft,
@required this.imagePosFromTop
});
@override
Widget build(BuildContext context) {
// This variable contains the size of the screen
final screenSize = MediaQuery.of(context).size;
return new Container(
child: new ExplodeViewBody(
screenSize: screenSize,
imagePath: imagePath,
imagePosFromLeft: imagePosFromLeft,
imagePosFromTop: imagePosFromTop),
);
}
}
接着开始实现 ExplodeViewBody
, 主要看它的 State
实现, _ExplodeViewState
中主要继承了 State
并混入了 TickerProviderStateMixin
用于实现动画执行的需求。
class _ExplodeViewState extends State<ExplodeViewBody> with TickerProviderStateMixin{
GlobalKey currentKey;
GlobalKey imageKey = GlobalKey();
GlobalKey paintKey = GlobalKey();
bool useSnapshot = true;
bool isImage = true;
math.Random random;
img.Image photo;
AnimationController imageAnimationController;
double imageSize = 50.0;
double distFromLeft=10.0, distFromTop=10.0;
final StreamController<Color> _stateController = StreamController<Color>.broadcast();
@override
void initState() {
super.initState();
currentKey = useSnapshot ? paintKey : imageKey;
random = new math.Random();
imageAnimationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 3000),
);
}
@override
Widget build(BuildContext context) {
return Container(
child: isImage
? StreamBuilder(
initialData: Colors.green[500],
stream: _stateController.stream,
builder: (buildContext, snapshot) {
return Stack(
children: <Widget>[
RepaintBoundary(
key: paintKey,
child: GestureDetector(
onLongPress: () async {
//do explode
}
child: Container(
alignment: FractionalOffset((widget.imagePosFromLeft / widget.screenSize.width), (widget.imagePosFromTop / widget.screenSize.height)),
child: Transform(
transform: Matrix4.translation(_shakeImage()),
child: Image.asset(
widget.imagePath,
key: imageKey,
width: imageSize,
height: imageSize,
),
),
),
),
)
],
);
},
):
Container(
child: Stack(
children: <Widget>[
for(Particle particle in particles) particle.startParticleAnimation()
],
),
)
);
}
@override
void dispose(){
imageAnimationController.dispose();
super.dispose();
}
}
这里省略了部分代码,省略部分在后面介绍。
首先,在 _ExplodeViewState
中初始化了 StreamController<Color>
对象,该对象可以通过 Stream
流来控制 StreamBuilder
触发 UI 重绘制。
然后,在 initState
方法中初始化了 imageAnimationController
作为动画控制器,用于控制图片爆炸前的抖动动画效果。
接着在 build
方法中, 通过条件判断是需要显示图片还是粒子动画,如果需要显示图像,就使用 Image.asset
显示图像效果;外层的 GestureDetector
用于长按时触发爆炸动画效果; StreamBuilder
中的 stream
用于保存图片的颜色和控制重绘的执行。
接着我们还需要实现 Particle
对象,它被用于配置每个粒子的动画效果。
如下代码所示,在 Particle
的构造方法中,需要指定 id(Demo 中是 index)、颜色和颗粒的位置作为参数,之后初始化一个 AnimationController
用于控制粒子的移动效果,通过设置 Tween
来实现动画的在你正负 x 和 y 轴上进行平移,另外还设置了动画过程中颗粒的透明度变化。
Particle({@required this.id, @required this.screenSize, this.colors, this.offsetX, this.offsetY, this.newOffsetX, this.newOffsetY}) {
position = Offset(this.offsetX, this.offsetY);
math.Random random = new math.Random();
this.lastXOffset = random.nextDouble() * 100;
this.lastYOffset = random.nextDouble() * 100;
animationController = new AnimationController(
vsync: this,
duration: Duration(milliseconds: 1500)
);
translateXAnimation = Tween(begin: position.dx, end: lastXOffset).animate(animationController);
translateYAnimation = Tween(begin: position.dy, end: lastYOffset).animate(animationController);
negatetranslateXAnimation = Tween(begin: -1 * position.dx, end: -1 * lastXOffset).animate(animationController);
negatetranslateYAnimation = Tween(begin: -1 * position.dy, end: -1 * lastYOffset).animate(animationController);
fadingAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(animationController);
particleSize = Tween(begin: 5.0, end: random.nextDouble() * 20).animate(animationController);
}
之后实现 startParticleAnimation()
方法,该方法用于执行粒子动画,该方法通过将上述 animationController
添加到 AnimatedBuilder
这个控件中并执行,之后通过AnimatedBuilder
的 builder
方法配合 Transform
和 FadeTransition
, 实现动画的移动和透明度变化效果。
startParticleAnimation() {
animationController.forward();
return Container(
alignment: FractionalOffset(
(newOffsetX / screenSize.width), (newOffsetY / screenSize.height)),
child: AnimatedBuilder(
animation: animationController,
builder: (BuildContext context, Widget widget) {
if (id % 4 == 0) {
return Transform.translate(
offset: Offset(
translateXAnimation.value, translateYAnimation.value),
child: FadeTransition(
opacity: fadingAnimation,
child: Container(
width: particleSize.value > 5 ? particleSize.value : 5,
height: particleSize.value > 5 ? particleSize.value : 5,
decoration:
BoxDecoration(color: colors, shape: BoxShape.circle),
),
));
} else if (id % 4 == 1) {
return Transform.translate(
offset: Offset(
negatetranslateXAnimation.value, translateYAnimation.value),
child: FadeTransition(
opacity: fadingAnimation,
child: Container(
width: particleSize.value > 5 ? particleSize.value : 5,
height: particleSize.value > 5 ? particleSize.value : 5,
decoration:
BoxDecoration(color: colors, shape: BoxShape.circle),
),
));
} else if (id % 4 == 2) {
return Transform.translate(
offset: Offset(
translateXAnimation.value, negatetranslateYAnimation.value),
child: FadeTransition(
opacity: fadingAnimation,
child: Container(
width: particleSize.value > 5 ? particleSize.value : 5,
height: particleSize.value > 5 ? particleSize.value : 5,
decoration:
BoxDecoration(color: colors, shape: BoxShape.circle),
),
));
} else {
return Transform.translate(
offset: Offset(negatetranslateXAnimation.value,
negatetranslateYAnimation.value),
child: FadeTransition(
opacity: fadingAnimation,
child: Container(
width: particleSize.value > 5 ? particleSize.value : 5,
height: particleSize.value > 5 ? particleSize.value : 5,
decoration:
BoxDecoration(color: colors, shape: BoxShape.circle),
),
));
}
},
),
);
}
)
如上代码所示,这里实现了四种不同方向的例子移动,通过使用不同的方向值和 offset
,然后根据上面定义的 Tween
对象配置动画,最后使用了圆形形状的 BoxDecoration
和可变的高度和宽度创建粒子。
这样就完成了 Particle
类的实现,接下来介绍从图像中获取颜色的实现。
Future<Color> getPixel(Offset globalPosition, Offset position, double size) async {
if (photo == null) {
await (useSnapshot ? loadSnapshotBytes() : loadImageBundleBytes());
}
Color newColor = calculatePixel(globalPosition, position, size);
return newColor;
}
Color calculatePixel(Offset globalPosition, Offset position, double size) {
double px = position.dx;
double py = position.dy;
if (!useSnapshot) {
double widgetScale = size / photo.width;
px = (px / widgetScale);
py = (py / widgetScale);
}
int pixel32 = photo.getPixelSafe(px.toInt()+1, py.toInt());
int hex = abgrToArgb(pixel32);
_stateController.add(Color(hex));
Color returnColor = Color(hex);
return returnColor;
}
如上所示代码,实现了从图像中获取指定位置的像素颜色,在 Demo 中使用了不同的方法来加载和设置图像的 bytes(loadSnapshotBytes()
或者 loadImageBundleBytes()
),从而获取颜色数据。
// Loads the bytes of the image and sets it in the img.Image object
Future<void> loadImageBundleBytes() async {
ByteData imageBytes = await rootBundle.load(widget.imagePath);
setImageBytes(imageBytes);
}
// Loads the bytes of the snapshot if the img.Image object is null
Future<void> loadSnapshotBytes() async {
RenderRepaintBoundary boxPaint = paintKey.currentContext.findRenderObject();
ui.Image capture = await boxPaint.toImage();
ByteData imageBytes =
await capture.toByteData(format: ui.ImageByteFormat.png);
setImageBytes(imageBytes);
capture.dispose();
}
void setImageBytes(ByteData imageBytes) {
List<int> values = imageBytes.buffer.asUint8List();
photo = img.decodeImage(values);
}
现在当我们长按图像时,就可以进入散射粒子的最终动画,并执行以下方法开始生成粒子:
RenderBox box = imageKey.currentContext.findRenderObject();
Offset imagePosition = box.localToGlobal(Offset.zero);
double imagePositionOffsetX = imagePosition.dx;
double imagePositionOffsetY = imagePosition.dy;
double imageCenterPositionX = imagePositionOffsetX + (imageSize / 2);
double imageCenterPositionY = imagePositionOffsetY + (imageSize / 2);
for(int i = 0; i < noOfParticles; i++){
if(i < 21){
getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 60), box.size.width).then((value) {
colors.add(value);
});
}else if(i >= 21 && i < 42){
getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 52), box.size.width).then((value) {
colors.add(value);
});
}else{
getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 68), box.size.width).then((value) {
colors.add(value);
});
}
}
Future.delayed(Duration(milliseconds: 3500), () {
for(int i = 0; i < noOfParticles; i++){
if(i < 21){
particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.7)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 60)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 60));
}else if(i >= 21 && i < 42){
particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.5)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 52)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 52));
}else{
particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.9)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 68)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 68));
}
}
setState(() {
isImage = false;
});
});
如上代码所示,这里使用了 RenderBox
类获得的图像的位置,然后从上面定义的 getPixel()
方法获取颜色。
获取的颜色是从图像上的三条水平线中提取到的,并在同一条线上使用了随机偏移,这样可以从图像中获得到更多的颜色,然后使用适当的参数值在不同位置使用 Particle
创建粒子。
当然这里还有 3.5 秒的延迟执行,而在这个延迟过程中会出现图像抖动。通过使用 Matrix4.translation()
方法可以简单地实现抖动,该方法使用与下面所示的 _shakeImage
方法来实现不同的偏移量来快速转换图像。
Vector3 _shakeImage() {
return Vector3(math.sin((imageAnimationController.value) * math.pi * 20.0) * 8, 0.0, 0.0);
}
最后,在摇动图像并创建了粒子之后图像消失,并且调用之前的 startParticleAnimation
方法,这完成了在 Flutter 中的图像爆炸。
最后如下就可以引入 ExplodeView
。
ExplodeView(
imagePath: path,
imagePosFromLeft: xxxx,
imagePosFromTop: xxxx
),
Demo 地址: https://github.com/mdg-soc-19/explode-view/blob/master/lib/explode_view.dart
ps:因为不像 Android 上可以获取
Bitmap
的横竖坐标上的二维像素点,所以没办法实现整个图片原地爆炸的效果。
资源推荐
- Github : https://github.com/CarGuo
- 开源 Flutter 完整项目:https://github.com/CarGuo/GSYGithubAppFlutter
- 开源 Flutter 多案例学习型项目: https://github.com/CarGuo/GSYFlutterDemo
- 开源 Fluttre 实战电子书项目:https://github.com/CarGuo/GSYFlutterBook
- 开源 React Native 项目:https://github.com/CarGuo/GSYGithubApp
推荐阅读
-
【译】Flutter:图像的爆炸动画
-
Flutter的菜鸟教程十八:动画-AnimatedWidget简化
-
Flutter Animation(1)动画的简单使用
-
Android 开发者的 Flutter(三) —— Flutter 中的动画与绘制
-
HTML5 用动画的表现形式装载图像
-
Android开发之图形图像与动画(二)Animation实现图像的渐变/缩放/位移/旋转
-
Android开发之图形图像与动画(三)Animation效果的XML实现
-
HTML5 用动画的表现形式装载图像
-
Android开发之图形图像与动画(三)Animation效果的XML实现
-
Android开发之图形图像与动画(二)Animation实现图像的渐变/缩放/位移/旋转