Unityを触り始めると、つい Update() に全部書いてしまいがちですよね。
インベントリUIの例でいうと、

  • アイテムリストの管理
  • Grid上のアイコン生成と破棄
  • マウス入力の監視
  • ドラッグ&ドロップ中のアイコン追従
  • スロットの入れ替え処理

こういった処理をひとつの巨大スクリプトに詰め込むと、すぐに「どこを触ればいいのか分からないクラス」になってしまいます。
そこで今回は、「アイテム配列を受け取り、GridContainerにアイコンを並べ、D&D操作を管理する」という役割だけに絞った InventoryGrid コンポーネントを作って、UIインベントリ周りの責務をきれいに分割してみましょう。

【Unity】ドラッグ&ドロップ対応のインベントリUI!「InventoryGrid」コンポーネント

以下では、

  • アイテム配列(スクリプタブルオブジェクト想定)を受け取る
  • GridLayoutGroup 配下にアイコンボタンを自動生成する
  • マウスによるドラッグ&ドロップでスロット入れ替えを行う

という、インベントリ表示専用のコンポーネントを実装していきます。
「インベントリの中身をどう決めるか」は別のコンポーネントに任せて、UI表示とD&Dだけを担当させる設計ですね。

フルコード:InventoryGrid.cs


using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Sample.Inventory
{
    /// <summary>
    /// シンプルなインベントリアイテムの定義。
    /// 実際のプロジェクトでは ScriptableObject にしてもOK。
    /// </summary>
    [Serializable]
    public class InventoryItem
    {
        // アイテム名(デバッグ・ツールチップなどに使用)
        public string itemName;

        // UIに表示するアイコン
        public Sprite icon;
    }

    /// <summary>
    /// インベントリの見た目(Grid)とドラッグ&ドロップを管理するコンポーネント。
    /// ・アイテム配列を受け取り
    /// ・GridLayoutGroup配下にスロットを並べ
    /// ・ドラッグ&ドロップでスロット入れ替えを行う
    /// という役割だけに責務を絞っています。
    /// </summary>
    public class InventoryGrid : MonoBehaviour
    {
        [Header("参照")]
        [SerializeField]
        private Transform gridContainer;
        // GridLayoutGroup をアタッチした GameObject を指定

        [SerializeField]
        private GameObject slotPrefab;
        // 1スロット分のプレハブ(Button + Image など)

        [SerializeField]
        private Canvas rootCanvas;
        // ドラッグ中のアイコンを正しく表示するための Canvas

        [Header("インベントリデータ")]
        [SerializeField]
        private InventoryItem[] items;
        // 外部のインベントリ管理クラスからセットしてもOK

        [Header("ドラッグ中アイコンの見た目")]
        [SerializeField]
        private Image dragIconImage;
        // ドラッグ中のアイコンを表示する Image
        // (Canvas 配下に置き、最初は非表示にしておく)

        // 内部管理用:生成したスロットを保持
        private InventorySlotUI[] _slots;

        // 現在ドラッグ中かどうか
        private bool _isDragging;
        // ドラッグ開始元のスロットインデックス
        private int _dragOriginIndex = -1;

        private void Awake()
        {
            // 必須参照チェック(最低限のガード)
            if (gridContainer == null)
            {
                Debug.LogError("InventoryGrid: gridContainer が設定されていません。");
            }

            if (slotPrefab == null)
            {
                Debug.LogError("InventoryGrid: slotPrefab が設定されていません。");
            }

            if (rootCanvas == null)
            {
                rootCanvas = GetComponentInParent<Canvas>();
                if (rootCanvas == null)
                {
                    Debug.LogError("InventoryGrid: rootCanvas が設定されておらず、親にも Canvas が見つかりません。");
                }
            }

            if (dragIconImage != null)
            {
                dragIconImage.gameObject.SetActive(false);
            }
        }

        private void Start()
        {
            RebuildGrid();
        }

        /// <summary>
        /// 外部からアイテム配列を差し替えるためのAPI。
        /// インベントリの中身が更新されたときに呼び出します。
        /// </summary>
        public void SetItems(InventoryItem[] newItems)
        {
            items = newItems;
            RebuildGrid();
        }

        /// <summary>
        /// Grid上のスロットを作り直し、現在の items 配列を反映する。
        /// </summary>
        public void RebuildGrid()
        {
            if (gridContainer == null || slotPrefab == null)
            {
                return;
            }

            // 既存の子オブジェクト(スロット)を削除
            for (int i = gridContainer.childCount - 1; i >= 0; i--)
            {
                Destroy(gridContainer.GetChild(i).gameObject);
            }

            if (items == null || items.Length == 0)
            {
                _slots = Array.Empty<InventorySlotUI>();
                return;
            }

            _slots = new InventorySlotUI[items.Length];

            for (int i = 0; i < items.Length; i++)
            {
                // スロットプレハブを生成
                GameObject slotObj = Instantiate(slotPrefab, gridContainer);
                // スロットUIコンポーネントを取得(なければ付与)
                InventorySlotUI slotUI = slotObj.GetComponent<InventorySlotUI>();
                if (slotUI == null)
                {
                    slotUI = slotObj.AddComponent<InventorySlotUI>();
                }

                // スロットに必要な初期化情報を渡す
                slotUI.Initialize(this, i, items[i]);
                _slots[i] = slotUI;
            }
        }

        /// <summary>
        /// スロットから呼ばれる:ドラッグ開始。
        /// </summary>
        public void BeginDrag(int slotIndex)
        {
            if (items == null || slotIndex < 0 || slotIndex >= items.Length)
            {
                return;
            }

            InventoryItem item = items[slotIndex];
            if (item == null || item.icon == null)
            {
                // 空スロットやアイコンなしの場合はドラッグしない
                return;
            }

            _isDragging = true;
            _dragOriginIndex = slotIndex;

            // ドラッグ中アイコンの見た目をセット
            if (dragIconImage != null)
            {
                dragIconImage.sprite = item.icon;
                dragIconImage.SetNativeSize();
                dragIconImage.gameObject.SetActive(true);
            }
        }

        /// <summary>
        /// スロットから呼ばれる:ドラッグ終了。
        /// マウスが離された時点で呼び出されます。
        /// </summary>
        public void EndDrag(int pointerOverSlotIndex)
        {
            if (!_isDragging)
            {
                return;
            }

            // ドラッグ中フラグをクリア
            _isDragging = false;

            // ドラッグ中アイコンを非表示に
            if (dragIconImage != null)
            {
                dragIconImage.gameObject.SetActive(false);
            }

            // 有効なインデックスでなければ何もしない
            if (_dragOriginIndex < 0 || _dragOriginIndex >= items.Length)
            {
                _dragOriginIndex = -1;
                return;
            }

            // ドロップ先が有効なスロットならアイテムを入れ替え
            if (pointerOverSlotIndex >= 0 && pointerOverSlotIndex < items.Length)
            {
                SwapItems(_dragOriginIndex, pointerOverSlotIndex);
            }

            _dragOriginIndex = -1;

            // UIを更新
            RefreshAllSlots();
        }

        /// <summary>
        /// 毎フレーム、ドラッグ中のアイコンをマウス位置に追従させる。
        /// Update はあくまで「見た目の追従」だけに責務を絞る。
        /// </summary>
        private void Update()
        {
            if (!_isDragging || dragIconImage == null || rootCanvas == null)
            {
                return;
            }

            // マウスのスクリーン座標を取得
            Vector2 screenPos = Input.mousePosition;

            // Canvas の RenderMode に応じた座標変換
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                rootCanvas.transform as RectTransform,
                screenPos,
                rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay
                    ? null
                    : rootCanvas.worldCamera,
                out Vector2 localPos
            );

            dragIconImage.rectTransform.anchoredPosition = localPos;
        }

        /// <summary>
        /// 2つのスロット間でアイテムを入れ替える。
        /// </summary>
        private void SwapItems(int indexA, int indexB)
        {
            if (items == null)
            {
                return;
            }

            if (indexA < 0 || indexA >= items.Length) return;
            if (indexB < 0 || indexB >= items.Length) return;

            if (indexA == indexB) return;

            InventoryItem temp = items[indexA];
            items[indexA] = items[indexB];
            items[indexB] = temp;
        }

        /// <summary>
        /// 内部の items 配列の内容に合わせて、全スロットの表示を更新。
        /// </summary>
        private void RefreshAllSlots()
        {
            if (_slots == null || items == null)
            {
                return;
            }

            int count = Mathf.Min(_slots.Length, items.Length);
            for (int i = 0; i < count; i++)
            {
                _slots[i].SetItem(items[i]);
            }
        }
    }

    /// <summary>
    /// 1スロット分のUIと、ドラッグ開始/終了のイベントを担当するコンポーネント。
    /// このクラスは「スロットの見た目とイベント」だけに責務を絞ります。
    /// </summary>
    [RequireComponent(typeof(Image))]
    public class InventorySlotUI : MonoBehaviour,
        IPointerDownHandler,
        IPointerUpHandler,
        IPointerEnterHandler,
        IPointerExitHandler
    {
        [SerializeField]
        private Image iconImage;
        // アイテムアイコンを表示する Image

        // 所属する InventoryGrid
        private InventoryGrid _inventoryGrid;
        // 自分のスロットインデックス
        private int _slotIndex;

        // マウスが現在このスロットの上にあるか
        private bool _isPointerOver;

        /// <summary>
        /// InventoryGrid から呼ばれる初期化処理。
        /// </summary>
        public void Initialize(InventoryGrid inventoryGrid, int slotIndex, InventoryItem item)
        {
            _inventoryGrid = inventoryGrid;
            _slotIndex = slotIndex;

            // iconImage が未設定なら、自身の Image を利用
            if (iconImage == null)
            {
                iconImage = GetComponent<Image>();
            }

            SetItem(item);
        }

        /// <summary>
        /// スロットに表示するアイテムを更新。
        /// null の場合はアイコンを非表示にします。
        /// </summary>
        public void SetItem(InventoryItem item)
        {
            if (iconImage == null)
            {
                return;
            }

            if (item != null && item.icon != null)
            {
                iconImage.sprite = item.icon;
                iconImage.color = Color.white;
            }
            else
            {
                // 空スロット用の見た目(透明にするなど)
                iconImage.sprite = null;
                iconImage.color = new Color(1f, 1f, 1f, 0f);
            }
        }

        /// <summary>
        /// マウスが押されたときに呼ばれる。
        /// ここでドラッグ開始を InventoryGrid に通知します。
        /// </summary>
        public void OnPointerDown(PointerEventData eventData)
        {
            if (eventData.button != PointerEventData.InputButton.Left)
            {
                return;
            }

            _inventoryGrid?.BeginDrag(_slotIndex);
        }

        /// <summary>
        /// マウスが離されたときに呼ばれる。
        /// InventoryGrid に「どのスロット上で離されたか」を伝えます。
        /// </summary>
        public void OnPointerUp(PointerEventData eventData)
        {
            if (eventData.button != PointerEventData.InputButton.Left)
            {
                return;
            }

            // 現在マウスが乗っているスロットインデックスを渡す
            int dropIndex = _isPointerOver ? _slotIndex : -1;
            _inventoryGrid?.EndDrag(dropIndex);
        }

        /// <summary>
        /// マウスカーソルがこのスロットに入ったとき。
        /// </summary>
        public void OnPointerEnter(PointerEventData eventData)
        {
            _isPointerOver = true;
        }

        /// <summary>
        /// マウスカーソルがこのスロットから出たとき。
        /// </summary>
        public void OnPointerExit(PointerEventData eventData)
        {
            _isPointerOver = false;
        }
    }
}

