UGUI拖拽效果实现2

前言

        之前我个人写过一个版本的拖拽效果实现,但是后面在一些场景中发现了一些问题,比如:应用窗口任意变换的时候,拖拽效果的设定显得麻烦,且会照成跟不上鼠标的问题;如果拖拽的物体本身的情况复杂,比如其锚点和其UI对齐格式奇葩或者其父节点也存在问题,那么判断是否在屏幕的方法就会错误,等等。

        所以,为了解决这些问题,我又写了一个新的版本,这次我将会详细介绍一下这个版本的实现。

环境

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

正文

        这个版本的实现,仍然是基于UGUI本身提供的事件接口IBeginDragHandlerIDragHandlerIEndDragHandler。这次我还额外多加了IPointerDownHandler,这个接口。至于为什么我要多加这个接口,这个我在后面来说明。与前面的版本不同的是,这次的版本我并不考虑是否在屏幕外,我仅考虑UI是否在画布内。如果你要实现是否在屏幕内,其实就是将这个限制移动的画布设定为主画布就好了。特别说明,这次的版本我在UI为RenderMode.ScreenSpaceCamera模式下测试过。完整的代码如下所示:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Test
{
[RequireComponent(typeof(Image))]
public class DragArea : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerDownHandler
{
// 做限制的画布
[SerializeField]
private Canvas refCanvas;

// UI相机
[SerializeField]
private Camera uiCamera;

// 偏移量
private Vector2 offset;
// 组件RectTransform
private RectTransform rectTransform;
// 画布的RectTransform
private RectTransform canvasRectTransform;

private bool ready = false;

// 是否可交互,默认可以交互
private bool interoperable = true;

public bool Interoperable
{
get{ return interoperable; }
set{ interoperable = value; }
}
// 是否锁定在画布内
public bool LockedInCanvas { get; set; }

// 各各情况下的事件
public Action<PointerEventData> StartDrag;
public Action<PointerEventData> Draging;
public Action<PointerEventData> DragEnd;

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

if (!ready)
{
refCanvas = GetComponentInParent<Canvas>();
if (refCanvas != null)
{
canvasRectTransform = refCanvas.GetComponent<RectTransform>();
// 这里要注意不一定所有的画布都有相机的
// 这里我只是尝试取相机
uiCamera = refCanvas.worldCamera;
}
}
}

// 设定画布,和UI相机
public void SetCanvasInfo(Canvas canvas,Camera uiCamera)
{
refCanvas = canvas;
canvasRectTransform = refCanvas.GetComponent<RectTransform>();
this.uiCamera = uiCamera;
ready = true;
}

public void OnBeginDrag(PointerEventData eventData)
{

}

public void OnDrag(PointerEventData eventData)
{
// 如果可交互并准备好
if (Interoperable && ready)
{
// 执行拖拽事件
Draging?.Invoke(eventData);

// 算出鼠标相对于当前UI轴心点的偏移量
Vector2 pos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, uiCamera, out pos);

// 减去之前点击时鼠标和UI轴心点的偏移量,使得UI和鼠标一开始的相对位置不变
pos -= offset;
// 加上当前UI的轴心点得到UI真正的位置
pos += rectTransform.anchoredPosition;
rectTransform.anchoredPosition = pos;
// 限制在画布内
if (LockedInCanvas)
{
// 获取到当前UI轴心点在窗口中的位置
var screenPos = eventData.position - offset;
// 获取这个轴心点相对于画布的轴心点的位置
Vector2 uiPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRectTransform, screenPos, uiCamera, out uiPos);
var rect = this.rectTransform.rect;
var lockedArea = canvasRectTransform.sizeDelta;
// 得到相对于画布最左下点位的偏移量
uiPos.x += lockedArea.x * this.canvasRectTransform.pivot.x;
uiPos.y += lockedArea.y * this.canvasRectTransform.pivot.y;
// 判断是否超出画布边界
float maxXVal = uiPos.x + rect.width * (1 - this.rectTransform.pivot.x);
float maxYVal = uiPos.y + rect.height * (1 - this.rectTransform.pivot.y);
float minXVal = uiPos.x - rect.width * this.rectTransform.pivot.x;
float minYVal = uiPos.y - rect.height * this.rectTransform.pivot.y;
if (maxXVal > lockedArea.x)
{
pos.x -= maxXVal - lockedArea.x;
}

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

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

public void OnEndDrag(PointerEventData eventData)
{
DragEnd?.Invoke(eventData);
}

public void OnPointerDown(PointerEventData eventData)
{
// 判断是否准备完毕
if (refCanvas == null)
{
Debug.LogError("DragArea: Canvas is null!");
ready = false;
return;
}

if (uiCamera == null)
{
Debug.LogError("DragArea: uiCamera is null!");
ready = false;
return;
}
ready = true;
if (Interoperable)
{
StartDrag?.Invoke(eventData);
// 记录鼠标和UI点位的偏移量
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, uiCamera, out offset);
}
}
}
}

