SODA Engineering Blog
👆

そのインタラクション、どう作るか?

に公開

GestureDetectorは使ったことありますか?
Flutter使ってれば何度も使ったことあると思います。

しかし、そのほとんどはシングルタップを目的としたonTapのはずです。

この記事ではさまざまなアプリのインタラクションを題材として、onTap以外のメソッドの使い方を見ていきます。
Flutterの知識を少しでも向上してもらうのはもちろん、「こんなインタラクション、UIもあるのか!」と感じてもらい、明日から仕事や個人開発に活かしてもらえたら嬉しいです。

この記事では初級、中級、上級に分けてGestureDetectorを使った色々なインタラクションデザインを見ていきます。

  • 初級はGestureDetectorの使い方をおさらいしながら、"タップ"という一般的な動作を掘り下げます。
  • 中級以上では普段使っているアプリの隠されたインタラクションを例に挙げ、それをFlutterでどう実装するかを見ていきます。
  • 上級ではGestureDetector以外の知っておくと役立つWidgetやメソッドを紹介します。

初級

シングルタップ: GestureDetector.onTap

onTapメソッドはユーザーのシングルタップ(短く一度触れ、指を離した動作)を検知し、処理を実行するためのメソッドです。


シングルタップ

タップと判定されるのは指が触れたときと、話したタイミングのどちらでしょう?
正解は指を離した時です。この違いは似ているようですがユーザー体験を左右します。
指が触れた後、指をボタンの外へ動かすことでタップ処理をキャンセルすることができます。
触れたときにタップ判定されてしまうと、画面をスクロールしようと思って触れた指にボタンがあると誤動作が起こります。


タップのキャンセル

ダブルタップ: GestureDetector.onDoubleTap

次はダブルタップについてです。画像をダブルタップして拡大するときに使われたりします。
ユーザーが素早い速さで2回タップすることでGestureDetector.onDoubleTapで呼ばれます。


iOSの写真アプリ

ここで疑問なのは、onTapとonDoubleTapが両方定義されているとき、どのようにダブルタップを区別しているのでしょうか?
まちがえてonTapが2回呼ばれたりしないのでしょうか?

GestureDetector(
  onTap: () {...},
  onDoubleTap: () {...},
),

もちろんonDoubleTapが正しく呼ばれます。
この理由はFlutter内部のコードを見るとわかります。1回目のタップから300ms以内に2回目のタップが発生したらダブルタップと定義しています。

なので、onTapとonDoubleTapの両方を定義している場合、シングルタップしただけでもダブルタップではないことを確認するため300ms待つので、少し反応が遅かったりします。

/// ダブルタップジェスチャーにおいて、
/// 最初のタップ開始から2回目のタップ開始までの最大許容時間。
//
// Android では、ViewConfiguration のドキュメントによると、
// 実際には「最初のタップの up イベント」から
// 「2回目のタップの down イベント」までの時間を指す。
const Duration kDoubleTapTimeout = Duration(milliseconds: 300);

/// ダブルタップジェスチャーにおいて、
/// 最初のタップが終了してから
/// 2回目のタップが開始されるまでの最小時間。
const Duration kDoubleTapMinTime = Duration(milliseconds: 40);

こちらを日本語訳しています

中級

GestureDetectorの基本に触れたところで次は中級です。中級では普段使っているアプリの実際の動きをFlutterで再現しながらまたGestureDetectorに触れていきます。

Picture-in-Picture(iOS)

まず最初はiOSのPicture-in-Pictureです。この機能は動画の再生中に他のアプリを開いても小さな画面として表示され続けます。
特徴としてはドラッグすることで画面の端っこに移動できます。

これをFlutterで再現するためにはGestureDetectoronPanStartonPanUpdateonPanEndを使います。(まとめてパンジェスチャと呼びます)。
パンジェスチャは指の移動を検知することができます。マップ系アプリやドラッグ&ドロップなどでも使われています。

実装の一連の流れとしては

  • onPanStartでは指が動き始めを検知する。
  • onPanUpdateは指が移動するごとに呼ばれ、移動距離を取得する。
  • onPanEndは指が離れたタイミングで呼ばれ、対象Widgetを(今回であれば画面の中心に)アニメーションさせながら移動します。
