[Flutter] CupertinoContextMenuとGestureDetectorなどのonTapイベントとを併用したい場合の対処方法

# 本記事のまとめ
* Flutter標準のCupertino Widget `CupertinoContextMenu`がちょっと使いづらかったから改造したよ。
* `CupertinoContextMenu`で`onTapDown`で動かしている処理を`onLongPress`でやるように変えたよ。
* `CupertinoContextMenu`の`child`に設定したWidgetのタップイベントがそのまま伝播して変な挙動になることがあるので、`AbsorbPointer`Widgetでタップイベントを無効化できるようにしたよ。

ソースコード: https://github.com/popy1017/flutter_custom_cupertino_context_menu_sample

# CupertinoContextMenuとは
`CupertinoContextMenu`は、iOSの標準UIを実装した`Cupertino`系のWidgetであり、以下のようにWidgetが長押しされたときに表示されるフルスクリーンのモーダルである。（左がiOS,右がFlutter）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/a128115e-5ea2-4fc0-f154-5fdb8070d40b.gif" /> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/6c258af3-0b04-0d21-3435-28ca63a2ac02.gif" />
[CupertinoContextMenu class - cupertino library - Dart API](https://api.flutter.dev/flutter/cupertino/CupertinoContextMenu-class.html)

少ないコードでリッチなアニメーションを含むUIを構築できるのでとても便利なのだが、実際に使ってみたところ、以下のような問題があることがわかった。

# 問題点
以下のように、`CupertinoContextMenu`の`child`で`onTap`を使いたい場合、`CupertinoContextMenu`の表示イベントと子`Widget`の`onTap`イベントが両方発生してしまうことがあることがわかった。(良い感じの長さでタップすると結構容易に再現可能)

![問題.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/03b81617-3098-a3a2-3d6e-e370449e3b3a.gif)

```dart:main.dart
CupertinoContextMenu(
  actions: _actions,
  child: TappablePhoto(
    onTap: () => _moveToNewPage(context),
  ),
),
```

# 対処方法
`CupertinoContextMenu`のソースコードを見てみると、`長押し`とは言っているが実際には`onTapDown`でアニメーションが開始していることがわかった。さらに見ていくと、アニメーションの完了時にメニューを表示するようになっていることもわかった。

```dart:context_menu.dart
  void _onTapDown(TapDownDetails details) {

    ~~

    Overlay.of(context).insert(_lastOverlayEntry);
    _openController.forward();  // <= アニメーションの開始
  }
```

ソースコードや、`onTap`と`onLongPress`が同時に起きないことを参考に、②の区間でタップを離すとこのような現象が起こると推測。

```
onTapDown(アニメーション開始)
↓
|<---------onTap判定範囲---------->|<------onLongPress判定範囲---->|
|<---Animationの長さ--->|
|<---------①--------->|<---②--->|<--------------③------------>|
|-------------------------------------------------------------->時間
```
以上のため、`onTapDown`ではなく`onLongPress`時にアニメーションを再生するように変更。

```dart:custom_cupertino_context_menu.dart
//void _TapDown(TapDownDetails details) {
  void _onLongPress() {
    setState(() {
      _childHidden = true;
    });

    ~~    

    Overlay.of(context).insert(_lastOverlayEntry);
    _openController.forward();
  }
  
  ~~
  
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapCancel: _onTapCancel,
      //onTapDown: _onTapDown,
      onTapUp: _onTapUp,
      onTap: _onTap,
      onLongPress: _onLongPress,
      child: TickerMode(
        enabled: !_childHidden,
        child: Opacity(
          key: _childGlobalKey,
          opacity: _childHidden ? 0.0 : 1.0,
          child: widget.child,
        ),
      ),
    );
  }
```
また、上記対応後も以下のような問題があることが判明。

1. `LongPress`後にアニメーションが再生しアニメーションが終わったらContextMenuが表示されるため、アニメーションの再生中に元のWidgetをタップすると`onTap`の処理とContextMenuの表示の両方が行われてしまう。
2. ContextMenuの上部に表示されるWidgetは元のWidgetとほぼ同じ(大きさが違うだけ?)なので、ContextMenuに表示されるWidgetも`onTap`イベントを持ってしまっている。(場合によっては問題ではない)

結論から言うと、1,2の問題に対してはタップイベントを無効化できる`AbsorbPointer`Widgetを使うことで解決できる。

1つ目の問題に関しては、`CupertinoContextMenu`では、`onTapDown`時に`CupertinoContextMenu`の子Widgetと同じWidget(`_DecoyChild`と呼ばれている)を子Widgetの上に重ねてアニメーションを再生しているため、`_DecoyChild`のタップイベントを無効化すれば解決可能である。
2つ目の問題に関しては、引数で渡した子Widgetをアニメーション可能にしたWidgetを表示しているだけなので、これも`AbsorbPointer`Widgetでタップイベント無効化すれば解決可能である。

以下のように、`_DecoyChild`や`ContextMenuRoute`の該当するWidgetを`AbsorbingPointer`でラップして、`absorbing`を`true`に設定する。
(Githubのソースコードでは、`onLongPress`や`absorbing`を選択できるようにした。)

```dart:custom_cupertino_context_menu.dart
class _DecoyChildState extends State<_DecoyChild>
    with TickerProviderStateMixin {
  
  ~~

  Widget _buildAnimation(BuildContext context, Widget child) {
    final Color color = widget.controller.status == AnimationStatus.reverse
        ? _masklessColor
        : _mask.value;
    return Positioned.fromRect(
      rect: _rect.value,
      // TODO(justinmc): When ShaderMask is supported on web, remove this
      // conditional and use ShaderMask everywhere.
      // https://github.com/flutter/flutter/issues/52967.
      child: kIsWeb
          ? Container(key: _childGlobalKey, child: widget.child)
          : ShaderMask(
              key: _childGlobalKey,
              shaderCallback: (Rect bounds) {
                return LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: <Color>[color, color],
                ).createShader(bounds);
              },
              /********************************************************/
              child: AbsorbPointer(absorbing: true, child: widget.child),
              /********************************************************/             
            ),
    );
  }

  ~~
}
~~
class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic>
    with TickerProviderStateMixin {

  ~~

  // The order and alignment of the _ContextMenuSheet and the child depend on
  // both the orientation of the screen as well as the position on the screen of
  // the original child.
  List<Widget> _getChildren(
      Orientation orientation, _ContextMenuLocation contextMenuLocation) {
    final Expanded child = Expanded(
      child: Align(
        alignment: _getChildAlignment(
          widget.orientation,
          widget.contextMenuLocation,
        ),
        child: AnimatedBuilder(
          animation: _moveController,
          builder: _buildChildAnimation,
          /*********************************************************/
          child: AbsorbPointer(absorbing: true, child: widget.child),
          /*********************************************************/
        ),
      ),
    );
    ~~
  }
  ~~
}
```