Flutter便利パッケージ fab_circular_menu 〜おしゃれ円形メニュー表示FAB〜

# 使ってみるパッケージ
[fab_circular_menu | Flutter Package](https://pub.dev/packages/fab_circular_menu)

紹介されているgifのように、1つのボタンを円状に展開して、複数の選択肢を表示することができる。

<img src="https://i.imgur.com/ErrNnAw.gif" />

# 使い方
## インストール
通常のパッケージなどと同じように、`pubspec.yaml`を書き換えて、`flutter pub get`をするだけ。

```yaml:pubspec.yaml
dependencies:
  fab_circular_menu: ^1.0.0
```

## 使用例
`Scaffold`widgetの`floatingActionButton`に、`FabCircularMenu`widgetを指定するだけ。

```dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Placeholder(),
        floatingActionButton: FabCircularMenu(
          children: <Widget>[
            IconButton(icon: Icon(Icons.home), onPressed: () {
              print('Home');
            }),
            IconButton(icon: Icon(Icons.favorite), onPressed: () {
              print('Favorite');
            })
          ]
        )
      )
    );
  }
}
```
### カスタマイズオプション
`fabOpenColor`は**輪が開いているとき**の色の設定で、`fabOpenIcon`は**輪っかを開く**アイコンの設定となっているので注意(`fabCloseColor`と`fabCloseIcon`も同様)。
輪を開く前と後で、ボタンの色を同じにする場合は、ややこしいので`fabOpenColor`,`fabCloseColor`は使わずに`fabColor`で両方とも同じ色に設定することができる。

| Property | 説明 | デフォルト値 |
|:--|:--|:--|
| alignment | FloatingActionButtonの位置 | Alignment.bottomRight |
| ringColor | 表示される輪っかの色 | accentColor |
| ringDiameter | 表示される輪の直径 | screenWidth * 1.25 (portrait), screenHeight * 1.25 (landscape |
| ringWidth |表示される輪の幅| ringDiameter * 0.3 |
| fabSize | FABのサイズ | 64.0 |
| fabElevation | FABのエレベーション（浮き出し具合） | 8.0 |
| fabColor | FAB自体の色。輪っかが表示される前と後のボタン両方に適用される。アイコンの色ではない。輪っか表示前後で色を変えたい場合は、以下の`fabOpenColor`と`fabCloseColor`を使う。 | primaryColor |
| fabOpenColor | **輪っかが表示されている時**のボタンの色。(fabColorより優先される) | - |
| fabCloseColor | **輪っかが閉じているとき**のボタンの色。(fabColorより優先される) | - |
| fabOpenIcon | **輪っかを開く**ボタンのアイコン。(色を指定するときのopen/closeと意味が逆になっている？) | Icon(Icons.menu) |
| fabCloseIcon | **輪っかを閉じるとき**のボタンのアイコン | Icon(Icons.close) |
| fabMargin | FABボタンのマージン | EdgeInsets.all(16.0) |
| animationDuration | 輪っかが広がるアニメーションのDuration | Duration(milliseconds: 800) |
| animationCurve | アニメーションカーブの設定。(正直そんなに変わらない気がする。 [Curves class - animation library - Dart API](https://api.flutter.dev/flutter/animation/Curves-class.html)) | Curves.easeInOutCirc |
| onDisplayChange | 輪っかが開いたり閉じたりするたびに呼ぶコールバック。開閉どちらのタイミングでも実行される。 | - |

## ボタンをプログラム的に開閉する
`FabCircularMenu`に`key`を付与して、`keyName.currentState.close();`などでプログラム的に開閉することが可能。

```dart
class MyApp extends StatelessWidget {
  final GlobalKey<FabCircularMenuState> fabKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: RaisedButton(
          onPressed: () {
            if (fabKey.currentState.isOpen) {
              fabKey.currentState.close();
            } else {
              fabKey.currentState.open();
            }
          },
          child: Text('Toggle menu')
        ),
        floatingActionButton: FabCircularMenu(
          key: fabKey,
          children: <Widget>[
            // ...
          ]
        )
      )
    );
  }
}
```

## おまけ
### 輪っかの色を透過させる
`ringColor`に`Colors.white.withAlpha(100)`のように指定する。`Colors.white.withOpacity(0.5)`でもOK。
`withAlpha`は`0~255`の値をとり、255のとき透過なしになり、`withOpacity`は`0.0~1.0`の値をとり、1のとき透過なしになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/8b53acad-aadc-dc5f-1ad2-3928ea7480a5.png)

```dart
  floatingActionButton: FabCircularMenu(
    key: fabKey,
    alignment: Alignment.bottomCenter,     // FABを中央に表示
    ringColor: Colors.cyan.withAlpha(200), // アルファ値を200に設定
    fabColor: Colors.white,                // FABの背景色を白に設定
    fabOpenIcon: Icon(Icons.menu, color: primaryColor),
    fabCloseIcon: Icon(Icons.close, color: Colors.red),
```

### メニュー以外の部分をタップした時にもメニューを閉じるようにする
個人的に、メニュー(輪っか)部分以外の部分をタップした際に輪っかが閉じない点が直感的に扱い辛いと感じたため、輪っか以外の部分をタップした時にメニューを閉じる方法を調査し、以下のように実装した。
（力技なので、他のWidgetなどの動作に影響があるかもしれません。他に何か良い方法がありましたらご教示いただけると幸いです。)

1. まず、メニュー表示時にFAB以外の領域を非活性化する => `AbsorbPointer`
2. 次に、FAB以外の領域をタップした時にイベントを発火させ、メニューを閉じる => `GestureDetector`

また、メニューが開いているか・閉じているかといった状態を保持する必要があるので`StatefulWidget`を使って、以下のようなViewを作る。
メニューの開閉状態の変数`_isOpen`は、`onDisplayChange`で値を変えている。

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey<FabCircularMenuState> fabKey = GlobalKey();

  bool _isOpen = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: ListView.builder(
            itemCount: 20,
            itemBuilder: (BuildContext context, int index) {
              return RaisedButton(
                key: Key('button_$index'),
                child: Text('Button $index'),
                onPressed: () => _showDialog(context, index),
              );
            }),
        // FloatingActionButtonは親Widgetのcontextを継承しないため、
        // Builderを使うことで意図的にcontextを継承させる
        // contextを継承しないと、Dialogなどの表示ができない
        floatingActionButton: Builder(
          builder: (BuildContext context) => FabCircularMenu(
            key: fabKey,
            onDisplayChange: (bool isOpen) {
              setState(() {
                _isOpen = isOpen;
              });
            },
            animationDuration: Duration(milliseconds: 200),
            ringColor: Colors.cyan.withAlpha(200),
            fabColor: Colors.white,
            children: <Widget>[
              RawMaterialButton(
                onPressed: () => _showDialog(context, 111),
                shape: CircleBorder(),
                padding: const EdgeInsets.all(24.0),
                child: Icon(Icons.looks_one, color: Colors.white),
              ),
              RawMaterialButton(
                onPressed: () => _showDialog(context, 222),
                shape: CircleBorder(),
                padding: const EdgeInsets.all(24.0),
                child: Icon(Icons.looks_two, color: Colors.white),
              ),
              RawMaterialButton(
                onPressed: () => _showDialog(context, 333),
                shape: CircleBorder(),
                padding: const EdgeInsets.all(24.0),
                child: Icon(Icons.looks_3, color: Colors.white),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future<void> _showDialog(BuildContext context, int index) async {
    await showDialog(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('Button $index is tapped'),
          );
        });
  }

  void _closeMenu() {
    if (fabKey.currentState.isOpen) {
      fabKey.currentState.close();
    }
  }
}
```
この状態では、メニューが開いている状態でもButtonをタップすることができ、Buttonをタップするとダイアログが表示される。メニューは、右下のFABを押すまで閉じない。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/28d9acd3-ac67-a2cd-ad9e-9de693f19279.png)

#### メニュー表示時にFAB以外の領域を非活性化する
タップなどのポインタイベントを非活性化するには`AbsorbPointer`widgetを使う。(`absorb`は`吸収する`という意味)
  => [AbsorbPointer class - widgets library - Dart API](https://api.flutter.dev/flutter/widgets/AbsorbPointer-class.html)

>`absorbing`が`true`の時、このWidgetはそれ自身のhit testingを終了させる(?)ことで、サブツリーがポインタイベントを受信するのを防ぎます。通常どおり、レイアウトのスペースを使い、子を描画します。子がイベントのターゲットになるのを防ぐだけで、`RenderBox.hitTest`は`true`を返します。

類似したWidgetとして`IgnorePointer`があるが、こちらは`RenderBox.hitTest`が`false`を返すようになる模様。
あまり理解していないが、`AbsorbPointer`はタップが無効化されるだけで、`IgnorePointer`は指定したWidgetがタップすらできなくなる感じ？(感覚的には、`AbsorbPointer`がボタンでいう`onPressed: (){}`で、`IgnorePointer`が`onPressed: null`という感じ？)
**非活性部分をタップした時**にメニューを閉じたいので、今回は`AbsorbPointer`を使う。

`Scaffold`の`body`である`ListView`を`AbsorbPointer`でラップし、`absorbing`プロパティにはメニューの開閉状態を表す`_isOpen`を指定する。

```dart
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: AbsorbPointer(  // <- 追加
          absorbing: _isOpen, // <- 追加
          child: ListView.builder(
              itemCount: 20,
              itemBuilder: (BuildContext context, int index) {
                return RaisedButton(
                  key: Key('button_$index'),
                  child: Text('Button $index'),
                  onPressed: () => _showDialog(context, index),
                );
              }),
        ),
```
これで、メニュー表示時(`isOpen`が`true`の時)に`Button N`を押すことはできなくなった。

#### FAB以外の領域をタップした時にイベントを発火させる
次に、`Scaffold`の`body`をタップした時にイベントを発火させるために、`body`全体を`GestureDetector`で囲む。
さらに、`onTap`プロパティにメニューを閉じる処理を書くことで、完成。

```dart
 @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: GestureDetector( // <- 追加
          onTap: _closeMenu,   // <- 追加
          child: AbsorbPointer(
            absorbing: _isOpen,
            child: ListView.builder(
                itemCount: 20,
  ...

  // 追加
  void _closeMenu() {
    if (fabKey.currentState.isOpen) {
      fabKey.currentState.close();
    }
  }
```

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Flutterのおしゃれパッケージ<a href="https://t.co/VDbMuUkyLZ">https://t.co/VDbMuUkyLZ</a> <a href="https://t.co/ZpgXMX1qjU">pic.twitter.com/ZpgXMX1qjU</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1276767857917874177?ref_src=twsrc%5Etfw">June 27, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

#### 注意点
`onDisplayChange`は**メニューが完全に開ききった(閉じきった)時に発火される**らしいので、メニューが開ききる前は`AbsorbPointer`の子widgetのイベントは非活性にならない。
`animationDuration`の値を調整することで、メニューが開ききるまでの時間を調整できるので、アニメーションの見た目がおかしくない範囲で小さく設定した方が良いかもしれない。

# 所感
* スタイリッシュなのでどこかで使いたいが、こんなにたくさん機能を持ったアプリを作る機会があるのだろうか。。。