【Flutter】シンプルな波のアニメーション (Simple Wave Animation)


# 目次
- [作成するアニメーション](#作成するアニメーション)
- [ベースプログラム](#ベースプログラム)
- [コーディング手順](#コーディング手順)
 - [1. アニメーションを作る準備](#1-アニメーションを作る準備)
 - [2. 波の要素を1つ追加](#2-波の要素を1つ追加)
 - [3. WaveClipperを実装](#3-waveclipperを実装)
 - [4. 波をもう一つ追加](#4-波をもう一つ追加)
- [最終的なプログラム](#最終的なプログラム_waveviewstateとwaveclipperのみ)
- [応用](#応用)


# 作成するアニメーション
<img width="300" alt="wave.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/231104/0237f3f9-1e2e-057e-128a-a8c0eba08c1b.gif">

# ベースプログラム
これが今回のベースとなるプログラムです。
一番下の`_WaveViewState`にアニメーションの実装をしていきます。


```main.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // システムバー・ナビゲーションバーの色
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: Brightness.dark,
      statusBarBrightness: Brightness.light,
      systemNavigationBarColor: Colors.white,
      systemNavigationBarDividerColor: Colors.grey,
      systemNavigationBarIconBrightness: Brightness.dark,
    ));
    return MaterialApp(
      debugShowCheckedModeBanner: false, // DEBUGバナー削除
      title: 'Wave Animation',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: WaveView(),
    );
  }
}

class WaveView extends StatefulWidget {
  @override
  _WaveViewState createState() => _WaveViewState();
}

class _WaveViewState extends State<WaveView> {
  // ...
}


```



# コーディング手順

## 1. アニメーションを作る準備
```dart
// 1
class _WaveViewState extends State<WaveView> with SingleTickerProviderStateMixin {
  // 2
  AnimationController waveController; // AnimationControllerの宣言
  static const darkBlue = Color(0xFF264bc5); // 波の色

  // 3
  @override
  void initState() {
    waveController = AnimationController(
      duration: const Duration(seconds: 3), // アニメーションの間隔を3秒に設定
      vsync: this, // おきまり
    )..repeat(); // リピート設定

    super.initState();
  }

  // 4
  @override
  void dispose() {
    waveController.dispose(); // AnimationControllerは明示的にdisposeする。
    super.dispose();
  }

  // 5
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: waveController, // waveControllerを設定
        builder: (context, child) => Stack(
          children: <Widget>[
            Text(waveController.value.toString()), // テスト用
            // 本当の要素はこのあと追加します。
          ],
        ),
      ),
    );
  }

}
```

1. AnimationControllerを1つだけ扱うので`SingleTickerProviderStateMixin`を適用
2. `waveController`という`AnimationController`を宣言
3. アニメーションの間隔・リピートを設定
4. `AnimationController`の破棄
5. `AnimatedBuilder`に`waveController`を設定

ここまでできたら一度コンパイルして動かしてみましょう。
`waveController.value`が3秒間隔で0~1まで変化していることがわかるはずです。


## 2. 波の要素を1つ追加
build関数のみ

``` dart
class _WaveViewState extends State<WaveView> with SingleTickerProviderStateMixin {
  // ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: waveController, // waveControllerを設定
        builder: (context, child) => Stack(
          children: <Widget>[
            // ↓ 追加部分
            // 1
            ClipPath(
              // 3
              child: Container(color: darkBlue),
              // 2
              clipper: WaveClipper(context, waveController.value, 0),
            ),
            // ↑ 追加部分
          ],
        ),
      ),
    );
  }
}
```


1. クリッピング（切り抜き）用のウイジェット`ClipPath`を追加
2. 切り抜き対象(child)にContainerを設定（波の色を指定）
3. 切り抜き方法として`WaveClipper`を設定

`WaveClipper`が未定義であるため，このままでは動きません。
次のステップで`WaveClipper`を実装します。


## 3. `WaveClipper`を実装
`WaveClipper`は`_WaveViewState`の外に実装します。

```dart
class WaveClipper extends CustomClipper<Path> {
  // 1
  WaveClipper(this.context, this.waveControllerValue, this.offset) {
    final width = MediaQuery.of(context).size.width; // 画面の横幅
    final height = MediaQuery.of(context).size.height; // 画面の高さ

    // coordinateListに波の座標を追加
    for (var i = 0; i <= width / 3; i++) {
      final step = (i / width) - waveControllerValue;
      coordinateList.add(
        Offset(
          i.toDouble() * 3, // X座標
          math.sin(step * 2 * math.pi - offset) * 45 + height * 0.5, // Y座標
        ),
      );
    }
  }

  final BuildContext context;
  final double waveControllerValue; // waveController.valueの値
  final double offset; // 波のずれ
  final List<Offset> coordinateList = []; // 波の座標のリスト

  // 2
  @override
  Path getClip(Size size) {
    final path = Path()
      // addPolygon: coordinateListに入っている座標を直線で結ぶ。
      //             false -> 最後に始点に戻らない
      ..addPolygon(coordinateList, false)
      ..lineTo(size.width, size.height) // 画面右下へ
      ..lineTo(0, size.height) // 画面左下へ
      ..close(); // 始点に戻る
    return path;
  }

  // 3
  // 再クリップするタイミング -> animationValueが更新されていたとき
  @override
  bool shouldReclip(WaveClipper oldClipper) =>
      waveControllerValue != oldClipper.waveControllerValue;
}
```

1. コンストラクタを定義, `coordinateList`の初期化を行います。
2. 切り抜き方法を定義, 引数の`size`は画面のサイズです。
3. 再クリップの判定方法を定義, 返り値がtrueなら再クリップされます。

これで動くようになりました。リロードしてみましょう。波が一つ追加されているはずです。

## 4. 波をもう一つ追加
build関数のみ

``` dart
class _WaveViewState extends State<WaveView> with SingleTickerProviderStateMixin {
  // ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: waveController, // waveControllerを設定
        builder: (context, child) => Stack(
          children: <Widget>[
            // 1つ目の波
            ClipPath(
              child: Container(color: darkBlue),
              clipper: WaveClipper(context, waveController.value, 0),
            ),
            // 2つ目の波
            ClipPath(
              child: Container(color: darkBlue.withOpacity(0.6)),
              clipper: WaveClipper(context, waveController.value, 0.5),
            ),
          ],
        ),
      ),
    );
  }
```

# 最終的なプログラム（`_WaveViewState`と`WaveClipper`のみ）
```main.dart
class _WaveViewState extends State<WaveView>
    with SingleTickerProviderStateMixin {
  AnimationController waveController; // AnimationControllerの宣言
  static const darkBlue = Color(0xFF264bc5); // 波の色

  @override
  void initState() {
    waveController = AnimationController(
      duration: const Duration(seconds: 3), // アニメーションの間隔を3秒に設定
      vsync: this, // おきまり
    )..repeat(); // リピート設定

    super.initState();
  }

  @override
  void dispose() {
    waveController.dispose(); // AnimationControllerは明示的にdisposeする。
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: waveController, // waveControllerを設定
        builder: (context, child) => Stack(
          children: <Widget>[
            // 1つ目の波
            ClipPath(
              child: Container(color: darkBlue),
              clipper: WaveClipper(context, waveController.value, 0),
            ),
            // 2つ目の波
            ClipPath(
              child: Container(color: darkBlue.withOpacity(0.6)),
              clipper: WaveClipper(context, waveController.value, 0.5),
            ),
          ],
        ),
      ),
    );
  }
}

class WaveClipper extends CustomClipper<Path> {
  WaveClipper(this.context, this.waveControllerValue, this.offset) {
    final width = MediaQuery.of(context).size.width; // 画面の横幅
    final height = MediaQuery.of(context).size.height; // 画面の高さ

    // coordinateListに波の座標を追加
    for (var i = 0; i <= width / 3; i++) {
      final step = (i / width) - waveControllerValue;
      coordinateList.add(
        Offset(
          i.toDouble() * 3, // X座標
          math.sin(step * 2 * math.pi - offset) * 45 + height * 0.5, // Y座標
        ),
      );
    }
  }

  final BuildContext context;
  final double waveControllerValue; // waveController.valueの値
  final double offset; // 波のずれ
  final List<Offset> coordinateList = []; // 波の座標のリスト

  @override
  Path getClip(Size size) {
    final path = Path()
      // addPolygon: coordinateListに入っている座標を直線で結ぶ。
      //             false -> 最後に始点に戻らない
      ..addPolygon(coordinateList, false)
      ..lineTo(size.width, size.height) // 画面右下へ
      ..lineTo(0, size.height) // 画面左下へ
      ..close(); // 始点に戻る
    return path;
  }

  // 再クリップするタイミング -> animationValueが更新されていたとき
  @override
  bool shouldReclip(WaveClipper oldClipper) =>
      waveControllerValue != oldClipper.waveControllerValue;
}
```


# 応用

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">燃料タンクっぽいやつ<a href="https://twitter.com/hashtag/Flutter?src=hash&amp;ref_src=twsrc%5Etfw">#Flutter</a> <a href="https://t.co/CNraqtW56O">pic.twitter.com/CNraqtW56O</a></p>&mdash; シン (@derodero24) <a href="https://twitter.com/derodero24/status/1259807310722957312?ref_src=twsrc%5Etfw">May 11, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>