😽

UnityのuGUI上でドラッグ&ドロップをなるべく少ない実装で実現する

に公開

uGUIでデモ的に作ったコードです。最低限のコードでドラッグ&ドロップをするコードが欲しくて作成しました。多くの既存の記事ではドラッグするまでのコードが多く、あまりドロップを実装する記事が見つからなかったため記事にて公開することにしました。

シーン構成

以下のようなシーンを想定しています。カードゲームでスロットにセットするようなイメージ。

  • Cardはただの単色のImage
  • CardsはHorizontal Layer Group
  • Cardsの中の最後だけplaceholderなのは、placeholderを用意していない場合、ドラッグ時に要素数が変わる→位置がズレることを防ぐことができることを確認するため
  • Zoneもただの単色のImage

コード

以下を、Cardにアタッチします。

using UnityEngine;
using UnityEngine.EventSystems;

[RequireComponent(typeof(RectTransform))]
[RequireComponent(typeof(CanvasGroup))]
public class CardDragDev : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    private Canvas canvas; // 座標変換用
    private RectTransform rect; // 自分のRectTransform
    private CanvasGroup canvasGroup; // Raycastの制御用
    [SerializeField] private Transform homeParent; // 元の親
    private Vector2 homeAnchorPos; // 元の位置
    private int homeSiblingIndex; // 最初の並び順

    // ドラッグごとの復元用
    private Transform originalParent; // ドラッグ開始時の親
    private Vector2 startAnchorPos; // 元の位置
    private int startSiblingIndex;
    private bool wasInDropZone; // ドロップゾーンに入っていたか

    private bool dropped = false; // ドロップされたか
    public void MarkDropped() => dropped = true;

    private Vector2 pointerOffset; // ドラッグ開始時のマウス位置とカード位置の差分
    private Camera UiCam() => canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;

    void Awake()
    {
        rect = GetComponent<RectTransform>();
        canvasGroup = GetComponent<CanvasGroup>();
        canvas = GetComponentInParent<Canvas>();

        if (homeParent == null)
        {
            homeParent = rect.parent;
            homeAnchorPos = rect.anchoredPosition;
            homeSiblingIndex = rect.GetSiblingIndex();
        }
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        originalParent = rect.parent;
        startAnchorPos = rect.anchoredPosition;
        startSiblingIndex = rect.GetSiblingIndex();
        wasInDropZone = originalParent != null && originalParent.GetComponent<DropZoneDev>() != null;

        var parentRect = rect.parent as RectTransform;
        if (parentRect != null &&
            RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRect, eventData.position, UiCam(), out var pLocal))
        {
            pointerOffset = rect.anchoredPosition - pLocal;
        }

        // ドラッグ中は一番上に表示
        canvasGroup.blocksRaycasts = false;
        canvasGroup.alpha = 0.85f;

        // 最前面に
        rect.SetAsLastSibling();
    }

    public void OnDrag(PointerEventData eventData)
    {
        var parentRect = rect.parent as RectTransform;
        if (parentRect != null &&
            RectTransformUtility.ScreenPointToLocalPointInRectangle(parentRect, eventData.position, UiCam(), out var pLocal))
        {
            rect.anchoredPosition = pLocal + pointerOffset;
        }
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        canvasGroup.blocksRaycasts = true;
        canvasGroup.alpha = 1.0f;

        if (dropped)
        {
            // DropZoneに配置された場合、アンカーを中央に調整
            if (rect.parent.TryGetComponent<DropZoneDev>(out var dropZone))
            {
                // アンカーを中央に設定
                rect.anchorMin = new Vector2(0.5f, 0.5f);
                rect.anchorMax = new Vector2(0.5f, 0.5f);

                // 位置を中央に調整
                rect.anchoredPosition = Vector2.zero;

                rect.localScale = Vector3.one * 0.8f;
            }
        }
        else
        {
            if (wasInDropZone)
            {
                rect.localScale = Vector3.one;

                // アンカーを左上に戻す
                rect.anchorMin = new Vector2(0f, 1f);
                rect.anchorMax = new Vector2(0f, 1f);

                rect.SetParent(homeParent, worldPositionStays: false);
                rect.SetSiblingIndex(Mathf.Clamp(homeSiblingIndex, 0, homeParent.childCount));
                rect.anchoredPosition = homeAnchorPos;
            }
            else
            {
                rect.SetParent(originalParent, worldPositionStays: false);
                rect.SetSiblingIndex(Mathf.Clamp(startSiblingIndex, 0, originalParent.childCount));
                rect.anchoredPosition = startAnchorPos;
            }
        }
        dropped = false;
    }

    // スクリプトから明示的にHomeに戻す時用
    public void ReturnToHome()
    {
        rect.SetParent(homeParent, worldPositionStays: false);
        rect.SetSiblingIndex(Mathf.Clamp(homeSiblingIndex, 0, homeParent.childCount));
        rect.anchoredPosition = homeAnchorPos;
    }
}

続いて、以下をZoneにアタッチします。

using UnityEngine;
using UnityEngine.EventSystems;

public class DropZoneDev : MonoBehaviour, IDropHandler, IPointerEnterHandler, IPointerExitHandler
{
    public void OnDrop(PointerEventData eventData)
    {
        var card = eventData.pointerDrag;
        if (card == null) return;

        if (card.TryGetComponent<CardDragDev>(out var drag))
        {
            card.transform.SetParent(transform, worldPositionStays: false);
            (card.transform as RectTransform).anchoredPosition = Vector2.zero;
            drag.MarkDropped();
        }
    }

    public void OnPointerEnter(PointerEventData eventData) { /* ハイライト等 */ }
    public void OnPointerExit(PointerEventData eventData)  { /* 解除 */ }
}

これで動作するはず。

終わりに

そもそもどう実装するんだと思っていたところからだったので、インターフェースが用意されていて、しかも最低限の実装であればかなりコード自体は疎結合に実装できるのだとびっくりしました。

Discussion