使い方の手順

ここでは、プレイヤーのインベントリUI を例に、実際のセットアップ手順を見ていきます。

  1. UIレイアウトを作る

    • Canvas を1つ用意します(Render Mode は Screen Space – Overlay 推奨)。
    • Canvas の子に、インベントリ用の Panel を作成します。
    • Panel の子に GridLayoutGroup をアタッチした GameObject(例: InventoryGridContainer)を作成します。
      Cell Size や Spacing を調整して、インベントリのマス目レイアウトを決めましょう。
  2. スロット用プレハブを作る

    • Hierarchy 上で、空の UI > Image を作成し、名前を InventorySlot にします。
    • サイズを 64×64 などに調整し、背景画像や枠線を設定します。
    • このオブジェクトに InventorySlotUI をアタッチします。
    • 必要なら、この Image を iconImage にドラッグ&ドロップで割り当てます(未設定でも自動取得されます)。
    • Prefab 化して、Project ビューにドラッグして保存します。
  3. InventoryGrid コンポーネントをセットアップする

    • Panel(または任意の GameObject)に InventoryGrid をアタッチします。
    • Grid Container に、先ほど作成した InventoryGridContainer(GridLayoutGroup付き)を指定します。
    • Slot Prefab に、作成した InventorySlot プレハブを指定します。
    • Root Canvas に、インベントリUIが属している Canvas を指定します(未設定でも親から自動取得を試みます)。
    • ドラッグ中アイコン用に、Canvas の子に空の Image を作成し、dragIconImage に割り当てておきます。最初は SetActive(false) にしておくか、インスペクタで非表示にしておきましょう。
  4. アイテム配列を用意して渡す

    テスト用として、インスペクタから直接配列を埋めてもOKですし、以下のような簡単な管理スクリプトを作ってもよいです。

    
    using UnityEngine;
    using Sample.Inventory;
    
    public class PlayerInventoryExample : MonoBehaviour
    {
        [SerializeField]
        private InventoryGrid inventoryGrid;
    
        [SerializeField]
        private Sprite[] testItemIcons;
    
        private InventoryItem[] _items;
    
        private void Start()
        {
            // テスト用に、アイコン配列から InventoryItem 配列を組み立てる
            _items = new InventoryItem[testItemIcons.Length];
            for (int i = 0; i < testItemIcons.Length; i++)
            {
                _items[i] = new InventoryItem
                {
                    itemName = $"Item_{i}",
                    icon = testItemIcons[i]
                };
            }
    
            // InventoryGrid に渡して表示させる
            inventoryGrid.SetItems(_items);
        }
    }
    

    この状態で再生すると、Grid 上にアイコンが並び、左クリックでドラッグ&ドロップしてスロットを入れ替えできるようになります。

