做过Unity项目的朋友应该都有体会,拖拽功能是那种“看起来五分钟,调起来两小时”的典型需求。玩家要拖背包里的装备、拖场景里的机关、拖面板上的滑块,甚至拖数字去调整数值——只要拖拽逻辑没处理好,就会弹出物体跟不上鼠标、UI点了没反应、拖完位置不对、手机上一指过去直接错乱这一堆妖毛病。这篇文章我把Unity里最常见的两类拖拽,也就是UGUI拖拽和3D物体拖拽,从原理到代码再到排错完整拆了一遍,内容全部来自实际项目的踩坑记录和验证结果。无论你是刚上手Unity的新手,还是项目做到一半回来查bug的开发者,这篇应该都能帮你少折腾半天。
1. 项目概述与核心思路
1.1 拖拽在Unity项目里的两种主要场景
Unity里的拖拽需求,绝大多数可以分成两类:一类是纯UI界面上的拖拽,比如背包里的物品拖到装备槽、卡牌拖到出战位、编辑器面板里拖控件调参。这类操作全程发生在屏幕空间,拖的是RectTransform,背后的技术栈是EventSystem + GraphicRaycaster,加上Unity提供的IDragHandler这一组接口。
另一类是3D场景里的拖拽,比如解谜游戏里搬箱子、策略游戏里拖单位到指定位置、关卡编辑器里摆模型。这类操作发生在世界空间,被拖的是Transform,背后的技术是鼠标射线和Collider碰撞检测。如果需求是“从UI背包里拖一个物品出来放到3D场景里生成”,那就要把两套逻辑结合起来,这种混合场景最容易出问题,后面我会重点讲。
想清楚这层区别再动手,就不会出现“把3D拖拽代码写到UI组件上”这种方向性错误。我见过不少新手上来直接给UI物体挂一个OnMouseDown脚本,结果怎么点都没反应,因为UGUI根本不走OnMouse这条事件链路。
1.2 选型背后的判断逻辑
判断用哪种方案,有一个很简单的方法:看目标最终存在于哪个坐标系。如果物体存在于Canvas下的RectTransform树里,就按UI拖拽处理;如果物体存在于场景的GameObject层级里,就按3D物体拖拽处理。
在具体选型时,我一般会优先考虑下面这个表格:
| 需求场景 | 推荐方案 | 核心依赖 |
|---|---|---|
| UI背包、快捷栏等界面拖拽 | UGUI事件接口(IBeginDragHandler等) | EventSystem、GraphicRaycaster |
| 3D场景搬动物体、摆放模型 | 鼠标/触摸射线 + Collider | Camera、Physics.Raycast、Collider |
| UI拖到场景、跨区域放置 | 混合方案:UI事件 + 射线投射到平面 | 前两者 + Plane投影 |
选型时还要把目标设备的输入方式考虑进去。纯编辑器里鼠标拖拽很好写,但到了手机上,多点触控、UI和场景点击冲突、触摸坐标换算都会冒出来。所以在项目初期就要决定:要不要封装统一的输入层,还是直接用Unity内置的输入管理器。如果项目比较简单,直接用Input.mousePosition加UI事件也行;但如果项目有复杂手势需求,我建议一开始就规划好输入抽象层,不然后期改起来非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拖拽背后的核心原理
2.1 EventSystem与UGUI事件接口的工作流程
UGUI拖拽能够工作,依赖的是EventSystem这套事件广播系统。你可以把它理解成一个服务员:当手指或鼠标按下时,Input模块把屏幕坐标交给EventSystem,EventSystem再询问场景里有谁想知道这个消息。它能问到的对象,必须是被GraphicRaycaster登记过的、带Graphic组件并且Raycast Target打开的UI元素。
事件命中目标后,EventSystem会把PointerEventData分发给目标物体上实现了对应接口的脚本。拖拽要用的三个接口就是IBeginDragHandler、IDragHandler、IEndDragHandler。注意,这三个接口必须挂在被拖拽物体自身的脚本上,或者挂在同一GameObject的其他脚本里,事件才会被分发到。
这个流程也决定了排错方向。如果UI拖拽没反应,首先检查三件事:场景里有没有EventSystem;被拖物体是不是在带GraphicRaycaster的Canvas下;被拖物体上有没有实现对应事件接口。这三项缺一个,拖拽都起不来。
2.2 坐标转换:屏幕、本地、世界坐标之间别搞混
拖拽里最容易把人绕晕的就是坐标。屏幕坐标(ScreenPoint)指的是像素位置,比如鼠标在屏幕的(300, 450)这个像素点。本地坐标(LocalPoint)是相对于某个RectTransform的坐标,UI拖拽最终赋值的anchoredPosition,就是相对于父物体锚点的本地坐标。世界坐标(WorldPoint)则是3D空间里的真实位置,比如场景里一个cube的position。
UI拖拽的标准做法是先拿到屏幕坐标,再用RectTransformUtility.ScreenPointToLocalPointInRectangle把它转换成Canvas下的本地坐标,然后把这个本地坐标赋值给被拖物体的anchoredPosition。这里有个关键细节:必须告诉这个转换函数,你是相对于哪个RectTransform算的。一般用Canvas自身的RectTransform,这样物体就能在整个Canvas范围内自由移动。
千万不要直接把屏幕坐标当世界坐标用,也不要直接把鼠标的ScreenPoint赋给Transform.position。做过这个操作的人应该都有印象:物体瞬间飞到场景里某个奇怪的地方去了。原理很简单,屏幕坐标是二维像素,世界坐标是三维空间点,两者之间没有直接换算关系,必须经过相机的ScreenToWorldPoint或者带上相机的视角变换。
2.3 射线与碰撞体,3D拖拽的一半原理在Physics里
3D物体拖拽没有现成的事件接口,本质上就是“每一帧把鼠标位置换算成世界坐标,然后赋给物体Transform”。但在这之前,你必须先让系统知道“用户确实点到了这个物体”。这个过程依赖射线检测:从相机发出一条射线,穿过鼠标所在屏幕点,打到场景中第一个碰到的碰撞体。
Camera.ScreenPointToRay(Input.mousePosition)会生成一条从相机出发、经过鼠标位置的射线,然后Physics.Raycast会沿着射线去检测所有带Collider的物体。没有Collider的物体,射线永远打不中,自然就拖不动。这是一个非常非常常见的错误,尤其是项目里用了粒子特效、网格但是忘了加Collider的情况。
而OnMouseDown/OnMouseDrag/OnMouseUp这套Unity内置方法,本质上是给Collider物体包装好的“鼠标事件”。Unity会在收到鼠标点击时,自动对点击位置做一次射线检测,然后把事件发给被命中的物体。如果在手机端用这套方法,Unity会把单点触摸模拟成鼠标事件,所以单指拖拽基本可用,但多点触控就完全不够用了。
3. UGUI拖拽从零实现
3.1 最简接口实现:三件套
写一个最基础的UI拖拽,只需要让脚本继承三个接口,然后在对应的回调里更新位置。
csharp复制using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class UIDragSimple : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
private RectTransform rectTransform;
private Canvas canvas;
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvas = GetComponentInParent<Canvas>();
}
public void OnBeginDrag(PointerEventData eventData)
{
// 拖拽开始,把当前UI物体放到同层级的最上层,避免被其他UI盖住
transform.SetAsLastSibling();
}
public void OnDrag(PointerEventData eventData)
{
// 把屏幕坐标转换为Canvas下的本地坐标
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform,
eventData.position,
canvas.worldCamera,
out Vector2 localPos))
{
rectTransform.anchoredPosition = localPos;
}
}
public void OnEndDrag(PointerEventData eventData)
{
// 停手后做什么,取决于业务逻辑,比如判断是否落在某个格子里
}
}
这段代码的核心就一句话:用RectTransformUtility把屏幕上的鼠标点,转换成Canvas下的本地坐标,再塞给anchoredPosition。注意,canvas.worldCamera在Canvas的RenderMode为Screen Space Overlay时是null,但传null进去函数也能正常工作。如果切换到了Screen Space Camera或者World Space模式,就必须在Canvas的Event Camera属性上挂一个正确的相机,否则坐标转换会得到错误结果。
3.2 为什么直接赋值anchoredPosition还会跳
上面的代码跑起来,你会发现一个很别扭的现象:鼠标按下的那一瞬间,物品“啪”一下跳到了鼠标正中央,然后才跟着鼠标走。这是因为你直接取鼠标在Canvas下的本地坐标,然后赋值给了物体的anchoredPosition,等于强制把物体的原点(默认是几何中心)放到鼠标点上。
正常情况下拖拽,鼠标点到物体上的位置应该和物体保持一个相对偏移,物体不会突然瞬移。解决办法是在OnBeginDrag时,记录鼠标位置和物体当前位置之间的偏移量,然后在OnDrag里每次加上这个偏移。
csharp复制public class UIDragWithOffset : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
private RectTransform rectTransform;
private Canvas canvas;
private Vector2 offset;
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvas = GetComponentInParent<Canvas>();
}
public void OnBeginDrag(PointerEventData eventData)
{
transform.SetAsLastSibling();
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform,
eventData.position,
canvas.worldCamera,
out Vector2 localPos))
{
offset = rectTransform.anchoredPosition - localPos;
}
}
public void OnDrag(PointerEventData eventData)
{
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform,
eventData.position,
canvas.worldCamera,
out Vector2 localPos))
{
rectTransform.anchoredPosition = localPos + offset;
}
}
public void OnEndDrag(PointerEventData eventData)
{
// 不再拖拽时可以根据落点做吸附
}
}
offset的计算逻辑是:物体当前在Canvas本地坐标系里的位置,减去鼠标点在该坐标系里的位置,得到的差值就是鼠标点到物体锚点之间的“抓取偏移”。拖拽时再用新的鼠标本地坐标加上这个偏移,物体就能保持最开始那个抓取点不跳。
3.3 拖拽到某个格子/区域的吸附与回弹
实际项目里拖拽的收尾动作一般有两种:拖到一个有效区域就吸附进去,拖到无效区域就回弹到原位置。判断是否落在某个格子,可以用RectTransformUtility.RectangleContainsScreenPoint,这个函数会检测一个屏幕坐标是否落在指定RectTransform的矩形范围内。
csharp复制public void OnEndDrag(PointerEventData eventData)
{
foreach (Transform slot in gridSlots)
{
RectTransform slotRect = slot as RectTransform;
if (slotRect != null &&
RectTransformUtility.RectangleContainsScreenPoint(
slotRect,
eventData.position,
canvas.worldCamera))
{
// 切换到格子的父物体下
transform.SetParent(slotRect, false);
// 吸附到格子中心
rectTransform.anchoredPosition = Vector2.zero;
return;
}
}
// 没有落在任何格子里,回弹到原位置
transform.position = originWorldPos;
}
这里有几个细节值得注意。SetParent的第二个参数worldPositionStays,传false表示保持本地坐标不变,但这句话在不同场景下表现很不一样。如果你要从背包拖到装备槽,两个槽位所在的Canvas结构不同,最好先用世界坐标保存原始位置,再在切换父物体后手动调整anchoredPosition。如果直接传true,Unity会尝试保持世界位置,但在有CanvasScaler缩放的UI下,位置关系很容易变得诡异。
另外,判断格子时,RectangleContainsScreenPoint默认会把屏幕坐标和格子坐标做比较,它不关心格子是否旋转。如果你的格子有旋转、缩放,就可能判断不准。此时更稳妥的做法是循环遍历格子,用每个格子的RectTransform.TransformPoint(rect.center)算出世界空间中心点,再用距离阈值判断。
4. 3D物体拖拽实现
4.1 使用内置OnMouse事件快速实现
3D拖拽最简单粗暴的写法,是用Unity自带的OnMouseDown和OnMouseDrag。只要被拖物体挂了Collider,然后脚本直接写在那个物体上,点击拖动就自动生效。
csharp复制using UnityEngine;
public class Draggable3D : MonoBehaviour
{
private Camera mainCamera;
private float depth;
private Vector3 offset;
private bool dragging;
private void Start()
{
mainCamera = Camera.main;
}
private void OnMouseDown()
{
dragging = true;
// 记录物体在屏幕坐标下的深度(即物体离相机多远)
depth = mainCamera.WorldToScreenPoint(transform.position).z;
offset = transform.position - GetWorldPos();
}
private void OnMouseDrag()
{
if (dragging)
{
transform.position = GetWorldPos() + offset;
}
}
private void OnMouseUp()
{
dragging = false;
}
private Vector3 GetWorldPos()
{
// 鼠标位置和物体的屏幕深度组合成一个屏幕点,再转回世界坐标
Vector3 mousePos = Input.mousePosition;
mousePos.z = depth;
return mainCamera.ScreenToWorldPoint(mousePos);
}
}
关键点在于depth的计算。当鼠标点在屏幕上移动时,我们要把鼠标所在的屏幕点映射回物体的深度位置,否则物体会贴着相机乱飞。先记录物体在屏幕坐标下的z值,然后在每次拖拽时让鼠标的屏幕点也带上同样的z值,再通过ScreenToWorldPoint得到世界坐标。
这套方案的问题也很明显:每个物体都要挂一个Collider,而且要保证脚本挂在带Collider的那个GameObject上,不能挂在父物体上。OnMouse系列方法在事件分发时,是通过命中物体本身来查找脚本的,如果Collider在子物体而脚本在父物体,事件就不会触发。
4.2 用Plane投影拖动到固定平面
有很多游戏根本不需要物体在3D空间内自由移动,只允许物体在一个平面上滑动,比如地板、桌面、合成台。这时用ScreenToWorldPoint的深度法反而不方便,更好的办法是用Plane做投影。
csharp复制using UnityEngine;
public class PlaneDraggable : MonoBehaviour
{
private Camera mainCamera;
private Plane plane;
private Vector3 offset;
private bool dragging;
private void Start()
{
mainCamera = Camera.main;
// 这里以Y=0平面为例,可以通过参数控制高度
plane = new Plane(Vector3.up, Vector3.zero);
}
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
if (TryGetHitPoint(out Vector3 hit))
{
offset = transform.position - hit;
dragging = true;
}
}
else if (Input.GetMouseButtonUp(0))
{
dragging = false;
}
}
private void FixedUpdate()
{
if (dragging && TryGetHitPoint(out Vector3 hit))
{
transform.position = hit + offset;
}
}
private bool TryGetHitPoint(out Vector3 point)
{
point = Vector3.zero;
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
if (plane.Raycast(ray, out float enter))
{
point = ray.GetPoint(enter);
return true;
}
return false;
}
}
这样拖出来的物体严格在大地板的平面坐标上移动,不会出现高度漂移。而且这个方案不依赖Collider命中,因为拖拽判定是用射线和无限平面求交,即使物体本身没有碰撞体也能拖。适合做沙盘、地图编辑器这类需求。
如果你拖拽的同时希望物体碰到什么障碍物就停住,那就不能用这个简单版了,得用Physics.Raycast加Rigidbody做物理推挤,复杂度会高不少。这里先不展开。
4.3 触控与多点触控
移动端如果只用上面那个Mouse方案,单指拖动还行,因为Unity默认把第一根手指模拟成鼠标。但一旦用户用第二根手指去操作别的UI,Input.mousePosition只会跟踪第一根手指,就会出现串线、误拖。
处理触控拖拽时,我建议在拖拽开始阶段就记住touch的fingerId,后续每一帧只搜索手指列表里fingerId匹配的那个touch,用它的position作为拖拽坐标。不能写死Input.GetTouch(0),因为手指顺序不固定。
csharp复制private int dragFingerId = -1;
private void Update()
{
for (int i = 0; i < Input.touchCount; i++)
{
Touch touch = Input.GetTouch(i);
if (dragFingerId == -1 && touch.phase == TouchPhase.Began)
{
// 这里可以加碰撞检测,确认手指是否点到了可拖物体
dragFingerId = touch.fingerId;
}
if (touch.fingerId == dragFingerId)
{
// 把touch.position作为当前拖拽坐标
}
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
{
if (dragFingerId == touch.fingerId)
{
dragFingerId = -1;
}
}
}
}
同时要注意,手指落到UI上时,不应该触发3D拖拽。在触控环境下,EventSystem.current.IsPointerOverGameObject(fingerId)这个方法可以判断某个手指是否在UI区域上。鼠标环境下不传参数是判断鼠标,但触控环境必须带fingerId。用这个方法做个前置过滤,能避免很多类型冲突的bug。
5. 排错经验与常见问题实录
5.1 UI拖拽完全不响应
如果给UI物体加了代码,运行起来点击却没有任何反应,我习惯按这个顺序排查:
- 场景里有没有EventSystem。新建场景时Unity通常会带一个,但如果手工创建过空场景,很容易漏掉。
- 被拖物体所在Canvas的根节点有没有GraphicRaycaster。一个空Canvas默认只有Canvas和CanvasScaler,没有GraphicRaycaster,UI是收不到任何事件的。
- 被拖物体上有没有Graphic组件。Image、Text、RawImage都算,但空物体不算。没有Graphic,Raycaster不知道往哪投。
- 脚本是否实现了IDragHandler接口,方法名是否拼写正确。接口方法写错或者访问级别不是public,事件分不到。
- 物品上是否挂有CanvasGroup而且把Block Raycasts关了。有些框架为了让UI不挡射线会关掉这个,结果拖拽也被挡掉了。
还有一个很多人忽略的点:如果鼠标按下时点到的位置不是物体上任何一个Graphic像素,而是透明区域,事件可能不会命中。Image组件如果Source Image留空,默认仍然有可点击矩形,但如果你在Image的alpha设置为0且关闭Raycast Target,GraphicRaycaster不会理它。
5.2 3D拖拽不生效
3D拖拽不生效,最常见的直接原因是物体没有Collider。你可以直接在GameObject层级上看组件,没有Box Collider或Mesh Collider的话,鼠标射线永远打不中。其次要检查射线所在的Layer有没有被相机或脚本的Layer Mask排除掉。如果相机Culling Mask只渲染部分层,射线倒是不受影响,但Physics.Raycast可以指定layerMask,很多人写着写着就把层过滤掉了,导致只有特定层的物体可以拖。
OnMouse系列还有一个隐藏坑:如果物体上挂了CanvasGroup并关闭了Block Raycasts,这只影响UI事件,不影响OnMouse。但如果场景里有一个全屏的透明Image挡住了鼠标,鼠标射线打到这个Image上,GraphicRaycaster先截获了输入,OnMouse就不会触发。所以3D拖拽被UI挡住的概率非常大,表现就是“明明Collider都在,就是拖不动”。
5.3 拖拽过程抖动/跳变/位置不对
位置跳变基本都跟坐标转换有关。先检查代码里是不是直接用Input.mousePosition给Transform.position赋值。屏幕坐标是世界坐标的一个子集,这样赋过去,物体会飞到相机前方非常远或者非常近的位置。
还有一个很隐蔽的点:如果UI物体在带CanvasScaler的画布下,直接给rectTransform.anchoredPosition赋一个未经换算的屏幕坐标,会受Canvas缩放因子影响。比如CanvasScaler设成缩放模式为Scale With Screen Size,UI实际尺寸和设备分辨率不一致,那么屏幕坐标必须转成Canvas本地坐标才能用。用RectTransformUtility转换就是为了处理这一步。
如果物体在拖拽过程中出现左右抖动,可能是OnDrag和OnMouseDrag同时启用,两套逻辑抢着改坐标。还有一种情况是拖拽代码写在Update里,但又被Rigidbody的物理更新干扰了位置,物体在物理系统和拖拽系统之间来回拉扯。遇到这种情况,最好把拖拽的坐标更新放到FixedUpdate,或者在拖拽期间禁用Rigidbody的物理模拟。
5.4 UI挡住了3D场景的点击
这是3D拖拽最常见的“灵异事件”。本质上是因为UI层在事件系统里优先消费了输入,鼠标点下去时GraphicRaycaster先命中了一个透明或可见的Image,事件停在UI层,3D物体收不到。
最简单的过滤方式就是在拖拽开始的地方加判断:
csharp复制if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
{
return;
}
但是要注意,鼠标环境下IsPointerOverGameObject()传空参即可,手机触控环境必须传手指id,否则即使你的手指没有按UI,只要屏幕上有UI元素,它都可能返回true。写法是EventSystem.current.IsPointerOverGameObject(touch.fingerId)。
如果项目里同时存在多个Canvas,比如战斗UI和主界面UI,要把它们的GraphicRaycaster统一管理。有时候User Interface层盖住了世界空间,但你再往上翻一层,可能还有一个TextMeshPro加了Raycast Target,挡住了整个场景。遇到这种问题可以把不需要接收事件的UI组件Raycast Target全部关掉。
5.5 拖拽结束时闪回或无法吸附
闪回通常出现在OnEndDrag里做父物体切换之后。原因是你先改了parent,Unity会重新计算localPosition,结果之前保存的坐标在新父物体坐标系下失效了。处理方法是先保存世界坐标,切换parent后,用世界坐标赋值回transform.position,然后再读rectTransform.anchoredPosition。
另一个常见问题是吸附格子判断不精确。格子如果有Padding或ContentSizeFitter调整过尺寸,矩形区域会发生变化,RectangleContainsScreenPoint判断时使用的RectTransform.rect是本地坐标,但函数内部会把它转换到世界空间,理论上应该没问题。但如果你在Canvas子物体上使用,且Canvas有旋转,还是要多留个心眼,可以临时用Debug.DrawLine把格子的四个角在世界空间里画出来,检查矩形是否和预期一致。
5.6 移动端触摸串线、手指漂移
移动端最常见的问题,是用户开始拖一个物体,中途另一根手指也按到屏幕上,结果拖拽坐标突然跳到第二根手指的位置。原因就是用了Input.mousePosition或者Input.GetTouch(0)来取坐标。
解决思路前面已经提过:拖拽开始那一刻记录fingerId,之后每一帧从Input.touches里找到这个fingerId,只用这个touch的position。如果拖拽过程中这根手指提前抬起,就结束拖拽;如果另一根手指也按到同一个物体上,也不应该让物体被第二根手指接管。
多点触控下还要注意EventSystem的Touch Input Module里的Send Pointer Hover To Parent、Drag Threshold这些参数。数值设得太小,手指稍微抖一下就变成拖拽;设得太大,移动距离很小的时候都识别不了。平时开发我一般把Drag Threshold设成10~15像素,这样在手机上按下稍微动一点不会误触拖拽。
6. 封装与优化建议
6.1 封装一个通用的UI拖拽组件
每次写拖拽都要重复offset、坐标转换、置顶这些琐碎逻辑,所以我后来会直接做一个通用组件,挂在任何需要拖拽的UI物体上,然后通过回调暴露给业务层。
csharp复制using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
public class UIDragComponent : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[System.Serializable]
public class DragEvent : UnityEvent<PointerEventData> { }
public DragEvent onBeginDrag;
public DragEvent onDrag;
public DragEvent onEndDrag;
private RectTransform rectTransform;
private Canvas canvas;
private Vector2 offset;
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
canvas = GetComponentInParent<Canvas>();
}
public void OnBeginDrag(PointerEventData eventData)
{
transform.SetAsLastSibling();
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform,
eventData.position,
canvas.worldCamera,
out Vector2 localPos))
{
offset = rectTransform.anchoredPosition - localPos;
}
onBeginDrag?.Invoke(eventData);
}
public void OnDrag(PointerEventData eventData)
{
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform,
eventData.position,
canvas.worldCamera,
out Vector2 localPos))
{
rectTransform.anchoredPosition = localPos + offset;
}
onDrag?.Invoke(eventData);
}
public void OnEndDrag(PointerEventData eventData)
{
onEndDrag?.Invoke(eventData);
}
}
这样业务代码只需要在Inspector面板里把onBeginDrag、onDrag、onEndDrag三个事件拖到对应方法上,不需要关心坐标转换和偏移怎么算。团队协作时,这个组件能减少很多低级重复劳动。
如果项目已经有UI框架,建议把这类通用组件和框架的层级管理结合起来,比如拖拽开始自动从当前页面节点下移到根节点,拖拽结束再放回原页面。这个操作能避免页面切换时拖拽物体被销毁的问题。
6.2 减少GC与性能小技巧
拖拽是高频率操作,OnDrag每一帧都会执行,所以性能问题不能忽略。我常用的优化点有三个:
第一,不要在OnDrag里new任何对象。RectTransformUtility.ScreenPointToLocalPointInRectangle的out参数是值类型,不会有GC压力;但你如果写一个返回Vector2的方法,内部产生临时对象,每帧几百次调用就很容易产生小内存垃圾。平时写代码能用out尽量out。
第二,缓存一切组件。GetComponent、GetComponentInParent在Awake里做一次,OnDrag里只做字段赋值。Camera.main在手游里会遍历场景,非常慢,最好在Start里缓存。如果场景里相机可能会切换,就在切换相机时更新缓存。
第三,拖拽时SetAsLastSibling只需在OnBeginDrag调用一次,不要在OnDrag里反复做。如果依赖这个置顶操作,OnDrag里每次都会触发布局重建,Canvas开ForceRebuildLayoutRequests的话性能会非常难看。
6.3 拖拽功能的扩展方向
一个成熟项目的拖拽系统,通常不止移动物体。我遇到过很多后续扩展需求:拖拽到某个区域时自动排序,拖拽一个物品到合成槽后消耗掉,拖拽过程中的缩放和旋转,拖拽结束生成3D模型,还有拖拽过程中实时显示预览虚线。
这些需求本质上都是在基础拖拽事件上加一层业务逻辑。最稳的做法是保持基础组件不变,把“拖拽开始、拖拽中、拖拽结束”三个时机和“世界坐标/本地坐标”暴露出来,业务层通过事件去监听。不要为了让某个需求适配,在通用组件里堆一堆Switch分支,那样最后维护成本会很高。
如果有精力,还可以把网格吸附做成单独的组件挂在格子上,物品拖过去时由格子决定“要不要收留”,而不是让物品自己遍历所有格子。这种控制反转能让拖拽逻辑的复用性提升不少。
我在实际项目里做过一个卡牌背包系统,最初为了图省事,只写了OnDrag直接赋值anchoredPosition,结果每次拖牌都会先跳一下,后来补了offset才解决。所以这篇里提到的偏移计算、CanvasGroup拦截、射线层过滤,几乎都是项目里真实踩过的坑。如果你现在遇到拖拽类的诡异问题,别急着怀疑引擎,先把事件链路、坐标转换、遮挡关系这三件事查一遍,大多数问题都能定位在某个方向上。之后再遇到新的拖拽需求,用那个通用组件当底子,你会发现在Unity里做拖拽其实比想象中省力很多。