GestureDetector(
  onPanStart: (_) {
    // スタート処理
  },
  onPanUpdate: (details) {
    _xController.value = _offset + details.delta.dx;
    _yController.value = _offset + details.delta.dy;
  },
  onPanEnd: (details) {
    // アニメーション処理
  },
),

今回はわかりやすくするためん画面の中心に移動するサンプルを実装しています。

https://github.com/imajoriri/flutter_gakkai_09/blob/9a829ac9a92367e604705e82cd79cbf9f0c5764d/lib/screens/picture_in_picture_screen.dart#L126-L129

Picture-in-Pictureをより自然な動きにする

よりiOSのPicture-in-Pictureの動きに近づけるポイントを見ていきます。
PiPは指の移動速度によって指を離した後のアニメーションが変わってきます。このおかげでより現実世界の物体の動きに近づきます。
Flutterで実現するにはSpringSimulationGestureDetector.onPanEndを見ていきます。

SpringSimulationバネのような動きを再現させます。特徴としてVelocity(初速)を受け取ります。
GestureDetector.onPanEndにもvelocityというプロパティがあり、それを渡すことでPicture-in-Pictureの動きを再現できます。

指を動かさずに離した時と、勢いよく離した時の動きの違いを見比べてみてください。
この違いはCurvesを使っては再現できません。
(以下のサンプルはわかりやすくするために、通常のPiPよりゆっくりにしています。)


1回目が指を動かさずに離す。2回目が勢いよく離す

static const SpringDescription _spring = SpringDescription(
  mass: 1.0,
  stiffness: 420.0,
  damping: 28.0,
);

GestureDetector
  onPanEnd: (details) {
    final velocity = details.velocity.pixelsPerSecond;
    _xController.animateWith(
      SpringSimulation(_spring, _offset.dx, 0.0, velocity.dx),
    );
    _yController.animateWith(
      SpringSimulation(_spring, _offset.dy, 0.0, velocity.dy),
    );
  },
)

より詳細なコードはこちら。

https://github.com/imajoriri/flutter_gakkai_09/blob/9a829ac9a92367e604705e82cd79cbf9f0c5764d/lib/screens/picture_in_picture_screen.dart#L59-L68

2本指スワイプ

iOSのメールアプリは2本指で横にスワイプすることで選択モードに切り替えられます。
GestureDetectorで再現するためにはonScaleStartonScaleUpdateonScaleEndを使います。(スケール系と呼ぶことにします)
スケール系では触れている指の数をpointerCount(int)として取得することができます。これを用いて、指2本で横にスワイプしていることを検知します。

GestureDetector(
  onScaleStart: (details) {
    if (details.pointerCount == 2) {
      // 2本指での動作。
    }
  },
),


正確に実装するのであれば2本の指があった下にあるメールをデフォルトで選択状態にする必要があります

上級

上級ではGestureDetectorとは関係ないWidgetやコンストラクタを紹介していきます。
インタラクション全然関係ないじゃん!ってのもあります。笑

iOSぽい動き: SpringDescription.withDurationAndBounce

先ほど少し紹介したSpringSimulationを使ったバネっぽい動きですが、以前はSpringDescriptionを以下のように値を決めていました。

const springDescription = SpringDescription(
  mass: 0.9,
  stiffness: 190.0,
  damping: 15.0,
);

massはばねの質量、stiffnessはバネ定数、dampingは減衰係数なのですが、ぶっちゃけどんな数字を与えるかは勘で試しながら決めていました。
そこで最近のFlutterアプデでSpringDescription.withDurationAndBounceコンストラクタが追加されました。

これを使えば簡単にiOSぽいバウンスを再現できます。

final SpringDescription spring = SpringDescription.withDurationAndBounce(
  duration: const Duration(milliseconds: 300),
  bounce: 0.3,
);

例えば、iOSのPopoverぽいアニメーションを作ってみました。

https://github.com/flutter/flutter/pull/164411

