原文連結 :medium.com/mobile-deve…
本篇文章將展示如何使用 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 地址: github.com/mdg-soc-19/…
ps:因為不像 Android 上可以獲取
Bitmap
的橫豎座標上的二維畫素點,所以沒辦法實現整個圖片原地爆炸的效果。
資源推薦
- Github : github.com/CarGuo
- 開源 Flutter 完整專案:github.com/CarGuo/GSYG…
- 開源 Flutter 多案例學習型專案: github.com/CarGuo/GSYF…
- 開源 Fluttre 實戰電子書專案:github.com/CarGuo/GSYF…
- 開源 React Native 專案:github.com/CarGuo/GSYG…