Flutter2.0 繪製旋轉太空人+蛛網效果
前言
最近華為手錶的太空人錶盤突然間火了,錶盤上那個旋轉的太空人呆萌可愛。奈何沒有一款華為手錶,作為一名合格的程式猿,當然要擼起袖子自己來畫一個啦~
鑑於最近Flutter推出了2.0穩定版本,除了對移動端Android、iOS的支援外,Web端和桌面端的支援也整合到了Flutter2.0版本中,新特性支援空指標安全。本次通過Flutter2.0來編寫封面圖展示的效果。
編寫思路
1、旋轉太空人
在構思太空人如何繪製時,此文Flutter繪製-09-華為太空人-殘次版給我提供了繪製的思路,感謝作者的分享,站在巨人的肩膀上,才能走的更高更遠。
在調研之後,發現直接用程式碼動態生成的方式,可能不大適合,懶洋君繪畫水平也不高,畫出來肯定也是不好看。所以轉換了個思維,直接用視訊播放器來播放(這步偷了個懶,有更好實現方式的朋友,可以提供下新思路)。
2、動態蛛網
動態蛛網之前用Android實現了一版《Android實現蛛網背景效果》,是參考canvas-nest.js來寫的(網頁效果)。Adroid版本的實現沒有寫的很具體,感謝《五彩蛛網》的作者,將動態蛛網的繪製過程進行了分解,講解的很詳細。這次嘗試用Flutter來實現。
具體實現
旋轉太空人
1、視訊控制元件
決定使用視訊來播放太空人,那麼就用普及率最高的video_player來編寫。
視訊控制元件的程式碼:
這部分程式碼和video_player提供的example一樣,只修改了VideoPlayerController的建立。通過assets資源來播放視訊:VideoPlayerController.asset(this.videoAssetsUrl)
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class VideoView extends StatefulWidget {
final String videoAssetsUrl;
@override
_VideoViewState createState() => _VideoViewState(videoAssetsUrl);
VideoView(this.videoAssetsUrl);
}
class _VideoViewState extends State<VideoView> {
String videoAssetsUrl;
late VideoPlayerController _controller;
_VideoViewState(this.videoAssetsUrl);
@override
void initState() {
super.initState();
// 通過assets資源來播放視訊
_controller = VideoPlayerController.asset(this.videoAssetsUrl)
..initialize().then((_) {
// Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
setState(() {
_controller.setLooping(true);
_controller.play();
});
});
}
@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
child: _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: Container(),
);
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
}
複製程式碼
2、在pubspec.yaml
中定義視訊資源位置
assets:
- assets/video/
複製程式碼
視訊效果預覽:
動態蛛網
1、配置引數
// 小球可取的顏色值
final List<Color>? ballColors;
/// 小球總數
final int totalCount;
/// 小球連線最大的距離
final double maxDistance;
/// X軸加速度範圍,範圍越大,小球速度相差就越大
final int velocityXRange;
/// Y軸加速度範圍,範圍越大,小球速度相差就越大
final int velocityYRange;
// 每次重新整理,單位位移的畫素,大於0就行,越小,小球運動的越慢
final double eachMovePixel;
/// 小球連線的寬度
final double lineWidth;
/// 連線最大的透明度0~1
final double maxAlpha;
/// 小球半徑
final double radius;
/// 觸控影響半徑
final double touchRadius;
/// 觸控影響半徑
final Color? touchColor;
複製程式碼
2、小球物件資訊
import 'package:flutter/material.dart';
/// 小球
class Point {
/// X軸加速度
int velocityX;
/// Y軸加速度
int velocityY;
/// X軸當前位置
double x;
/// Y當前位置
double y;
/// 小球顏色
Color color;
Point(this.x, this.y,
{this.velocityX = 0, this.velocityY = 0, this.color = Colors.green});
}
複製程式碼
3、根據配置,在initState()
中構建運動小球列表
// 初始化小球列表
for (int i = 0; i < settings!.totalCount; i++) {
// 在控制元件大小範圍內,隨機新增小球
double x = Random().nextInt(width.toInt()).toDouble();
double y = Random().nextInt(height.toInt()).toDouble();
// 下面是設定初始加速度
// 通過下面的公式,防止出現加速度為0,且加速度可為正負velocityXRange
int velocityX = (Random().nextInt(settings!.velocityXRange) + 1) *
(1 - 2 * Random().nextInt(2));
int velocityY = (Random().nextInt(settings!.velocityYRange) + 1) *
(1 - 2 * Random().nextInt(2));
Color color;
if (settings!.ballColors != null && settings!.ballColors!.length > 0) {
color = settings!
.ballColors![Random().nextInt(settings!.ballColors!.length)];
} else {
color = Colors.green;
}
ballList.add(Point(x, y,
velocityX: velocityX, velocityY: velocityY, color: color));
}
複製程式碼
4、使用CustomPainter
來繪製動畫,需要重寫void paint(Canvas canvas, Size size)
和bool shouldRepaint(CustomPainter oldDelegate)
兩個方法。
/// 自定義PointPainter
class PointPainter extends CustomPainter {
math.Point? touchPoint;
Paint ballPaint;
Paint touchPaint = Paint();
List<Point> ballList;
Settings settings;
PointPainter(this.settings, this.touchPoint, this.ballPaint, this.ballList) {
if (settings.touchColor == null) {
touchPaint.color = Color.fromARGB(81, 176, 176, 176);
} else {
touchPaint.color = settings.touchColor!;
}
}
@override
void paint(Canvas canvas, Size size) {
// 在這裡進行真正的繪製
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
// 在實際場景中正確利用此回撥可以避免重繪開銷,目前簡單返回true
// 當條件變化時是否需要重畫
return true;
}
}
複製程式碼
5、獲取觸控點,使用GestureDetector
來獲取觸控事件,並記錄點選位置touchPoint
。
GestureDetector(
child: CustomPaint(
painter:
new PointPainter(settings!, touchPoint, ballPaint, ballList),
),
onPanStart: (DragStartDetails details) {
setState(() {
touchPoint = math.Point(
details.localPosition.dx, details.localPosition.dy);
});
},
onPanUpdate: (DragUpdateDetails details) {
setState(() {
touchPoint = math.Point(
details.localPosition.dx, details.localPosition.dy);
});
},
onPanEnd: (DragEndDetails details) {
setState(() {
touchPoint = null;
});
},
),
複製程式碼
6、在void paint(Canvas canvas, Size size)
中繪製動畫
@override
void paint(Canvas canvas, Size size) {
// 繪製小球列表
drawBallList(canvas);
// 繪製點選區域
drawTouchCircle(canvas);
}
複製程式碼
7、繪製運動小球及觸控點,還有連線
/// 繪製小球列表
void drawBallList(Canvas canvas) {
Paint linePaint = Paint();
// 繪製小球列表
ballList.forEach((ball1) {
linePaint.strokeWidth = settings.lineWidth;
ballPaint.color = ball1.color;
// 繪製小球
canvas.drawCircle(Offset(ball1.x, ball1.y), settings.radius, ballPaint);
// 繪製小球與觸控點之間的連線
drawTouchLine(ball1, linePaint, canvas);
ballList.forEach((ball2) {
// 繪製小球之間的連線
if (ball1 != ball2) {
int distance = point2Distance(ball1, ball2);
if (distance <= settings.maxDistance) {
// 小於最大連線距離,才進行連線
// 線條透明度,距離越遠越透明
double alpha =
(1.0 - distance / settings.maxDistance) * settings.maxAlpha;
Color color = ball1.color;
linePaint.color = Color.fromARGB(
(alpha * 255).toInt(), color.red, color.green, color.blue);
// 繪製兩個小球之間的連線
canvas.drawLine(
Offset(ball1.x, ball1.y), Offset(ball2.x, ball2.y), linePaint);
}
}
});
});
}
/// 繪製點選區域
void drawTouchCircle(Canvas canvas) {
if (touchPoint != null) {
canvas.drawCircle(
Offset(touchPoint!.x.toDouble(), touchPoint!.y.toDouble()),
settings.touchRadius,
touchPaint);
}
}
/// 繪製小球與觸控點之間的連線
void drawTouchLine(Point ball1, Paint linePaint, Canvas canvas) {
if (touchPoint != null) {
int distance = pointNum2Distance(
ball1.x, touchPoint!.x.toDouble(), ball1.y, touchPoint!.y.toDouble());
if (distance <= settings.touchRadius) {
// 線條透明度,距離越近越透明
double alpha = distance / settings.touchRadius * settings.maxAlpha;
Color color = ball1.color;
linePaint.color = Color.fromARGB(
(alpha * 255).toInt(), color.red, color.green, color.blue);
// 繪製兩個小球之間的連線
canvas.drawLine(
Offset(ball1.x, ball1.y),
Offset(touchPoint!.x.toDouble(), touchPoint!.y.toDouble()),
linePaint);
}
}
}
/// 計算兩點之間的距離
int point2Distance(Point p1, Point p2) {
return sqrt(pow(p1.x - p2.x, 2) + pow(p1.y - p2.y, 2)).toInt();
}
/// 計算兩點之間的距離
int pointNum2Distance(double x1, double x2, double y1, double y2) {
return sqrt(pow(x1 - x2, 2) + pow(y1 - y2, 2)).toInt();
}
複製程式碼
讓動畫動起來
1、通過addPersistentFrameCallback
回撥,在回撥中計算每一幀效果的數值,不斷重新整理幀,每一幀組合起來就實現動畫效果了。
WidgetsBinding? widgetsBinding = WidgetsBinding.instance;
widgetsBinding?.addPostFrameCallback((callback) {
// 頁面渲染第一幀的回撥
widgetsBinding.addPersistentFrameCallback((callback) {
// 持久幀的回撥,每一幀重新整理都回觸發
if (mounted) {
setState(() {
// 在這邊進行數值的計算賦值
});
widgetsBinding.scheduleFrame();
}
});
});
複製程式碼
2、setState((){});
中具體的計算過程:
setState(() {
catchBallCount = 0;
ballList.forEach((ball) {
// 計算點選時,小球的偏移量,營造聚攏效果
calculateTouchOffset(ball);
// 當遇到邊界時,需要改變x加速度方向
if (ball.x >=
width - settings!.radius / 2 - settings!.lineWidth / 2) {
if (ball.velocityX > 0) {
ball.velocityX = -ball.velocityX;
}
} else if (ball.x <=
0 + settings!.radius / 2 + settings!.lineWidth / 2) {
if (ball.velocityX < 0) {
ball.velocityX = -ball.velocityX;
}
}
// 根據加速度,計算出小球當前的x值
ball.x = ball.x + ball.velocityX * settings!.eachMovePixel;
// 和計算x值一樣的原理, 計算出y的值
// 當遇到邊界時,需要改變y加速度方向
if (ball.y >=
height - settings!.radius / 2 - settings!.lineWidth / 2) {
if (ball.velocityY > 0) {
ball.velocityY = -ball.velocityY;
}
} else if (ball.y <=
0 + settings!.radius / 2 + settings!.lineWidth / 2) {
if (ball.velocityY < 0) {
ball.velocityY = -ball.velocityY;
}
}
// 根據加速度,計算出小球當前的y值
ball.y = ball.y + ball.velocityY * settings!.eachMovePixel;
});
});
複製程式碼
幀率資訊
繪製動畫的時候,為了瞭解Flutter的繪製效率,新增了幀率想關的資訊展示。
幀率資訊控制元件:
import 'dart:async';
import 'package:flutter/material.dart';
/// 幀率資訊控制元件
class FrameRateView extends StatefulWidget {
@override
_FrameRateViewState createState() => _FrameRateViewState();
}
class _FrameRateViewState extends State<FrameRateView> {
int count = 0;
int offsetTime = 0;
int lastTime = DateTime.now().millisecondsSinceEpoch;
int frameRate = 0;
@override
void initState() {
super.initState();
WidgetsBinding? widgetsBinding = WidgetsBinding.instance;
// 第一幀的回撥
widgetsBinding?.addPostFrameCallback((callback) {
Timer.periodic(Duration(seconds: 1), (timer) {
// 1秒計算一次幀率
if (mounted) {
setState(() {
frameRate = count;
count = 0;
});
}
});
// 持久幀的回撥
widgetsBinding.addPersistentFrameCallback((callback) {
if (mounted) {
int nowTime = DateTime.now().millisecondsSinceEpoch;
setState(() {
count += 1;
offsetTime = nowTime - lastTime;
lastTime = nowTime;
});
widgetsBinding.scheduleFrame();
}
});
});
}
@override
Widget build(BuildContext context) {
return Text(
"重新整理次數:$count\n每秒幀數:$frameRate\n每幀耗時:$offsetTime",
style: TextStyle(color: Colors.white),
);
}
}
複製程式碼
最終效果
黑客帝國效果:
五彩蛛網效果:
感想
以下是個人對Flutter2.0使用中的一些個人體會:
-
空指標特性:和Kotlin差不多,也是用
感嘆號!
和問號?
進行空指標的處理判斷。 -
程式碼巢狀:巢狀這一點,真正上手Flutter的開發者,應該疑慮會比較少。因為程式碼中編寫的巢狀,僅僅是介面元件的宣告,真正繪製的時候,是不存在巢狀問題的。Flutter卡頓的原因,基本不是因為程式碼巢狀,更多是不能合理正確的對
StatelessWidget
和StatefulWidget
進行使用。 -
跨平臺:之前熟悉的是Android原生開發,對於iOS、Web、桌面端的瞭解,更多的是從原生平臺的程式碼框架去學習。平常工作內容更多的在資料處理展示上,比較少涉及到原生平臺特有的Api。在上手Flutter後,一套程式碼可以在多端上執行,確實大大拓寬了技術適用的廣度。目前來說,Android和iOS平臺基本滿足了業務要求,Web和桌面端目前效能和穩定性還沒達到期望值,還處在觀望期,希望後續官方能補齊短板。
倉庫地址
倉庫地址:github.com/SheepYang19…
感謝大家的閱讀,喜歡的話點個贊~
歡迎關注我的技術公眾號“懶洋君工作室”,不定期分享有趣、優質的技術文章~