【Unity】スマホ対応の第一歩:タッチ・スワイプ・ピンチ操作とSafe Area

作成: 2026-07-16

PCで動いたゲームをスマホへ持っていくと、操作とUIの問題が一気に自分ごとになります。New Input SystemのEnhanced Touchによるタップ・ドラッグ・長押しの判定、2本指のピンチとパン、UI上のタッチをゲーム操作へ通さない定番ガード、そしてScreen.safeAreaでノッチやパンチホールを避けるUI配置まで、モバイル対応の入口をまとめて解説します。

PCでは快適に遊べていたゲームを、いざAndroidやiPhoneで動かしてみると——ピンチでズームできない、ボタンを押したつもりがキャラも一緒に動く、スコア表示がノッチの裏に隠れて読めない。 スマホ対応の問題は、実機に入れた瞬間、全部まとめて自分ごとになります

この記事では、New Input SystemEnhanced Touch を軸に、タップ・ドラッグ・長押し・ピンチというモバイルの基本操作と、UIとの競合を防ぐガード、そして Safe Area 対応までを一気に整えます。最後は「1本指でパン、2本指でピンチズームできる見下ろしマップ」を完成させます。

スマホ画面をタッチ操作するイメージ

この記事でわかること

  • マウス互換だけでは足りない理由と Enhanced Touch の有効化
  • タップ・ドラッグ・長押し をしきい値で判定する定番ロジック
  • 2本指の ピンチ(ズーム)とパン の計算
  • UI上のタッチをゲーム操作へ通さない ガード(IsPointerOverGameObject
  • Screen.safeArea でノッチ・パンチホールを避けるUI配置
  • 実践:ピンチズームできる見下ろしマップを完成させる

動作確認環境: Unity 2022.3 LTS / Unity 6・Input System 1.7+

Sponsored

マウス互換だけでは足りない理由

Unityはタッチをある程度マウスとして扱ってくれるので、「タップ=クリック」だけのゲームなら、PC向けのコードがそのまま動くこともあります。それでもタッチ専用の書き方が必要になるのは、タッチとマウスが根本的に違うからです。

  • 指は複数ある: マウスカーソルは常に1つですが、指は2本、3本と同時に画面へ触れます。ピンチズームはその代表です
  • 「触れていない時の位置」が無い: マウスは動かすだけで位置が取れますが、指は触れた瞬間に突然現れ、離れた瞬間に消えます。ホバー(マウスオーバー)も存在しません
  • 1本の指には一生がある: 触れる(began)→ 動く(moved)→ 離れる(ended)という 状態の流れ を追いかけて、初めてドラッグや長押しを区別できます

この「複数の指を、IDと状態付きで追いかける」ためのAPIが、New Input Systemの Enhanced Touch です。

Enhanced Touchを有効にする

Enhanced Touchは省電力のため標準では眠っています。使う前に1回だけ有効化してください。

using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
// Touch型の名前が衝突しないよう、別名を付けておくと安全
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

public class TouchBootstrap : MonoBehaviour
{
    void OnEnable()
    {
        EnhancedTouchSupport.Enable();
    }

    void OnDisable()
    {
        EnhancedTouchSupport.Disable();
    }
}

有効化すると、 Touch.activeTouches で「いま画面に触れているすべての指」が読めるようになります。

void Update()
{
    foreach (Touch touch in Touch.activeTouches)
    {
        // 指ごとに、ID・位置・状態が取れる
        Debug.Log($"指{touch.touchId}: {touch.screenPosition} ({touch.phase})");
    }
}

touch.phase が指の一生を表します。Began(触れた瞬間)→ Moved / Stationary(動いた・止まっている)→ Ended / Canceled(離れた・中断)。以降のジェスチャー判定は、ぜんぶこの流れの読み取りです。

タップ・ドラッグ・長押しを判定する

1本指の基本ジェスチャーは、 「どれだけ動いたか」と「どれだけ長く触れていたか」の2つのしきい値 で区別できます。

1本指ジェスチャーの判定図。タップは短時間・ほぼ動かない、ドラッグは距離しきい値を超えて動く、長押しは動か��ないまま時間しきい値を超える、という判定の分かれ方を示す
  • タップ: ほぼ動かないまま、短時間で離れた
  • ドラッグ: 距離のしきい値を超えて動いた(動いている間じゅう継続)
  • 長押し: ほぼ動かないまま、時間のしきい値を超えた
using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

public class GestureDetector : MonoBehaviour
{
    [SerializeField] private float dragThreshold = 20f;   // これ以上動いたらドラッグ(ピクセル)
    [SerializeField] private float holdTime = 0.5f;       // これ以上触れていたら長押し(秒)

    private Vector2 startPosition;
    private float startTime;
    private bool isDragging;
    private bool holdFired;

    void Update()
    {
        if (Touch.activeTouches.Count != 1) return;
        Touch touch = Touch.activeTouches[0];

        switch (touch.phase)
        {
            case UnityEngine.InputSystem.TouchPhase.Began:
                startPosition = touch.screenPosition;
                startTime = Time.time;
                isDragging = false;
                holdFired = false;
                break;

            case UnityEngine.InputSystem.TouchPhase.Moved:
            case UnityEngine.InputSystem.TouchPhase.Stationary:
                // 距離しきい値を超えたら、以降はドラッグ扱い
                if (!isDragging && Vector2.Distance(touch.screenPosition, startPosition) > dragThreshold)
                {
                    isDragging = true;
                }

                // 動かないまま時間しきい値を超えたら長押し(1回だけ発火)
                if (!isDragging && !holdFired && Time.time - startTime > holdTime)
                {
                    holdFired = true;
                    Debug.Log("長押し!");
                }

                if (isDragging)
                {
                    Vector2 delta = touch.delta;
                    Debug.Log($"ドラッグ中: {delta}");
                }
                break;

            case UnityEngine.InputSystem.TouchPhase.Ended:
                // 動かず・長押しにもならず離れた=タップ
                if (!isDragging && !holdFired)
                {
                    Debug.Log("タップ!");
                }
                break;
        }
    }
}

しきい値の値はゲーム次第ですが、 ドラッグ判定は20ピクセル前後・長押しは0.5秒前後 が体感の出発点として無難です。「タップのつもりが少し指が滑る」のは普通のことなので、距離0でタップ判定しないのがコツです。

Sponsored

2本指:ピンチとパン

指が2本になったら、ジェスチャーは 2点の距離と中点 で考えます。

2本指ジェスチャーの図。ピンチは2本の指の距離の変化(広げる=ズームイン、縮める=ズームアウト)、パンは2本の指の中点の移動として計算することを示す
  • ピンチ(ズーム): 2本の指の 距離の変化。広がれば拡大、縮まれば縮小
  • パン(平行移動): 2本の指の 中点の移動
private float previousDistance;

void HandleTwoFingers()
{
    Touch t0 = Touch.activeTouches[0];
    Touch t1 = Touch.activeTouches[1];

    float currentDistance = Vector2.Distance(t0.screenPosition, t1.screenPosition);

    // どちらかの指が触れた直後は「前回の距離」が無いので記録だけして抜ける
    if (t0.phase == UnityEngine.InputSystem.TouchPhase.Began ||
        t1.phase == UnityEngine.InputSystem.TouchPhase.Began)
    {
        previousDistance = currentDistance;
        return;
    }

    // 距離の変化量がズーム量になる(プラス=広げた=ズームイン)
    float pinchDelta = currentDistance - previousDistance;
    previousDistance = currentDistance;

    Debug.Log($"ピンチ: {pinchDelta}");
}

大事なのは、 指が増減した瞬間に「前回の値」をリセットする ことです。これを忘れると、2本目の指が触れた瞬間にカメラが跳ねる「ズームの暴発」が起きます。実践のコードで、この処理を組み込んだ完成形を示します。

UIの上のタッチをゲームに通さない

モバイルで必ず起きる事故がこれです。ポーズボタンをタップしたら、ボタンの下のマップまでドラッグ判定されて画面がずれる——タッチは1回なのに、 UIとゲームの両方が反応してしまう 問題です。

UI上のタッチはUIだけが受け取り、ゲームへは通さないルート図。画面上のボタンに触れた指はEventSystemで止まり、何もない場所に触れた指だけがゲームのカメラ操作へ届く

定番のガードは、タッチの開始時に 「その指はUIの上にいるか?」 を確認することです。

using UnityEngine.EventSystems;

bool IsTouchOnUI(Touch touch)
{
    // touchIdを渡すのがポイント(引数なし版はタッチで正しく動かないことがある)
    return EventSystem.current != null &&
           EventSystem.current.IsPointerOverGameObject(touch.touchId);
}

ゲーム操作の入口で、UI上で始まったタッチを弾きます。「UIに触れた指はUIのもの、それ以外の指はゲームのもの」という所有権のルールを最初に決めておくと、以降のジェスチャー処理が濁りません。

注意: この判定にはシーン内に EventSystem が必要です(uGUIのCanvasを作ると自動で入ります)。また、New Input Systemを使うプロジェクトでは、EventSystemのコンポーネントが InputSystemUIInputModule になっていることを確認してください(古い StandaloneInputModule のままだとUIがタッチに反応しません)。

Screen.safeAreaでノッチを避ける

操作の次は表示です。最近のスマホは、ノッチ・パンチホール・角丸で 画面の四隅が欠けています。何も対策しないと、左上に置いたスコアがノッチの裏に隠れます。

Safe Area対応の比較図。対応前はスコア表示が画面最上部でノッチにぶつかっているが、Safe Area内へアンカーしたUIはノッチや角丸を避けた安全な領域に収まる

Unityは Screen.safeArea で「表示とタッチが安全な長方形」を教えてくれます。UIのルートに次のスクリプトを置き、アンカーをSafe Areaへ合わせるのが定番です。

using UnityEngine;

// Canvas直下の「UIルート」パネルに付ける
public class SafeAreaFitter : MonoBehaviour
{
    private RectTransform rectTransform;
    private Rect lastSafeArea;

    void Awake()
    {
        rectTransform = GetComponent<RectTransform>();
        Apply();
    }

    void Update()
    {
        // 画面回転などでSafe Areaが変わったら追従する
        if (Screen.safeArea != lastSafeArea)
        {
            Apply();
        }
    }

    void Apply()
    {
        Rect safeArea = Screen.safeArea;
        lastSafeArea = safeArea;

        // Safe Areaのピクセル座標を、0〜1のアンカーへ変換する
        Vector2 anchorMin = safeArea.position;
        Vector2 anchorMax = safeArea.position + safeArea.size;
        anchorMin.x /= Screen.width;
        anchorMin.y /= Screen.height;
        anchorMax.x /= Screen.width;
        anchorMax.y /= Screen.height;

        rectTransform.anchorMin = anchorMin;
        rectTransform.anchorMax = anchorMax;
        rectTransform.offsetMin = Vector2.zero;
        rectTransform.offsetMax = Vector2.zero;
    }
}

使い方のコツは、 Canvasの直下に「SafeAreaパネル」を1枚挟み、スコアやボタンは全部その子にする ことです。逆に、背景画像や全画面のエフェクトはSafeAreaパネルの外(Canvas直下)に置き、画面いっぱいまで広げます。「情報と操作は安全圏内、雰囲気は画面いっぱい」が使い分けの基準です。Canvasとアンカーの基礎 が曖昧な場合は、先にそちらを読むとこのスクリプトの意味がすっと入ります。

実践:ピンチズームできる見下ろしマップ

部品が揃ったので、1つの操作系として組み上げます。ストラテジーのマップ画面、タワーディフェンスの配置画面、街づくりゲームの俯瞰視点——「1本指でパン、2本指でピンチズーム、UIは誤爆しない」はそのまま使い回せる骨格です。

見下ろしマップをタッチ操作する場面図。1本指のドラッグでマップが横に流れ、2本指のピンチでズームし、画面隅のUIボタンに触れた指はマップを動かさない

見下ろしカメラ(Orthographic)に付ける完成コードです。

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;

[RequireComponent(typeof(Camera))]
public class TouchMapCamera : MonoBehaviour
{
    [SerializeField] private float panSpeed = 1f;
    [SerializeField] private float zoomSpeed = 0.05f;
    [SerializeField] private float minZoom = 3f;
    [SerializeField] private float maxZoom = 15f;

    private Camera cam;
    private float previousPinchDistance;
    private int previousTouchCount;

    void OnEnable()
    {
        EnhancedTouchSupport.Enable();
    }

    void OnDisable()
    {
        EnhancedTouchSupport.Disable();
    }

    void Awake()
    {
        cam = GetComponent<Camera>();
    }

    void Update()
    {
        // UI上で始まったタッチは、このカメラの操作に使わない
        int gameTouchCount = 0;
        Touch first = default;
        Touch second = default;

        foreach (Touch touch in Touch.activeTouches)
        {
            if (IsTouchOnUI(touch)) continue;

            if (gameTouchCount == 0) first = touch;
            else if (gameTouchCount == 1) second = touch;
            gameTouchCount++;
        }

        // 指の本数が変わった瞬間は「前回の値」を捨てる(ズーム・パンの暴発防止)
        if (gameTouchCount != previousTouchCount)
        {
            if (gameTouchCount == 2)
            {
                previousPinchDistance =
                    Vector2.Distance(first.screenPosition, second.screenPosition);
            }
            previousTouchCount = gameTouchCount;
            return;
        }

        if (gameTouchCount == 1)
        {
            Pan(first);
        }
        else if (gameTouchCount == 2)
        {
            Pinch(first, second);
        }
    }

    void Pan(Touch touch)
    {
        if (touch.phase != TouchPhase.Moved) return;

        // 画面上の指の移動を、ワールドの移動量へ換算する
        float unitsPerPixel = cam.orthographicSize * 2f / Screen.height;
        Vector2 delta = touch.delta * unitsPerPixel * panSpeed;

        // 指の動きと逆方向へカメラを動かすと「マップを掴んで動かす」感触になる
        transform.position -= new Vector3(delta.x, 0f, delta.y);
    }

    void Pinch(Touch t0, Touch t1)
    {
        float currentDistance = Vector2.Distance(t0.screenPosition, t1.screenPosition);
        float pinchDelta = currentDistance - previousPinchDistance;
        previousPinchDistance = currentDistance;

        // 指を広げたら寄る(orthographicSizeを小さく)、縮めたら引く
        float newSize = cam.orthographicSize - pinchDelta * zoomSpeed;
        cam.orthographicSize = Mathf.Clamp(newSize, minZoom, maxZoom);
    }

    bool IsTouchOnUI(Touch touch)
    {
        return EventSystem.current != null &&
               EventSystem.current.IsPointerOverGameObject(touch.touchId);
    }
}

実機(またはDevice Simulator)で触ってみてください。1本指でマップが「掴んだまま」ついてきて、2本指を広げるとズームイン、画面隅のボタンに触れてもマップは微動だにしない——この3つが揃えば合格です。もしズームの瞬間にカメラが跳ねるなら、指の本数が変わった瞬間のリセット(previousTouchCount の比較)が抜けていないかを見てください。ここがこの操作系でいちばん壊れやすい関節です。

ポイントは2つ。 指の本数が変わる瞬間が一番の事故ポイント(前回の値を必ずリセットする)。 UIの所有権チェックはジェスチャーの入口で1回だけ(各処理の中でバラバラに判定しない)。

おまけ:先に知っておくと良いこと

  • Device Simulatorでノッチ込みの確認: エディタのGameビューを Simulator に切り替えると、実在端末のノッチ・Safe Areaを再現できます。実機テストの前の一次確認に十分使えます。ただし複数指のジェスチャーの「感触」は実機でしか分からないので、ピンチの速度調整は実機で行ってください
  • タップできる大きさには下限がある: 指はマウスカーソルより太い道具です。ボタンは 物理サイズでおよそ1cm角(44〜48pt) を下回らないように。小さいボタンを並べるより、大きいボタンを減らす方がモバイルでは正解になりやすいです
  • 振動(ハプティクス)で手応えを返す: Handheld.Vibrate() で端末を振動させられます。長押し成立の瞬間などに短く返すと操作の確信感が上がります。細かい強弱の制御はプラットフォームごとのAPIやアセットの領域です

まとめ

  • タッチは「複数の指が現れて消える」入力。 Enhanced Touch で指ごとにID・位置・状態を追う
  • タップ・ドラッグ・長押しは 距離と時間の2つのしきい値 で判定する
  • ピンチは 2点間の距離の変化、パンは 中点の移動。指の本数が変わる瞬間に前回値をリセットする
  • UI上のタッチは IsPointerOverGameObject(touchId) でゲームに通さない
  • UIは Screen.safeArea に合わせたパネルの中へ。背景は画面いっぱいでよい

あなたのゲームをいまDevice Simulatorで開いたら、最初に壊れて見えるUIはどこでしょう——スマホ対応は、その1カ所を直すところから始まります。