UGUI拖拽效果实现

环境

    Windows10操作系统
    Unity2022.3.8f1c1
    Unity UI 1.0.0

正文

        UGUI本身是没有提供拖拽的组件,但是它仍然提供了接口让我们知道何时为拖拽操作。我们只要通过实现IBeginDragHandlerIDragHandlerIEndDragHandler接口就可以知道何时开始拖拽、何时正在拖拽和何时拖拽结束。下面是简单的拖拽实现,挂载此脚本的UI会跟随鼠标移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace UITest
{
[RequireComponent(typeof(Image))]
public class TowableArea : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
private Vector2 dragStartPos;

public void OnBeginDrag(PointerEventData eventData)
{
dragStartPos = eventData.currentInputModule.input.mousePosition;
}

public void OnDrag(PointerEventData eventData)
{
Vector2 curDragStartPos = eventData.currentInputModule.input.mousePosition;
Vector2 diff = curDragStartPos - dragStartPos;
this.transform.position += new Vector3(diff.x, diff.y);
dragStartPos = curDragStartPos;
}

public void OnEndDrag(PointerEventData eventData)
{
}
}
}

你会发现我这里强制要求需要Image组件。这是因为如果我不加,那么TowableArea就检测不到拖拽事件。当然你也可以直接让TowableArea继承Image,然后继续后面的实现。

        如果你有反编译过UGUI中Button的实现,或是看过Button的源码。你会发现Button并没有继承或是强制要求Image。虽然我觉得应该要继承Image,但是我看到UGUI中Button的做法后认为也可以按照这个思路。这里有一点要注意的是,如果你想把拖拽单独作做一个UI的功能,那么你最好是继承UIBehaviour。首先这个本来这个拖拽就是在UGUI上,所以我们让其继承UIBehaviour也算得上合理。其次如果你有同事做了一个关于UGUI的工具,那么继承于UIBehaviour的拖拽脚本,大概率可以使用这UGUI的工具。

拖拽UI限制于屏幕内

2024.4.29补充,下面这段代码在我实际项目中出现了问题。原因是UI中的位置并不和窗口比例对应。比如你是1920 x 1080的比例去做UI适配,然后你运行在一个2560 x 1440的电脑上运行。下面的代码仍然要使用1920 x 1080。现在我项目忙,没有时间去做细致化的修改,等之后我有时间了并且记起来。我就修改下面的代码。

2024.5.3补充。我仔细想了一下,实现起来考虑的情况太多了。我觉得我只要给出我的想法就好了,实现方面我也只给出我的情况。后面大家可以按照我的思路结合一下自己的情况进行修改底代码。

        大多数情况下,我们并不希望玩家那么自由的拖动UI。我们大都希望无论玩家怎么拖动UI,UI所在的位置都在屏幕内。我的办法是计算一个UI组件的最左上的点和最右下的点,然后我们就可以判断这个UI组件是否在屏幕内。主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var rectT = this.gameObject.GetComponent<RectTransform>();
var pos = rectT.anchoredPosition + diff;
var rect = rectT.rect;
var centerPos = new Vector2(Screen.width * (rectT.anchorMin.x + rectT.anchorMax.x), Screen.height * (rectT.anchorMin.y + rectT.anchorMax.y)) / 2;
float maxXVal = pos.x + centerPos.x + rect.width * (1 - rectT.pivot.x) * rectT.localScale.x;
float maxYVal = pos.y + centerPos.y + rect.height * (1 - rectT.pivot.y) * rectT.localScale.y;
float minXVal = pos.x + centerPos.x - rect.width * rectT.pivot.x * rectT.localScale.x;
float minYVal = pos.y + centerPos.y - rect.height * rectT.pivot.y * rectT.localScale.y;
if (maxXVal > Screen.width)
{
pos.x -= maxXVal - Screen.width;
}

if (maxYVal > Screen.height)
{
pos.y -= maxYVal - Screen.height;
}

if (minXVal < 0)
pos.x -= minXVal;
if (minYVal < 0)
pos.y -= minYVal;
rectT.anchoredPosition = pos;

