Flutterでバウンスするボタンを実装する

仕事で、Material DesignではないUIを実装したので、そのパーツについて紹介します。

## 実装したもの

ユーザがタップすると少し小さくなることで、タップフィードバックを行うボタンです。

↓イメージ
![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/251ba321-b73c-c21d-ccd5-bbbe7292cff5.gif)
（GIFアニメなので、少しカクついてますが、実際の端末では滑らかに動きます。）

## コード

全文は [こちら](https://github.com/noboru-i/flutter_sample/blob/master/lib/screens/bounce_button/bounce_button.dart)。

https://medium.com/flutter-community/flutter-bouncing-button-animation-ece660e19c91 の記事をベースにしつつ改変、ある程度自由度が高い形で作れたと思います。

### コード説明

上から順番に抜粋していき、それぞれの説明をしていきます。

```dart
import 'package:flutter/material.dart';

class BounceButton extends MaterialButton {
  BounceButton({
    Key key,
    @required VoidCallback onPressed,
    VoidCallback onLongPress,
    ButtonTextTheme textTheme,
    Color textColor,
    Color disabledTextColor,
    Color color,
    Color disabledColor,
    Brightness colorBrightness,
    double elevation = 0,
    EdgeInsetsGeometry padding,
    ShapeBorder shape,
    Clip clipBehavior = Clip.none,
    FocusNode focusNode,
    bool autofocus = false,
    MaterialTapTargetSize materialTapTargetSize,
    Duration animationDuration = kThemeChangeDuration,
    Widget child,
    this.ratio = 0.95,
  })  : assert(autofocus != null),
        assert(clipBehavior != null),
        super(
          key: key,
          onPressed: onPressed,
          onLongPress: onLongPress,
          textTheme: textTheme,
          textColor: textColor,
          disabledTextColor: disabledTextColor,
          color: color,
          disabledColor: disabledColor,
          colorBrightness: colorBrightness,
          elevation: elevation,
          padding: padding,
          shape: shape,
          clipBehavior: clipBehavior,
          focusNode: focusNode,
          autofocus: autofocus,
          materialTapTargetSize: materialTapTargetSize,
          animationDuration: animationDuration,
          child: child,
        );
```

Flutter標準のRaisedButtonやFlatButtonを参考に、 [MaterialButton](https://api.flutter.dev/flutter/material/MaterialButton-class.html) を継承しています。
これにより、「ボタンとしての標準的なUI・動作」は実装不要となっています。

コンストラクタについては、 [RaisedButtonのコンストラクタ定義](https://api.flutter.dev/flutter/material/RaisedButton/RaisedButton.html) と [FlatButtonのコンストラクタ定義](https://api.flutter.dev/flutter/material/FlatButton/FlatButton.html) を参考にしました。

`ratio` は、このUI用に追加したパラメータで、小さくなったときの倍率を設定できます。

プロジェクトのコード内に入れるときには、更に複数のパラメータに初期値を設定しています。

```dart

  final double ratio;

  @override
  Widget build(BuildContext context) {
    return BounceAnimation(
      onPressed: onPressed,
      onLongPress: onLongPress,
      ratio: ratio,
      animationDuration: animationDuration,
      child: AbsorbPointer(
        child: super.build(context),
      ),
    );
  }
}
```

`BounceButton` の定義自体はここまでです。

後述する `BounceAnimation` で `super.build` を囲んでいます。
タップ動作に関することは `BounceAnimation` に渡していますが、それ以外の「ボタン的なUI・動作」については、親クラスの動作に丸投げしています。

そのままだと、MaterialButtonの標準動作も実行されてしまったので、 `AbsorbPointer` を間にはさみ、JSの `stopPropagation` 的なことを実現しています。

```dart

class BounceAnimation extends StatefulWidget {
  BounceAnimation({
    @required this.onPressed,
    this.onLongPress,
    this.child,
    this.ratio = 0.95,
    this.animationDuration = kThemeChangeDuration,
    this.scaleAlignment = Alignment.center,
  });
```

`BounceAnimation` の定義です。こちらは、StatefulWidgetを継承しています。
他のUIパーツで作ったときに、「小さくなる起点」を変えたいパターンがあったため、`scaleAlignment`をパラメータに追加しています。（別記事で記載する予定です。）

```dart
  final VoidCallback onPressed;
  final VoidCallback onLongPress;
  final Widget child;
  final double ratio;
  final Duration animationDuration;
  final Alignment scaleAlignment;

  bool get enabled => onPressed != null || onLongPress != null;

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

```

`enabled` の定義は、標準ボタンの丸パクリです。
`_BounceAnimationState` で利用しています。（利用者側のコードでも使えるかと思って、privateにはしていません。）

```dart

class _BounceAnimationState extends State<BounceAnimation>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<double> _scale;
  TickerFuture _tickerFuture;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: widget.animationDuration,
    );
    _scale = _controller
        .drive(
          CurveTween(curve: Curves.linear),
        )
        .drive(
          Tween(begin: 1, end: widget.ratio),
        );
  }

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

Stateの定義です。
`SingleTickerProviderStateMixin` を適用することで、縮小・もとに戻るアニメーションを制御しています。

アニメーションカーブは `linear` に固定していますが、他のカーブを使うことで、面白いフィードバックにできると思います。

```dart
  void _forwardAnimation() {
    if (!widget.enabled) {
      return;
    }

    _tickerFuture = _controller.forward();
  }

  void _reverseAnimation() {
    if (!widget.enabled) {
      return;
    }

    // start the reverse animation after the forward animation ends.
    _tickerFuture.whenCompleteOrCancel(() {
      _controller.reverse();
    });
  }

```

タップ動作に合わせて、アニメーションの開始・終了を行っています。
先程の `enabled` を利用して、無効な場合はアニメーションしないようにしています。

`TickerFuture.whenCompleteOrCancel` を利用することで、「小さくなりきってから、もとに戻る」を実現しています。
（これが無いと、tapDown -> tapUpが高速に行われた場合に、フィードバックがほとんど見えませんでした。）

```dart

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => _forwardAnimation(),
      onTapUp: (_) => _reverseAnimation(),
      onTapCancel: _reverseAnimation,
      onTap: widget.onPressed,
      onLongPress: widget.onLongPress,
      child: ScaleTransition(
        scale: _scale,
        alignment: widget.scaleAlignment,
        child: widget.child,
      ),
    );
  }
}
```

`GestureDetector` -> `ScaleTransition` -> `child` という階層構造にすることで、
「タップに反応する」 -> 「子要素の大きさを変える」 -> 「実際に表示したいもの」を表現できました。

`onTapDown` と `onTapUp` でアニメーションの開始・反転を行っていますが、
`onTapCancel` でも、アニメーションの反転をすることで、押したままボタン領域外に出た場合にも大きさが戻るようにしています。

## 終わり

標準のUI部品を参考にしつつ、ある程度は移譲することで、比較的少ないコードでやりたいことが実現できました。

Flutterでは、多くのUI部品が `child` として `Widget` を受け取るので、それに従ってUI部品を作ることで、可用性の高い部品を作ることができました。（今回のボタンも、ボタン内にアイコンを表示したり、装飾したテキストを表示したりできます。）

## 参考

[Flutter — Bouncing button animation - Flutter Community - Medium](https://medium.com/flutter-community/flutter-bouncing-button-animation-ece660e19c91)
[FlutterのTransition系アニメーションWidgetをすべて紹介 - Flutter 🇯🇵 - Medium](https://medium.com/flutter-jp/transition-9c57528c84b8)