这里你可以发现,我将初始判断是否拖拽放到了IPointerDownHandler所要实现的函数OnPointerDown下。这是因为当开始调用OnBeginDrag时,鼠标其实要进行一定的偏移才会触发。如果这时候游戏卡顿就会照成鼠标与UI之间位置有着较大的偏移。当然如果你只要UI的点位和鼠标的点位重叠在一起,那么就在代码中将这个偏移值对应的逻辑删除。其实计算这个偏移值也是有很大的坑点,这个我放在后文讲述,因此如果你并没有这样的要求最好是不要加上偏移的。这里我额外说明一下UI的点位,代码中我们使用RectTransformUtility.ScreenPointToLocalPointInRectangle计算出来的偏差其实和UI轴心点(pivot)之间的偏差。

        我们将UI限制在画布中时,首先我们要先知道UI轴心点在这个画布中的位置。然后我们再用窗口中的位置得到UI轴心点在画布中的位置。因为我以画布的左下为原点,所以我要把现在得到的点转换为相对于画布左下点的位置。然后我们再通过加减UI的整体大小来得到UI在画布中的范围,最后我们将UI超出的部分移除,其位置限制便在画布内。我这里使用了鼠标位置(eventData.position)减去偏移量(offset)得到的位置便得到UI轴心点在窗口的位置。或许你有点奇怪,我们都有相机的情况下,我们完全可以使用相机提供的WorldToScreenPoint函数来实现我们想要的效果。这里我也有点疑惑,我个人认为是位置更新并没有实时更新,所以导致了WorldToScreenPoint函数的结果有问题。但是这个只是我的猜测,具体的原因我还没能找出来。

        而正是因为我使用了偏移量导致了另外一个问题。当UI缩放和主画布缩放不一致的情况下,eventData.position - offset不会是UI轴心点在窗口的位置。这是因为无论你如何缩放UI,你使用RectTransformUtility.ScreenPointToLocalPointInRectangle所得到的偏移值只会限制在UI设定的大小内即RectTransform.sizeDelta范围内,这个大家可以自行试验一下。而我们用rect获取到的值也不会随着UI缩放而改变。因此我们要用另外的办法计算这个偏移值,但是原本的偏移值仍然有用。因为我们拖拽赋值仍然使用的是RectTransformUtility.ScreenPointToLocalPointInRectangle函数得到的值,所以原先使用RectTransformUtility.ScreenPointToLocalPointInRectangle得到的偏移值仍然有用。下面是我重新修改后的计算偏移值的方法:

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
public void OnPointerDown(PointerEventData eventData)
{
// 判断是否准备完毕
if (refCanvas == null)
{
Debug.LogError("DragArea: Canvas is null!");
ready = false;
return;
}

if (uiCamera == null)
{
Debug.LogError("DragArea: uiCamera is null!");
ready = false;
return;
}
ready = true;
if (Interoperable)
{
StartDrag?.Invoke(eventData);
// 记录鼠标和UI点位的偏移量
var screenPos = uiCamera.WorldToScreenPoint(rectTransform.position);
offset.x = eventData.position.x - screenPos.x;
offset.y = eventData.position.y - screenPos.y;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, uiCamera, out rectOffset);
}
}
这里我建之前的offset变量变为了rectOffset,而offset用来计算在窗口下的偏移值。因为缩放不仅仅在计算偏移值时会导致问题,在后续计算是否超出画布边界时也会导致问题。这里我直接将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
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Test
{
[RequireComponent(typeof(Image))]
public class DragArea : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IPointerDownHandler
{
// 做限制的画布
[SerializeField]
private Canvas refCanvas;

// UI相机
[SerializeField]
private Camera uiCamera;

// 偏移量
private Vector2 offset;
// rect的偏移量
private Vector2 rectOffset;
// 组件RectTransform
private RectTransform rectTransform;
// 画布的RectTransform
private RectTransform canvasRectTransform;

private bool ready = false;

// 是否可交互,默认可以交互
private bool interoperable = true;

public bool Interoperable
{
get { return interoperable; }
set { interoperable = value; }
}
// 是否锁定在画布内
public bool LockedInCanvas { get; set; }

// 各各情况下的事件
public Action<PointerEventData> StartDrag;
public Action<PointerEventData> Draging;
public Action<PointerEventData> DragEnd;

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

if (!ready)
{
refCanvas = GetComponentInParent<Canvas>();
if (refCanvas != null)
{
canvasRectTransform = refCanvas.GetComponent<RectTransform>();
// 这里要注意不一定所有的画布都有相机的
// 这里我只是尝试取相机
uiCamera = refCanvas.worldCamera;
}
}
}

// 设定画布,和UI相机
public void SetCanvasInfo(Canvas canvas, Camera uiCamera)
{
refCanvas = canvas;
canvasRectTransform = refCanvas.GetComponent<RectTransform>();
this.uiCamera = uiCamera;
ready = true;
}

public void OnBeginDrag(PointerEventData eventData)
{

}

public void OnDrag(PointerEventData eventData)
{
// 如果可交互并准备好
if (Interoperable && ready)
{
// 执行拖拽事件
Draging?.Invoke(eventData);

// 算出鼠标相对于当前UI轴心点的偏移量
Vector2 pos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, uiCamera, out pos);

// 减去之前点击时鼠标和UI轴心点的偏移量,使得UI和鼠标一开始的相对位置不变
pos -= rectOffset;
// 加上当前UI的轴心点得到UI真正的位置
pos += rectTransform.anchoredPosition;
rectTransform.anchoredPosition = pos;
// 限制在画布内
if (LockedInCanvas)
{
// 获取到当前UI轴心点在窗口中的位置
var screenPos = eventData.position - offset;
// 获取这个轴心点相对于画布的轴心点的位置
Vector2 uiPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRectTransform, screenPos, uiCamera, out uiPos);
// 统一缩放
var rectWorldSize = rectTransform.rect.size * rectTransform.lossyScale;
var lockedArea = canvasRectTransform.sizeDelta;
// 得到相对于画布最左下点位的偏移量
uiPos.x += lockedArea.x * this.canvasRectTransform.pivot.x;
uiPos.y += lockedArea.y * this.canvasRectTransform.pivot.y;
// 统一缩放
lockedArea *= canvasRectTransform.lossyScale;
uiPos *= canvasRectTransform.lossyScale;
// 判断是否超出画布边界
float maxXVal = uiPos.x + rectWorldSize.x * (1 - this.rectTransform.pivot.x);
float maxYVal = uiPos.y + rectWorldSize.y * (1 - this.rectTransform.pivot.y);
float minXVal = uiPos.x - rectWorldSize.x * this.rectTransform.pivot.x;
float minYVal = uiPos.y - rectWorldSize.y * this.rectTransform.pivot.y;

// 将多余出来的值乘以缩放反向加回来
if (maxXVal > lockedArea.x)
{
pos.x -= (maxXVal - lockedArea.x) / rectTransform.lossyScale.x;
}

if (maxYVal > lockedArea.y)
{
pos.y -= (maxYVal - lockedArea.y) / rectTransform.lossyScale.y;
}

if (minXVal < 0)
pos.x -= minXVal / rectTransform.lossyScale.x;
if (minYVal < 0)
pos.y -= minYVal / rectTransform.lossyScale.y;
rectTransform.anchoredPosition = pos;
}
}
}

public void OnEndDrag(PointerEventData eventData)
{
DragEnd?.Invoke(eventData);
}

public void OnPointerDown(PointerEventData eventData)
{
// 判断是否准备完毕
if (refCanvas == null)
{
Debug.LogError("DragArea: Canvas is null!");
ready = false;
return;
}

if (uiCamera == null)
{
Debug.LogError("DragArea: uiCamera is null!");
ready = false;
return;
}
ready = true;
if (Interoperable)
{
StartDrag?.Invoke(eventData);
// 记录鼠标和UI点位的偏移量
var screenPos = uiCamera.WorldToScreenPoint(rectTransform.position);
offset.x = eventData.position.x - screenPos.x;
offset.y = eventData.position.y - screenPos.y;
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, uiCamera, out rectOffset);
}
}
}
}

总结

        这里我做两个实现,我认为这些都很有用。第一个实现需要画布,UI和主画布全部缩放一致下使用,虽然限制很多,但是它性能比第二个来得好。所以这里我将这两个实现都放出来,大家按照各自项目的需求来选择使用。现在我暂时没遇到新的问题了,但是我觉得可能还有问题。如果还有问题欢迎直接私信告知我。毕竟我并没有在很多复杂的场景下进行使用,所以我也不知道还有没有新的问题。