Flutter GestureDetectorの使い方とタッチイベント検出まとめ

# 1. はじめに

タッチ/ボタン入力検出用途で利用する、[GestureDetector](https://api.flutter.dev/flutter/widgets/GestureDetector-class.html)の使い方についてのまとめ記事です。

## サポート済みの入力デバイス

`GestureDetector`は、PrimaryとSecondaryの2つのボタン入力をサポートしています。基本的にはPrimaryのタッチ入力のみの利用になると思います。デスクトップ環境であれば、マウスボタンでも反応します (現状、Secondaryはどこにも接続されていない？)。

Flutter EngineのAPI経由で各種プラットフォームの入力デバイスI/Fと接続することで、Flutter FrameworkのDart側で入力を検出する仕組みです。

```
kPrimaryStylusButton: The stylus contacts the screen.
kSecondaryMouseButton: The primary mouse button.
```

# 2. 基本的な使い方

タッチを検出したいWidgetの親WidgetにGestureDetectorを利用します。タッチ検出には`onTap`プロパティを利用します。

```Dart:
GestureDetector(
  onTap: () {
    // シングルタップ時に呼ばれる
  },
  // タッチ検出対象のWidget
  child: Text(
    'How to use GestureDetector',
    textAlign: TextAlign.center,
    overflow: TextOverflow.ellipsis,
    style: TextStyle(fontWeight: FontWeight.bold),
  ),
)
```

## behaviorプロパティについて

GestureDetectorの[behavior](https://api.flutter.dev/flutter/rendering/HitTestBehavior-class.html)プロパティは意外と重要です。 

デフォルトはHitTestBehavior.deferToChildですが、このプロパティに設定するenum値に連動してGesture検出時の動きが変わります。子WidgetのGesture検出範囲に応じて適切に設定しましょう。

```Dart:
GestureDetector(
  behavior: HitTestBehavior.opaque, // こんな感じで指定
```

|HitTestBehavior enum選択肢|内容|
|---|---|
|deferToChild|子Widgetの有効範囲のみ反応するようにする|
|opaque|Padding領域も含めてタッチに反応するようにする|
|translucent|背景色が透明の場合、そのWidgetの背景部分をタッチしても反応するようにする|

# 3. 検出可能なイベント一覧とコールバック

検出可能なイベントとプロパティ名を以下にまとめています。
コールされる順番については、後述のユースケースの部分を参照して下さい。

## シングルタップ

スクリーン/ボタンの一回押し操作のイベントを検出する時に利用します。
タップ操作時の途中経過を知る必要がなければ、通常は`onTap`のみの利用になると思います。

```Dart:
GestureDetector(
  ...

  // シングルタップ開始時にコール
  // void Function(TapDownDetails) onTapDown
  onTapDown: (details) => print('onTapDown()'),

  // シングルタップ開始後、そのままタッチを動かすなどしてシングルタップがキャンセルされる時にコール
  // void Function() onTapCancel
  onTapCancel: () => print('onTapCancel()'),

  // タップが離れる時にコール
  // void Function(TapUpDetails) onTapUp
  onTapUp: (details) => print('onTapUp()'),

  // シングルタップ確定時にコール
  // void Function() onTap
  onTap: () => print('onTap()'),
)
```

#### TapUpDetailsのメンバ変数

実際に利用しそうなメンバ変数のみ紹介しておきます。`globalPosition`は公式サイトの説明ではスクリーン座標と書いてあり、スマホ本体のスクリーンの絶対座標かと思ってしまいますが、そうではなくてアプリ内画面の絶対座標です。Androidスマホであれば、アプリを画面分割して起動させて動作確認してみれば分かるかと思います。

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|

#### TapDownDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|kind|入力デバイス種類|

[PointerDeviceKind](https://api.flutter.dev/flutter/dart-ui/PointerDeviceKind-class.html)クラスの`kind`は、入力デバイスの種類を示しており、`mouse`や`touch`のようなenum値が格納されています。入力デバイスを見分けたい場合には便利かもしれません。

## タブルタップ

素早くタッチ2回操作ですね。
これが反応する時は、onTap系のコールバックは当然コールされません。

```Dart:
GestureDetector(
  ...

  // ダブルタップ確定時にコール
  // void Function() onDoubleTap
  onDoubleTap: () => print('onDoubleTap()'),
)
```

## ロングタップ

タッチ後、そのまましばらく静止する操作です。その状態でタッチ位置を移動するとそれも検出可能です。
onLongPressEndとonLongPressUpの厳密な違いは何でしょう？　（ソースコード読むか。。）

```Dart:
GestureDetector(
  ...

  // ロングプレス検出処理開始時にコール
  // void Function(LongPressStartDetails) onLongPressStart
  onLongPressStart: (details) => print('onLongPressStart()'),

  // ロングプレス確定時にコール
  // void Function() onLongPress
  onLongPress: () => print('onLongPress()'),

  // ロングプレス状態での移動で位置が変化した時にコール
  // void Function(LongPressMoveUpdateDetails) onLongPressMoveUpdate
  onLongPressMoveUpdate: (details) => print('onLongPressMoveUpdate()'),

  // ロングタップ状態が解除された (実質、タッチが離れた) 時にコール。onLongPressUpより先にコール
  // void Function(LongPressEndDetails) onLongPressEnd
  onLongPressEnd: (details) => print('onLongPressEnd()'),

  // ロングタップ状態が解除されて、タッチが離れた時にコール
  // void Function() onLongPressUp
  onLongPressUp: () => print('onLongPressUp()'),
)
```

#### LongPressStartDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|

#### LongPressEndDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|velocity|画面からタッチが離れる速度|

#### LongPressMoveUpdateDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|offsetFromOrigin|最初のロングタップ検出位置からの相対座標|
|localOffsetFromOrigin||

## 3Dタップ

iOS専用の機能です。
それ以外のプラットフォームでは利用しません (何も反応しません)。

```Dart:
GestureDetector(
  ...

  // 3Dタップ開始時にコール
  // void Function(ForcePressDetails) onForcePressStart
  onForcePressStart: (details) => print('onForcePressStart()'),

  // 押し込みの強さが変化した時にコール
  // void Function(ForcePressDetails) onForcePressUpdate
  onForcePressUpdate: (details) => print('onForcePressUpdate()'),

  // 押し込みの強さが最大時にコール
  // void Function(ForcePressDetails) onForcePressPeak
  onForcePressPeak: (details) => print('onForcePressPeak()'),

  // 3Dタップ終了時にコール
  // void Function(ForcePressDetails) onForcePressEnd
  onForcePressEnd: (details) => print('onForcePressEnd()'),
)
```

#### ForcePressDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|pressure|押し込みの強さ|

## スケール

2本指でのピンチ (イン/アウト/回転) 操作用です。ただし、1タッチ操作でも反応します。

また、後述のジェスチャーの所に記載している`onHorizontalDrag*/onVerticalDrag*/onPan*`系のプロパティと<font color="Red">同時に利用するとエラーになる</font>ため、注意して下さい。

```Dart:
GestureDetector(
  ...

  // スケール操作開始時にコール
  // void Function(ScaleStartDetails) onScaleStart
  onScaleStart: (details) => print('onScaleStart'),

  // スケール操作によるズーム倍率や回転率の変更時にコール
  // void Function(ScaleUpdateDetails) onScaleUpdate
  onScaleUpdate: (details) => print('onScaleUpdate'),

  // スケール操作終了時にコール
  // void Function(ScaleEndDetails) onScaleEnd
  onScaleEnd: (details) => print('onScaleEnd'),
)
```

#### ScaleStartDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|focalPoint|アプリ画面内の絶対座標|
|localFocalPoint|Widget内の相対座標|

#### ScaleUpdateDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|focalPoint|アプリ画面内の絶対座標|
|localFocalPoint|Widget内の相対座標|
|rotation|開始時点からの回転角度|
|scale|開始時点からの倍率|
|verticalScale|開始時点からの垂直方向の倍率|

#### ScaleEndDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|velocity|画面からタッチが離れる速度(スワイプ操作の強さ検出に使えます)|

## ジェスチャー (水平方向)

水平方向のジェスチャー検出やドラッグ操作検出用です。

```Dart:
GestureDetector(
  ...

  // 初回タップ時にコール
  // void Function(DragDownDetails) onHorizontalDragDown
  onHorizontalDragDown: (details) => print('onHorizontalDragDown()'),

  // 初回タップ後にタップが離れたり、水平方向に移動した場合にコール
  // void Function() onHorizontalDragCancel
  onHorizontalDragCancel: () => print('onHorizontalDragCancel()'),

  // onHorizontalDragDownコール後の初回水平方向に移動時にコール
  // void Function() onHorizontalDragCancel
  onHorizontalDragStart: (details) => print('onHorizontalDragStart()'),

  // 水平方向に移動した時にコール
  // void Function() onHorizontalDragCancel
  onHorizontalDragUpdate: (details) => print('onHorizontalDragUpdate()'),

  // タッチを離すなど、水平方向の移動終了時にコール
  // void Function(DragEndDetails) onHorizontalDragEnd
  onHorizontalDragEnd: (details) => print('onHorizontalDragEnd()'),
)
```

#### DragDownDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|

#### DragStartDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|sourceTimeStamp|タイムスタンプ|

#### DragUpdateDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|primaryDelta||
|delta||

## ジェスチャー (垂直方向)

onHorizontalDrag*同様、`onVerticalDragDown/onVerticalDragCancel/onVerticalDragStart/onVerticalDragUpdate/onVerticalDragEnd`が用意されています。

## ドラッグ

前述の水平垂直お構いなしにドラッグ操作で位置を検出したい場合に利用します。

```Dart:
GestureDetector(
  ...

  // 初回タップ時にコール
  // void Function(DragDownDetails) onPanDown
  onPanDown: (details) => print('onPanDown()'),

  // ドラッグ操作がキャンセルされた時にコール
  // void Function() onPanCancel
  onPanCancel: () => print('onPanCancel()'),

  // ドラッグ操作が開始された時にコール
  // void Function(DragStartDetails) onPanStart
  onPanStart: (details) => print('onPanStart()'),

  // ドラッグ操作で位置が変化した時にコール
  // void Function(DragUpdateDetails) onPanUpdate
  onPanUpdate: (details) => print('onPanUpdate()'),

  // ドラッグ操作が終了した時にコール
  // void Function(DragEndDetails) onPanEnd
  onPanEnd: (details) => print('onPanEnd()'),
```

#### DragDownDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|

#### DragStartDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|sourceTimeStamp|タイムスタンプ|

#### DragUpdateDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|globalPosition|アプリ画面内の絶対座標|
|localPosition|Widget内の相対座標|
|sourceTimeStamp|タイムスタンプ|
|delta|前回からの変化量|
|primaryDelta|画面からタッチが離れる速度(スワイプ操作の強さ検出に使えます)|

#### DragEndDetailsのメンバ変数

|メンバ変数|内容|
|---|---|
|primaryVelocity||
|velocity||

## セカンダリーボタン

Android/iOSの通常操作ではまず使う (反応する) ことがないと思います。
無視しましょう。

```Dart:
GestureDetector(
  ...

  // void Function(TapDownDetails) onSecondaryTapDown
  onSecondaryTapDown: (details) => print('onSecondaryTapDown()'),

  // void Function(TapUpDetails) onSecondaryTapUp
  onSecondaryTapUp: (details) => print('onSecondaryTapUp()'),

  // void Function() onSecondaryTapCancel
  onSecondaryTapCancel: () => print('onSecondaryTapCancel'),
)
```

# 4. ユースケース毎のコール順序

以下のユースケース毎に各コールバックイベントがどのように発生するのか、その確認結果を掲載しておきます。

## シングルタップ操作時

### onTap系

```
onTapDown()
onTapUp()
onTap()

# タッチ後、指をそのまま異動
onTapDown()
onTapCancel()
```

### ドラッグ(onPan)系

```
onPanDown()
onPanCancel()
```

## スケール操作時

```
onScaleStart()
onScaleUpdate()
onScaleEnd()
```

## ピンチ操作時

```
onPanDown()
onPanStart()
onPanUpdate()
onPanEnd()
```

## ダブルタップ操作時

```
onDoubleTap()
```

## ロングプレス操作時

```
# 最初はシングルタップから反応
onTapDown()
onTapCancel()

# そのあとでロングタップ判定に移行
onLongPressStart()
onLongPress()

# そのままタッチを動かす
onLongPressMoveUpdate()

# ここでタッチを離す
onLongPressEnd()
onLongPressUp()
```