外側タップを検知するTapRegion

次はRawMenuAnchorなどで使われているTapRegionWidgetについて紹介します。
このWidgetでは自分自身の外側をタップしたことを検知できます。RawMenuAnchorでは外側をタップしたときにメニューを閉じるために使われており、特徴としては外側のタップは他のGestureDetectorやボタンのタップの妨げにならないことです。

例えばTapRegionではなくGestureDetectorなどを画面いっぱいに覆って外側タップを検知しようとすると、その下にあるGestureDetectorやボタンのタップは検知されません。

Squircle: ClipRSuperellipse

1年以上前に書いた記事で、iOSにはあってFlutterでは表現できない角丸がある話を書きました。
簡単にいうと、より柔らかい印象を与えられる角丸です。Squircleと言います。
以前の記事ではPackageを使うことで再現できると記載しましたが、現在ではFlutterが対応してくれています。

https://zenn.dev/team_soda/articles/7ba9aeedaf37b5

ClipRSuperellipse(
  borderRadius: BorderRadius.circular(_radius),
  child: Container(
    width: 150,
    height: 150,
    color: Colors.orange,
    child: Center(
      child: Text(
        'RSuperellipse',
            style: theme.textTheme.titleSmall?.copyWith(color: Colors.white),
      ),
    ),
  ),
)

番外編

ここからは番外編として、自分の好きなジェスチャとか操作性、UIを紹介したいと思います。
ただしFlutterで実装しようとするとそれだけで1つの記事になってしまいそうなので、アプリの紹介だけにさせていただきます。
もし、実装方法知りたいものがあればコメントで教えてください!

TikTokのボトムシートを閉じるジェスチャ

TikTokのコメントのボトムシートは縦スクロールだけではなく、横スワイプでも閉じることができます。
Push遷移のスワイプバックと同じ動作なので馴染みがあり、縦スクロールでは一番上までスクロールする必要がありますが、横スワイプはその必要がありません。

https://x.com/imasirooo/status/2016318009724588501?s=20

長押しで2倍速(TikTok)

もう一つTikTokから。画面の右端の方を長押しすると2倍速再生されます。これはただの長押しなので実装するとしてもシンプルですが、ここから下にプルダウンすると2倍速が固定になります(指を離しても2倍速のまま)

https://x.com/imasirooo/status/2016319062398414875?s=46&t=1cq5AFn8AWdmqIS1lgVt8g

長押しで2倍速(YouTube)

YouTubeも長押しで2倍速にできます。
ここで面白い!と感じたのは、長押ししながらも他の操作はできることです。GestureDetectorで長押しを検知している間は、他の操作を検知することはできません。

https://x.com/imasirooo/status/2016320719941193868

Clearの2段階Pull

Clearというタスク管理アプリです。
このアプリはPull to refreshのようなスワイプジェスチャでタスクを追加できます。
そしてさらに引っ張ると画面を1つ戻ることができます。
引っ張る距離によってアクションが変わる点が面白いと感じました。
こちらGIFでうまく撮れず、Xのツイート動画で失礼します。

https://x.com/imasirooo/status/1898346839285658044?s=46&t=1cq5AFn8AWdmqIS1lgVt8g

マップの拡大・拡小

Zenlyの後継であるBumpは地図の端っこをパンジェスチャで拡大できます。
普通地図は2本指で拡大しますが、このジェスチャによって片手で楽に行えます。

iOS純正のマップアプリはダブルタップからのスワイプで拡大できますね。

おわりに。

長押しとか、スワイプとか...色々工夫すると隠しコマンド的なものを作れて面白いですよね?!
隠しコマンドは初見だと気づきづらいかもしれません。特にそのアプリを使い慣れた「中級者」向けの設計になってきますが、隠しコマンドに気づいてくれるとより熱狂的なユーザーになってくれるのではないでしょうか。

この記事を最後まで読んでいただきありがとうございます!

2026年も学んだことを惜しみなくアウトプットしていくつもりなので、この記事へのいいねで応援してもらえると嬉しいです!

SODA Engineering Blog
SODA Engineering Blog

Discussion