【Unity2D】ドラッグ&ドロップで任意の処理を実行するクラスを作成した

Unity

Unityでゲームを作成している者です。主に2Dのゲームを作成しています。

今回、オブジェクトをドラッグ&ドロップし、ドロップ完了時に任意の処理を実行させることができるクラスを作成してみました。

例えばですが

  • 特定のエリアにドロップした後に、そのオブジェクトを破壊する
  • 特定のエリアにドロップした後に、そのオブジェクトからテキストを取得して、それを表示させる
  • 特定のエリアにドロップした後に、そのオブジェクトの色を変える

などです。

自分のゲームだとドラッグ&ドロップで色々と操作させるUIにしている関係で、ドラッグ&ドロップを実行させるクラスが乱立してしまいました。

ただ、よく見るとドロップした後に実行する動作が違うだけで、その他は同じクラスがほとんどでした。

そこで当初は基底クラスを継承して、実行後の動作部分だけを実装させるようにしていました。

しかし動作部分を関数で渡して対応する方がわかりやすい気がしたので、そちらを採用しました。

そのプログラムについて紹介します。(基底クラスパターンも最後に紹介します。)

動作部分を関数で渡して任意の動作を実行させる

ドロップした後に関数で指定された動作を実行するプログラムのサンプルになります。ファイル名はDraggable.csとしています。

using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.EventSystems;
public class Draggable : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler
{
    // このオブジェクトの元の位置
    private Vector2 prePos;

    // このオブジェクトの元の親
    private GameObject preParent;

    // ドロップ可能エリア
    public List<MonoBehaviour> dropArea;

    // ドラッグ開始時に実行するアクション
    public Action beforeBeginDrag;

    // ドロップ完了時に実行するアクション
    public Action<MonoBehaviour, Action> onDropSuccess;

    // ドロップ可能エリア以外にドロップされたときの処理
    public Action<Action> onDropFail;

    public void OnBeginDrag(PointerEventData eventData)
    {
        // ドラッグ開始時に実行するアクションを実行
        if (beforeBeginDrag != null)
        {
            beforeBeginDrag.Invoke();
        }
        // このオブジェクトの元の位置と親を予め保存
        prePos = transform.position;
        preParent = this.transform.parent.gameObject;
        // 最上位に移動
        this.transform.SetParent(transform.root.gameObject.transform, true);

    }

    public void OnDrag(PointerEventData eventData)
    {
        transform.position = eventData.position;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        bool isSuccess = false;
        foreach (MonoBehaviour area in dropArea)
        {
            if (contains(area.GetComponent<RectTransform>(), eventData))
            {
                // ドロップ可能エリアにこのオブジェクトが含まれる場合
                onDropSuccess.Invoke(area, resetPos()); // 引数1:ドロップしたエリア、引数2:位置をもとに戻す関数
                isSuccess = true;
            }
        }

        // 失敗時処理
        if (!isSuccess)
        {
            if (onDropFail == null)
            {
                // 失敗時アクションが未設定の場合、位置をもとに戻す
                resetPos().Invoke();
            }
            else
            {
                // アクション設定済みならそれを実行
                onDropFail.Invoke(resetPos());
            }
        }
    }

    private Action resetPos()
    {
        Action ret = () =>
        {
            // 位置をもとに戻す
            transform.position = prePos;
            this.transform.SetParent(preParent.transform, true);
        };
        return ret;
    }

    // targetがareaの範囲内にいるかどうかを判定する
    // https://hacchi-man.hatenablog.com/entry/2020/05/09/220000
    // を参考に作成させていただきました
    private bool contains(RectTransform area, PointerEventData target)
    {
        var selfBounds = GetBounds(area);
        var worldPos = Vector3.zero;
        RectTransformUtility.ScreenPointToWorldPointInRectangle(
            area,
            target.position,
            target.pressEventCamera,
            out worldPos);
        worldPos.z = 0f;
        return selfBounds.Contains(worldPos);
    }