这里我改变的位置从position改为了anchoredPosition,我认为使用这个会更加方便去判断组件是否在屏幕内。有关AnchorPivotanchoredPosition的对应关系,大家可以网络上搜索相关的文章。在我个人的测试下,这段逻辑可以支持Unity提供的所有Anchor选择。但是其他奇怪的Anchor设定,我并没有进行测试。如果你发现上面代码的问题,麻烦您指出来或私信告知。

        上面我粗暴的将UI的画布大小认为就是窗口大小,这是一个错误的观点。在UGUI中,我们所有的UI都要在一个主画布下。我们可以在主画布中找到一个组件:CanvasScaler。当其UI Scale ModeConstant Pixel Size,我们才可以大致的认为画布大小就是窗口大小。(关于CanvasScalerUI Scale Mode的具体数值介绍,大家可以查看一下官方文档——画布缩放器 (Canvas Scaler) - Unity 手册)所以当其不是这个设置的时候,上面的代码从逻辑上就是错误的。那只要我们可以获得主画布真正的大小,那么上面的代码就正确了。

        其实主画布本身仍然算是画布,它是画布,那么它必定就有RectTransform。那么其真正的大小不就是RectTransform.sizeDelta。一般而言,我们只有主画布需要CanvasScaler,所以我这里用了一些取巧的方法获取主画布。大家可以根据自己的UI框架进行修改。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace UITest
{
[RequireComponent(typeof(Image))]
public class TowableArea : UIBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
private Vector2 dragStartPos;
// 主画布的大小
// 因为我需要用这个代码进行测试,所以我为了方便才去获取主画布的RectTransform
private RectTransform canvasSize;

protected override void Awake()
{
base.Awake();
var scaler = GetComponentInParent<CanvasScaler>();
canvasSize = scaler.GetComponent<RectTransform>();
}

public void OnBeginDrag(PointerEventData eventData)
{
dragStartPos = eventData.currentInputModule.input.mousePosition;
}

public void OnDrag(PointerEventData eventData)
{
Vector2 curCanvasSize = canvasSize.sizeDelta;
Vector2 curDragStartPos = eventData.currentInputModule.input.mousePosition;
Vector2 diff = curDragStartPos - dragStartPos;
var rectT = this.gameObject.GetComponent<RectTransform>();
var pos = rectT.anchoredPosition + diff;
var rect = rectT.rect;
var centerPos = new Vector2(curCanvasSize.x * (rectT.anchorMin.x + rectT.anchorMax.x), curCanvasSize.y * (rectT.anchorMin.y + rectT.anchorMax.y)) / 2;
float maxXVal = pos.x + centerPos.x + rect.width * (1 - rectT.pivot.x) * rectT.localScale.x;
float maxYVal = pos.y + centerPos.y + rect.height * (1 - rectT.pivot.y) * rectT.localScale.y;
float minXVal = pos.x + centerPos.x - rect.width * rectT.pivot.x * rectT.localScale.x;
float minYVal = pos.y + centerPos.y - rect.height * rectT.pivot.y * rectT.localScale.y;
if (maxXVal > curCanvasSize.x)
{
pos.x -= maxXVal - curCanvasSize.x;
}

if (maxYVal > curCanvasSize.y)
{
pos.y -= maxYVal - curCanvasSize.y;
}

if (minXVal < 0)
pos.x -= minXVal;
if (minYVal < 0)
pos.y -= minYVal;
rectT.anchoredPosition = pos;
dragStartPos = curDragStartPos;
}

public void OnEndDrag(PointerEventData eventData)
{
}
}
}

补充说明

        即使是上面的这段代码,我也没有在很多特殊的场景下测试过。我也只是在自己的电脑上进行了尝试。所以我真的不能保证是没有问题的,如果你有问题请帮忙指出。我会努力修改的。当然如果是因为版本变换而出现的问题,那我就无能为力了。我本身较懒,我也不想去维护两个版本的代码。