メリットと応用

この InventoryGrid コンポーネントを使うことで、

  • UI表示とデータ管理の責務を分離できる
    • インベントリの中身(何を持っているか)は PlayerInventory など別コンポーネントで管理。
    • Grid の生成・ドラッグ&ドロップ・アイコン表示だけを InventoryGrid が担当。
    • 巨大な God クラスを避け、テストや改造がしやすくなります。
  • プレハブ化でレベルデザインが楽になる
    • インベントリUI一式をプレハブにしておけば、シーンごとに簡単に再利用できます。
    • Grid の列数やスロット間隔を変えたバリエーションも、Prefab Variant などで管理しやすいです。
  • ドラッグ&ドロップのロジックを1か所に集約
    • 「ドラッグ中のビジュアル」「スロット入れ替え」「どのスロットに落としたか」の判定が InventoryGrid に集約されます。
    • 後から「右クリックで使用」「Ctrl+ドラッグで分割」などの機能追加もしやすくなります。

応用例としては、

  • 敵のドロップ品一覧UI(クリックで拾う)
  • クラフト画面の素材スロット
  • 装備スロット(武器・防具など)

などにも同じ仕組みを流用できます。「何を持っているか」のデータ構造だけ変えて、UI部分はこのコンポーネントを使い回すイメージですね。

改造案:スロットクリックでアイテム名をログに出す

ドラッグ&ドロップ以外にも、スロットクリックで何かしたくなる場面は多いです。
例えば、「左クリックで詳細ウィンドウを開く」などの処理を足したい場合、InventoryGrid に以下のようなメソッドを追加し、InventorySlotUI から呼ぶ形にすると責務を保ったまま拡張できます。


public void OnSlotClicked(int slotIndex)
{
    if (items == null || slotIndex < 0 || slotIndex >= items.Length)
    {
        return;
    }

    InventoryItem item = items[slotIndex];
    if (item == null)
    {
        Debug.Log($"スロット {slotIndex} は空です。");
    }
    else
    {
        Debug.Log($"スロット {slotIndex} をクリック: {item.itemName}");
        // ここで詳細ウィンドウを開く・装備する などの処理に分岐させてもOK
    }
}

このように、小さなコンポーネントを組み合わせていくと、
「インベントリUIを全部1ファイルに書く」のではなく、「表示担当」「データ担当」「操作担当」に分けた、拡張しやすい設計に育てていけます。ぜひ自分のプロジェクト用にカスタマイズしてみてください。