    private Bounds GetBounds(RectTransform target)
    {
        Vector3[] s_Corners = new Vector3[4];
        var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
        var max = new Vector3(float.MinValue, float.MinValue, float.MinValue);
        target.GetWorldCorners(s_Corners);
        for (var index2 = 0; index2 < 4; ++index2)
        {
            min = Vector3.Min(s_Corners[index2], min);
            max = Vector3.Max(s_Corners[index2], max);
        }

        max.z = 0f;
        min.z = 0f;

        Bounds bounds = new Bounds(min, Vector3.zero);
        bounds.Encapsulate(max);
        return bounds;
    }

}

なお、ドラッグが成功しているかどうか判定するためのメソッドについては下記のブログで紹介されているコードを少し改変させてもらい実装いたしました。

ちなみにカメラがScreen Space Cameraの場合はOnDragのコードが以下のようになるかと思います。


    public void OnDrag(PointerEventData eventData)
    {
        // Screen Space Cameraの場合、カメラの位置からの差分だけ移動させる
        Vector3 vec = Camera.main.WorldToScreenPoint(transform.position);
        vec.x += eventData.delta.x;
        vec.y += eventData.delta.y;
        transform.position = Camera.main.ScreenToWorldPoint(vec);
    }

使い方のサンプルは以下の通りです。

まずはキャンパスに「ドラッグできるエリア」と「ドロップできるオブジェクト」を作成します。

次に「ドロップできるオブジェクト」に対してDraggable.csをアタッチします。そしてdropArea(ドロップ可能エリア)に「ドラッグできるエリア」を設定します。

スクリプト側(メイン関数など)からドロップ後に実行するアクションを設定します。ドラッグオブジェクト側のスクリプトからオブジェクトをもとの位置に戻すアクション(resetPos)が提供されるので、成功時と失敗時の両方で元の位置に戻しています。この部分は移動したままにするなら親をドロップエリアに変更する感じですかね。

using System;
using UnityEngine;

public class test : MonoBehaviour
{
    // ドロップ可能オブジェクト
    public Draggable dropObj;

    void Start()
    {
        dropObj.beforeBeginDrag = () =>
        {
            Debug.Log("ドラッグ前に呼び出される処理");
        };
        dropObj.onDropSuccess = (MonoBehaviour area, Action resetAction) =>
        {
            Debug.Log("ドラッグ成功時に呼び出される処理");
            resetAction.Invoke();
        };
        dropObj.onDropFail = (Action resetAction) =>
        {
            Debug.Log("ドラッグ失敗時に呼び出される処理");
            resetAction.Invoke();
        };
    }
}

実行結果:成功時

実行結果:失敗(エリア内にドロップしなかった)時

基底クラスを継承して、実行後の動作部分だけを実装

基底クラスを作成して、そのクラスを継承して新クラスを作成していくパターンの実装サンプルになります。

まず基底クラスは以下の通り。

using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.EventSystems;
public abstract class Draggable : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler
{
    // このオブジェクトの元の位置
    protected Vector2 prePos;

    // このオブジェクトの元の親
    protected GameObject preParent;

    // ドロップ可能エリア
    public List<MonoBehaviour> dropArea;

    // ドラッグ開始時に実行するアクション
    public abstract void beforeBeginDrag();

    // ドロップ完了時に実行するアクション
    public abstract void onDropSuccess();

    // ドロップ可能エリア以外にドロップされたときの処理
    public abstract void onDropFail();

    public void OnBeginDrag(PointerEventData eventData)
    {
        // ドラッグ開始時に実行するアクションを実行
        beforeBeginDrag();
        // このオブジェクトの元の位置と親を予め保存
        prePos = transform.position;
        preParent = this.transform.parent.gameObject;
        // 最上位に移動
        this.transform.SetParent(transform.root.gameObject.transform, true);

    }

