Flutter實現一個牛頓擺

語言: CN / TW / HK

一起養成寫作習慣!這是我參與「掘金日新計劃 · 4 月更文挑戰」的第18天,點選檢視活動詳情

前言

  • 牛頓擺大家應該都不陌生,也叫碰碰球、永動球(理論情況下),那麼今天我們用Flutter實現這麼一個理論中的永動球,可以作為載入Loading使用。

- 知識點:繪製、動畫曲線、多動畫狀態更新

效果圖:

638bdf30-7b2a-4c3e-ad14-94da128b68f1.gif

1、繪製靜態效果

首先我們需要把線和小圓球繪製出來,對於看過我之前文章的小夥伴來說這個就很簡單了,效果圖: image.png
關鍵程式碼:

```dart // 小圓球半徑 double radius = 6;

/// 小球圓心和直線終點一致 //左邊小球圓心 Offset offset = Offset(20, 60); //右邊小球圓心 Offset offset2 = Offset(20 * 6 * 8, 60);

Paint paint = Paint() ..color = Colors.black87 ..strokeWidth = 2;

/// 繪製線 canvas.drawLine(Offset.zero, Offset(90, 0), paint); canvas.drawLine(Offset(20, 0), offset, paint); canvas.drawLine( Offset(20 + radius * 2, 0), Offset(20 + radius * 2, 60), paint); canvas.drawLine( Offset(20 + radius * 4, 0), Offset(20 + radius * 4, 60), paint); canvas.drawLine( Offset(20 + radius * 6, 0), Offset(20 + radius * 6, 60), paint); canvas.drawLine(Offset(20 + radius * 8, 0), offset2, paint);

/// 繪製小圓球 canvas.drawCircle(offset, radius, paint); canvas.drawCircle(Offset(20 + radius * 2, 60), radius, paint); canvas.drawCircle(Offset(20 + radius * 4, 60), radius, paint); canvas.drawCircle(Offset(20 + radius * 6, 60), radius, paint); canvas.drawCircle(offset2, radius, paint); ```

2、加入動畫

思路: 我們可以看到5個小球一共2個小球在運動,左邊小球運動一個來回之後傳遞給右邊小球,右邊小球開始運動,右邊一個來回再傳遞給左邊開始,也就是左邊運動週期是:0-1-0,正向運動一次,反向再運動一次,這樣就是一個週期,右邊也是一樣,左邊運動完傳遞給右邊,右邊運動完傳遞給左邊,這樣就簡單實現了牛頓擺的效果。

兩個關鍵點

小球運動路徑: 小球的運動路徑是一個弧度,以豎線的起點為圓心,終點為半徑,那麼我們只需要設定小球運動至最高點的角度即可,通過角度就可計算出小球的座標點。

運動曲線: 當然我們知道牛頓擺小球的運動曲線並不是勻速的,他是有一個加速減速過程的,撞擊之後,小球先加速然後減速達到最高點速度為0,之後速度再從0慢慢加速進行撞擊小球,周而復始。
下面的運動曲線就是先加速再減速,大概符合牛頓擺的運動曲線。我們就使用這個曲線看看效果。 ndb.gif
完整原始碼: ```dart class OvalLoading extends StatefulWidget { const OvalLoading({Key? key}) : super(key: key);

@override _OvalLoadingState createState() => _OvalLoadingState(); }

class _OvalLoadingState extends State with TickerProviderStateMixin { // 左邊小球 late AnimationController _controller = AnimationController(vsync: this, duration: Duration(milliseconds: 300)) ..addStatusListener((status) { if (status == AnimationStatus.completed) { _controller.reverse(); //反向執行 1-0 } else if (status == AnimationStatus.dismissed) { _controller2.forward(); } }) ..forward(); // 右邊小球 late AnimationController _controller2 = AnimationController(vsync: this, duration: Duration(milliseconds: 300)) ..addStatusListener((status) { // dismissed 動畫在起始點停止 // forward 動畫正在正向執行 // reverse 動畫正在反向執行 // completed 動畫在終點停止 if (status == AnimationStatus.completed) { _controller2.reverse(); //反向執行 1-0 } else if (status == AnimationStatus.dismissed) { // 反向執行完畢左邊小球執行 _controller.forward(); } }); late var cure = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); late var cure2 = CurvedAnimation(parent: _controller2, curve: Curves.easeOutCubic);

late Animation animation = Tween(begin: 0.0, end: 1.0).animate(cure);

late Animation animation2 = Tween(begin: 0.0, end: 1.0).animate(cure2);

@override Widget build(BuildContext context) { return Container( margin: EdgeInsetsDirectional.only(top: 300, start: 150), child: CustomPaint( size: Size(100, 100), painter: _OvalLoadingPainter( animation, animation2, Listenable.merge([animation, animation2])), ), ); }

@override void dispose() { _controller.dispose(); _controller2.dispose(); super.dispose(); } }

class _OvalLoadingPainter extends CustomPainter { double radius = 6; final Animation animation; final Animation animation2; final Listenable listenable;

late Offset offset; // 左邊小球圓心 late Offset offset2; // 右邊小球圓心

final double lineLength = 60; // 線長

_OvalLoadingPainter(this.animation, this.animation2, this.listenable) : super(repaint: listenable) { offset = Offset(20, lineLength); offset2 = Offset(20 * radius * 8, lineLength); }

// 擺動角度 double angle = pi / 180 * 30; // 30°

@override void paint(Canvas canvas, Size size) { Paint paint = Paint() ..color = Colors.black87 ..strokeWidth = 2;

// 左邊小球 預設座標 下方是90度 需要+pi/2
var dx = 20 + 60 * cos(pi / 2 + angle * animation.value);
var dy = 60 * sin(pi / 2 + angle * animation.value);
// 右邊小球
var dx2 = 20 + radius * 8 - 60 * cos(pi / 2 + angle * animation2.value);
var dy2 = 60 * sin(pi / 2 + angle * animation2.value);

offset = Offset(dx, dy);
offset2 = Offset(dx2, dy2);

/// 繪製線
  canvas.drawLine(Offset.zero, Offset(90, 0), paint);
canvas.drawLine(Offset(20, 0), offset, paint);
canvas.drawLine(
    Offset(20 + radius * 2, 0), Offset(20 + radius * 2, 60), paint);
canvas.drawLine(
    Offset(20 + radius * 4, 0), Offset(20 + radius * 4, 60), paint);
canvas.drawLine(
    Offset(20 + radius * 6, 0), Offset(20 + radius * 6, 60), paint);
canvas.drawLine(Offset(20 + radius * 8, 0), offset2, paint);

/// 繪製球
canvas.drawCircle(offset, radius, paint);
canvas.drawCircle(
    Offset(20 + radius * 2, 60),
    radius,
    paint);

canvas.drawCircle(Offset(20 + radius * 4, 60), radius, paint);
canvas.drawCircle(Offset(20 + radius * 6, 60), radius, paint);
canvas.drawCircle(offset2, radius, paint);

} @override bool shouldRepaint(covariant _OvalLoadingPainter oldDelegate) { return oldDelegate.listenable != listenable; } } ``` 去掉線的效果:
b6a23e8a-9c4a-4aa8-9518-46a53b756a88.gif

總結

本文展示了實現牛頓擺的原理,其實並不複雜,關鍵點就是小球的運動軌跡和運動速度曲線,如果用到專案中當做Loading還有很多優化的空間,比如加上小球影子、修改小球顏色或者把小球換成好玩的圖片等等操作會看起來更好看一點,本篇只展示了實現的原理,希望對大家有一些幫助~