    public void OnDrag(PointerEventData eventData)
    {
        transform.position = eventData.position;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        bool isSuccess = false;
        foreach (MonoBehaviour area in dropArea)
        {
            if (contains(area.GetComponent<RectTransform>(), eventData))
            {
                // ドロップ可能エリアにこのオブジェクトが含まれる場合
                onDropSuccess();
                isSuccess = true;
            }
        }

        // 失敗時処理
        if (!isSuccess)
        {
            onDropFail();
        }
    }

    protected Action resetPos()
    {
        Action ret = () =>
        {
            // 位置をもとに戻す
            transform.position = prePos;
            this.transform.SetParent(preParent.transform, true);
        };
        return ret;
    }

    // targetがareaの範囲内にいるかどうかを判定する
    // https://hacchi-man.hatenablog.com/entry/2020/05/09/220000
    // を参考に作成させていただきました
    protected bool contains(RectTransform area, PointerEventData target)
    {
        var selfBounds = GetBounds(area);
        var worldPos = Vector3.zero;
        RectTransformUtility.ScreenPointToWorldPointInRectangle(
            area,
            target.position,
            target.pressEventCamera,
            out worldPos);
        worldPos.z = 0f;
        return selfBounds.Contains(worldPos);
    }
    protected Bounds GetBounds(RectTransform target)
    {
        Vector3[] s_Corners = new Vector3[4];
        var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
        var max = new Vector3(float.MinValue, float.MinValue, float.MinValue);
        target.GetWorldCorners(s_Corners);
        for (var index2 = 0; index2 < 4; ++index2)
        {
            min = Vector3.Min(s_Corners[index2], min);
            max = Vector3.Max(s_Corners[index2], max);
        }

        max.z = 0f;
        min.z = 0f;

        Bounds bounds = new Bounds(min, Vector3.zero);
        bounds.Encapsulate(max);
        return bounds;
    }

}

次にここから実装クラスを作成します。

using UnityEngine;

public class DragImpl : Draggable
{
    public override void beforeBeginDrag()
    {
        Debug.Log("ドラッグ前に呼び出される処理");
    }

    public override void onDropFail()
    {
        Debug.Log("ドラッグ失敗時に呼び出される処理");
        resetPos();
    }

    public override void onDropSuccess()
    {
        Debug.Log("ドラッグ成功時に呼び出される処理");
        resetPos();
    }
}

この方法だと処理がクラス内部で完結するので、より複雑な実装ができるし、外部から書き換えがないからわかりやすくて安全。

なのでプログラミング的に言うとこちらを採用するべきなきがしますが、やはり動作がちょっとでも違った場合にクラスを新規に作成していかないといけないのが面倒なので自分はActionで渡す方式にしました。

元のオブジェクトは移動させずにオブジェクトのコピーをドロップできるようにする

ブログタイトルとは別の話題になりますが、個人的にドラッグアンドドロップする場合は、元のオブジェクトの位置は変更せずにオブジェクトのコピーを移動させる方がしっくりきます。

言葉だと伝わりにくいですが以下のような感じです。

個人的によく使いそうなのでソースコードを貼っておきます。

using System.Collections.Generic;
using UnityEngine;
using System;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class Draggable : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler
{
    // このオブジェクトの元の位置
    private Vector2 prePos;

    // このオブジェクトの元の親
    private GameObject preParent;

    // ドロップ可能エリア
    public List<MonoBehaviour> dropArea;

    // ドラッグ開始時に実行するアクション
    public Action beforeBeginDrag;

    // ドロップ完了時に実行するアクション
    public Action<MonoBehaviour, Action> onDropSuccess;

    // ドロップ可能エリア以外にドロップされたときの処理
    public Action<Action> onDropFail;

    // ドラッグ中、オブジェクトのコピーをその場に残す
    public bool moveCopyObj = false;
    private GameObject copyObj = null;

    public void OnBeginDrag(PointerEventData eventData)
    {
        // ドラッグ開始時に実行するアクションを実行
        if (beforeBeginDrag != null)
        {
            beforeBeginDrag.Invoke();
        }
        // このオブジェクトの元の位置と親を予め保存
        prePos = transform.position;
        preParent = this.transform.parent.gameObject;
        // 最上位に移動
        this.transform.SetParent(transform.root.gameObject.transform, true);

        // オブジェクトのコピーをその場に残す場合、オブジェクトをコピーする
        if (moveCopyObj)
        {
            GameObject target = eventData.pointerDrag;
            copyObj = copy(target);
            // 移動させるオブジェクトは半透明にする
            childHalfA(target);
        }

    }

    public void OnDrag(PointerEventData eventData)
    {
        transform.position = eventData.position;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        bool isSuccess = false;
        foreach (MonoBehaviour area in dropArea)
        {
            if (contains(area.GetComponent<RectTransform>(), eventData))
            {
                // ドロップ可能エリアにこのオブジェクトが含まれる場合
                onDropSuccess.Invoke(area, resetPos()); // 引数1:ドロップしたエリア、引数2:位置をもとに戻す関数
                isSuccess = true;
            }
        }

        // 失敗時処理
        if (!isSuccess)
        {
            if (onDropFail == null)
            {
                // 失敗時アクションが未設定の場合、位置をもとに戻す
                resetPos().Invoke();
            }
            else
            {
                // アクション設定済みならそれを実行
                onDropFail.Invoke(resetPos());
            }
        }
    }

    private Action resetPos()
    {
        Action ret = () =>
        {
            // 位置をもとに戻す
            transform.position = prePos;
            this.transform.SetParent(preParent.transform, true);
        };
        return ret;
    }

    // targetがareaの範囲内にいるかどうかを判定する
    // https://hacchi-man.hatenablog.com/entry/2020/05/09/220000
    // を参考に作成させていただきました
    private bool contains(RectTransform area, PointerEventData target)
    {
        var selfBounds = GetBounds(area);
        var worldPos = Vector3.zero;
        RectTransformUtility.ScreenPointToWorldPointInRectangle(
            area,
            target.position,
            target.pressEventCamera,
            out worldPos);
        worldPos.z = 0f;
        return selfBounds.Contains(worldPos);
    }

    private Bounds GetBounds(RectTransform target)
    {
        Vector3[] s_Corners = new Vector3[4];
        var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
        var max = new Vector3(float.MinValue, float.MinValue, float.MinValue);
        target.GetWorldCorners(s_Corners);
        for (var index2 = 0; index2 < 4; ++index2)
        {
            min = Vector3.Min(s_Corners[index2], min);
            max = Vector3.Max(s_Corners[index2], max);
        }

        max.z = 0f;
        min.z = 0f;

        Bounds bounds = new Bounds(min, Vector3.zero);
        bounds.Encapsulate(max);
        return bounds;
    }

    // ゲームオブジェクトをコピーする
    private GameObject copy(GameObject source)
    {
        GameObject ret = UnityEngine.Object.Instantiate(source);
        // 元オブジェクトと同じ位置に移動させる
        ret.transform.SetParent(source.transform.parent, true);
        ret.transform.position = source.transform.position;
        // 元オブジェクトと同じ大きさにする
        ret.transform.localScale = source.transform.localScale;
        return ret;
    }

    // 子要素の透明度をすべて半分にする
    private void childHalfA(GameObject target)
    {
        Transform children = target.GetComponentInChildren<Transform>();
        if (children != null)
        {
            foreach (Transform child in children)
            {
                if (child.GetComponent<Image>() != null)
                {
                    setA(child.GetComponent<Image>(), child.GetComponent<Image>().color.a / 2);
                }
                if (child.GetComponent<Text>() != null)
                {
                    setA(child.GetComponent<Text>(), child.GetComponent<Text>().color.a / 2);
                }
                childHalfA(child.gameObject);
            }
        }
    }

    // 画像を任意の透明度にする
    private void setA(Image i, float a)
    {
        i.color = new Color(i.color.r, i.color.b, i.color.g, a);
    }
    private void setA(Text i, float a)
    {
        i.color = new Color(i.color.r, i.color.b, i.color.g, a);
    }
}

コメント

タイトルとURLをコピーしました