ScrollRect 制作摇杆

使用 ScrollRect 组件可以很容易的制作摇杆。 新建脚本继承ScrollRect,重写OnDrag,在其中检测摇杆距离。 注意要同时开启 vertical 和 horizontal 两个方向。 完整脚本如下:

  
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class Joystick : ScrollRect
{
    public Vector2 Pos;
    private Scrollbar bar;
    protected float mRadius = 0f;
    private bool isDragging;    
    
    protected override void Start()
    {
        base.Start();
        // 摇杆块的半径 

        mRadius = ((RectTransform) transform).sizeDelta.x * 0.5f;
    }

    public override void OnDrag(PointerEventData eventData)
    {
        base.OnDrag(eventData);
        isDragging = true;

        // 限制在摇杆块范围内

        var contentPostion = this.content.anchoredPosition;
        if (contentPostion.magnitude > mRadius)
        {
            contentPostion = contentPostion.normalized * mRadius;
            SetContentAnchoredPosition(contentPostion);
        }
        // 更新位置

        Pos = contentPostion.normalized;
    }

    public override void OnEndDrag(PointerEventData eventData) 
    { 
        base.OnEndDrag(eventData);
        isDragging = false;
        checkTime = Time.time;
    }

    private float checkTime;
    private void Update()
    {
        // 松手后,更新回到中心过程的位置

        if (isDragging) return;
        
        if (Time.time - checkTime < 1)
        {
            Pos = new Vector2(content.anchoredPosition.x / mRadius, content.anchoredPosition.y / mRadius);
        }
        else
        {
            Pos = Vector2.zero;
        }
    }
}


UI点击渗透

有时好几个UI组件叠在一起,希望点击最上层,下面的也能响应到。 继承事件监听接口,在实现事件中,都用EventSystem.current.RaycastAll()方法找的所有可以响应的对象,最后用 ExecuteEvents.Execute() 响应事件。

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

public class MultiLayerEvent : MonoBehaviour, IPointerClickHandler, IPointerDownHandler, IPointerUpHandler
{
    public void OnPointerDown(PointerEventData eventData)
    {
        PassEvent(eventData, ExecuteEvents.pointerDownHandler);
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        PassEvent(eventData, ExecuteEvents.pointerUpHandler);
    }
    
    public void OnPointerClick(PointerEventData eventData)
    {
        PassEvent(eventData, ExecuteEvents.submitHandler);
        PassEvent(eventData, ExecuteEvents.pointerClickHandler);
    }

    //把事件透下去

    public void PassEvent<T>(PointerEventData data, ExecuteEvents.EventFunction<T> function)
        where T : IEventSystemHandler
    {
        List<RaycastResult> results = new List<RaycastResult>();
        EventSystem.current.RaycastAll(data, results);
        GameObject current = data.pointerCurrentRaycast.gameObject;
        for (int i = 0; i < results.Count; i++)
        {
            if (current != results[i].gameObject)
            {
                ExecuteEvents.Execute(results[i].gameObject, data, function);
                // 如果你只想响应穿透的第一个,直接break就行

                //break;

            }
        }
    }
}


用于新手引导的遮罩

新手引导经常需要一种效果,就是目标物体透明,周围遮住的遮罩:

一个简单的做法就是,在前面放一个覆盖整个屏幕大小的图片遮罩,利用上面的事件渗透方法检测点击,然后修改默认的 UI shader,根据像素点与目标的距离判断是否显示。

首先是脚本文件,用于更新shader信息。

  
using UnityEngine;
using UnityEngine.UI;

public class Script_05_11 : MonoBehaviour
{
	public RectTransform CanvasRt;
	public Camera UICamera;

	public bool ShrinkAnim = true;
	public bool FollowTarget;
	
	private float radius;
	private float range;
	private Vector3 center;
	private readonly Vector3[] corners = new Vector3[4]; 

	private Material material;
	private RectTransform target;
	
	void Awake ()
	{
		material = GetComponent<Image>().material;
	}

	public void ResetTarget(RectTransform rt)
	{
		if (rt == null) return;
		target = rt;		
		UpdateCenter();
		UpdateRange();
	}

	// 更新位置

	void UpdateCenter()
	{
		if (target == null) return;
		
		target.GetWorldCorners(corners);
		radius = Vector2.Distance(corners[0], corners[2]) / 2f;

		float x = corners[0].x + (corners[3].x - corners[0].x) / 2f;
		float y = corners[0].y + (corners[1].y - corners[0].y) / 2f;
		center = new Vector3(x, y, 0);
		Vector2 centerPos;
		RectTransformUtility.ScreenPointToLocalPointInRectangle(CanvasRt, center, UICamera, out centerPos);

		material.SetVector("_Center", new Vector4(centerPos.x, centerPos.y, 0, 0));
	}

	// 缩小动画

	void UpdateRange()
	{
		if (!ShrinkAnim) return;
		CanvasRt.GetWorldCorners(corners);
		foreach (var c in corners)
		{
			range = Mathf.Max(Vector3.Distance(c, center), range);
		}

		material.SetFloat("_Silder", range);
		needShrink = true;
	}

	private float velocity;
	private bool needShrink;
	void Update()
	{
		if (ShrinkAnim && needShrink)
		{
			float value = Mathf.SmoothDamp(range, radius, ref velocity, 0.3f);
			if (!Mathf.Approximately(value, range))
			{
				range = value;
				material.SetFloat("_Silder", range);
			}
			else
			{
				needShrink = false;
			}
		}
		else if(FollowTarget)
		{
			UpdateCenter();
		}
	}
}

调用 ResetTarget() 更新目标,目前只实现了实时更新位置,如果需要,也可以实时更新大小。
接着是 shader 文件,我们可以从官网上下载内置的shader,UI默认的是 UI-Default.shader 文件,只需要进行简单的修改即可。

首先在 Properties 增加参数

 
Properties
{
    ...
    _Center("Center", vector) = (0, 0, 0, 0)
    _Silder ("_Silder", Range (0,1000)) = 1000
}

然后不要忘记在 Pass 内声明,最后在 frag 函数里增加两句,根据distance 函数判断距离,从而决定像素点是否透明。

  
Pass
{
    Name "Default"
    ...

    float _Silder;
    float2 _Center;

    ...

    fixed4 frag(v2f IN) : SV_Target
    {
        ... 
        
        color.a *= (distance(IN.worldPosition.xy,_Center.xy) > _Silder);
        color.rgb *= color.a;            
        return color;        
    }
}  


MenuItem 用于扩展菜单选项,而且还可以拓展 Project 和 Hierarchy 窗口的菜单。

  
// 在菜单 Window下 添加一个选项

[MenuItem("Window/My Window")]
static void MenuItemTest1() { ... }
  
// 当路径为Assets,则在 Project 右键菜单 添加一个选项

[MenuItem("Assets/My Tool")]
static void MenuItemTest2() { ... }
  
// 当路径为GameObject,且priority 在前面,则在 Hierarchy Create 添加一个选项

[MenuItem("GameObject/My Tool", false, 0)]
static void MenuItemTest3() { ... }
全局自定义快捷键

在 MenuItem 路径后可以设置自定义快捷键:

1
2
3
4
5
6
%: ctrl(windows) / command(macOS)  
#: shift  
&: alt  
UP/DOWN/LEFT/RIGHT: 上下左右 四个按键
F1...F12: F1 到 F12
HOME, END, PGUP, PGDN

如快捷键是 Ctrl + g:
[MenuItem(“MyMenu/Do Something with a Shortcut Key %g”)]

重写 Hierarchy 自带菜单功能

通过MneuItem重写同名的选项, 实现新建一个Image,并且默认不开启 raycastTarget:

  
[MenuItem("GameObject/UI/Image")]
static void CreatImage()
{
    Transform parent = null;
    if(Selection.activeTransform && Selection.activeTransform.GetComponentInParent<Canvas>())
    {
        parent = Selection.activeTransform;
    }
    else
    {
        Canvas canvas = Object.FindObjectOfType<Canvas>();
        if (canvas == null)
        {
            Debug.LogError("please create a canvas first!");
            return;
        }
        parent = canvas.transform;
    }

    Image image = new GameObject("image").AddComponent<Image>();
    image.raycastTarget = false;
    image.transform.SetParent(parent, false);
    Selection.activeTransform = image.transform;
}


拓展 Project 和 Hierarchy 中Item的视图

主要依靠委托实现:

1
EditorApplication.projectWindowItemOnGUI
:Project Window 中每个可见物体的OnGUI事件委托
1
EditorApplication.hierarchyWindowItemOnGUI
:Hierarchy Window 中每个可见物体的OnGUI事件委托

1
[InitializeOnLoadMethod] 属性
: 可以确保每次编译代码后首先调用。

在物体旁边加一个按钮:

Project 窗口:

  
[InitializeOnLoadMethod]
static void ExtensionProjectButton()
{
    EditorApplication.projectWindowItemOnGUI = (guid, rect) =>
    {
        string selectGuid = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(Selection.activeObject));
        if (Selection.activeObject && guid == selectGuid)
        {
            float width = 40;
            rect.x += rect.width - width;
            rect.width = width;
            
            if (GUI.Button(rect, "Click"))
            {
                Debug.Log("click: " + Selection.activeObject.name);
            }
        }
    };
}

Hierarchy 窗口:

  
[InitializeOnLoadMethod]
static void ExtensionHierarchyButton()
{
    EditorApplication.hierarchyWindowItemOnGUI = (id, rect) =>
    {
        if (Selection.activeObject && id == Selection.activeObject.GetInstanceID())
        {
            float width = 50f;
            rect.x += rect.width - width;
            rect.width = width;
            
            // btn.png 是Asset下的一张图片

            if (GUI.Button(rect, AssetDatabase.LoadAssetAtPath<Texture>("Assets/btn.png")))
            {
                Debug.LogFormat("click : {0}", Selection.activeObject.name);
            }
        }
    };
}


监听资源修改事件

继承 UnityEditor.AssetModificationProcessor 类, 重写监听资源的方法:
IsOpenForEdit
OnWillCreateAsset
OnWillDeleteAsset
OnWillMoveAsset
OnWillSaveAssets

扩展 Inspector 窗口

Unity将许多 Editor 绘制方法都放入了dll中,为了增加自己扩展的内容,又能保持原有界面的绘制,我们可以使用反射获取dll内函数,从而实现此功能。

给Transform组件增加一个按钮:

  
[CustomEditor(typeof(Transform))]
public class EditorTest4 : Editor
{
    private Editor m_Editor;

    void OnEnable()
    {
        m_Editor = CreateEditor(target, Assembly.GetAssembly(typeof(Editor)).GetType("UnityEditor.TransformInspector", true));
    }

    public override void OnInspectorGUI()
    {
        if (GUILayout.Button("拓展按钮")) { }
        //调用系统绘制方法

        m_Editor.OnInspectorGUI();
    }
}



可编辑状态

有时我们可以看到 Inspector界面是灰色不可编辑的,实现的方法有几种:

1 设置 GUI.enabled, 如上面代码改为,则 Transform 不可编辑:

  
GUI.enabled = false;
m_Editor.OnInspectorGUI();
GUI.enabled = true;

2 设置物体或者组件的 HideFlags为 NotEditable.

Inspector 组件的 Context菜单

点击组件的设置按钮(或右键),会弹出菜单,这个菜单也可以扩展,同样使用 MenuItem:

  
[MenuItem("CONTEXT/Transform/New Context")]
public static void NewContext1(MenuCommand command )
{	
    //获取对象名

    Debug.Log (command.context.name);
}

对于自己实现的脚本组件,也可以在其中直接实现相关Context功能,如下重写 Remove:

  
[ContextMenu("Remove Component")]
void RemoveComponent()
{
    Debug.Log("Remove Component");
    //防止代码同步错误,延迟一帧调用

    UnityEditor.EditorApplication.delayCall = () => DestroyImmediate(this);
}


扩展 Scene 窗口

扩展选中的组件,继承Editor, 实现OnSceneGUI(),并在 Handles.BeginGUI() 和 Handles.EndGUI() 之间实现自定义界面:

  
[CustomEditor(typeof(Camera))]
public class EditorTest5 : Editor
{
    void OnSceneGUI()
    {
        Camera camera = target as Camera;
        if (camera != null)
        {
            Handles.BeginGUI();
            if (GUILayout.Button("click", GUILayout.Width(60f)))
            {
                Debug.LogFormat("click = {0}", camera.name);
            }
            Handles.EndGUI();
        }
    }
}

全局扩展,实现 SceneView.onSceneGUIDelegate 委托:

  
[InitializeOnLoadMethod]
static void InitializeOnLoadMethod()
{
    SceneView.onSceneGUIDelegate = sceneView =>
    {
        Handles.BeginGUI();
        GUI.Label(new Rect(0f, 0f, 50f, 15f), "标题");
        Handles.EndGUI();
    };
}
[SelectionBase] 属性

给组件增加此属性,可保证在 Scene 中点击其子物体,也只能选择到此父物体。

Inspector 面板扩展

对于自定义脚本的 Inspector 面板,一般是直接用 public 可序列化变量来显示的。为了扩展,可以使用 EditorGUI 进行绘制。
EditorGUI 和 GUI 用法基本一致,提供了大量组件,如文本、按钮、图片等。要绘制 EditorGUI, 需要调用

1
[CustomEditor(typeof(YourType))]
属性,并继承 Editor,重写 OnInspectorGUI 函数。

一个完整示例:

  
using UnityEngine;
using UnityEditor;

public class EditorTest6 : MonoBehaviour
{
    public int myId;
    public string myName;
    public GameObject prefab;
    public bool toogle1;
    public bool toogle2;

    public enum MyEnum
    {
        One = 1,
        Two,
    }
    public MyEnum myEnum = MyEnum.One;
}

#if UNITY_EDITOR
[CustomEditor(typeof(EditorTest6))]
public class ScriptEditorTest6 :Editor
{
    Vector3 scrollPos = Vector3.zero;
    private bool m_EnableToogle;
    
    public override void OnInspectorGUI()
    {
        //获取脚本对象

        EditorTest6 script = target as EditorTest6;
        if(script == null) return;
        
        //绘制滚动条

        scrollPos =EditorGUILayout.BeginScrollView(scrollPos, false, true);

        script.myName = EditorGUILayout.TextField("text", script.myName);
        script.myId = EditorGUILayout.IntField("int", script.myId);
        script.prefab =
            EditorGUILayout.ObjectField("GameObject", script.prefab, typeof(GameObject), true) as GameObject;

        //绘制按钮

        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("1"))
        {
            script.myEnum = EditorTest6.MyEnum.One;
        }
        if (GUILayout.Button("2"))
        {
            script.myEnum = EditorTest6.MyEnum.Two;
        }
        
        //绘制Enum

        script.myEnum = (EditorTest6.MyEnum) EditorGUILayout.EnumPopup("MyEnum:", script.myEnum);
        EditorGUILayout.EndHorizontal();
        
        //Toogle组件

        m_EnableToogle = EditorGUILayout.BeginToggleGroup("EnableToogle", m_EnableToogle);
        script.toogle1 = EditorGUILayout.Toggle("toogle1", script.toogle1);
        script.toogle2 = EditorGUILayout.Toggle("toogle2", script.toogle2);
        EditorGUILayout.EndToggleGroup();

        EditorGUILayout.EndScrollView();
    }
}
#endif

EditorWindow 窗口

我们也可以通过Unity Editor 实现自己的窗口,进行更丰富的交互操作。这是通过 EditorWindow 实现,其实Unity自身的窗口也是如此实现的。
要实现窗口功能,需要继承EditorWindow类。

使用 EditorWindow.GetWindow() 可以打开窗口。

  
[MenuItem("Window/My Window")]
public static void ShowWindow()
{
    //EditorWindow.GetWindowWithRect(typeof(MyWindow), new Rect(50, 100, 600, 600));

    EditorWindow.GetWindow(typeof(MyWindow), false, "My Window");
}

可以在 OnGUI() 中绘制窗口。

同时可以通过 OnAwake, OnDestroy, OnFocus, OnLostFocus, OnHierarchyChange, OnInspectorUpdate, OnProjectChange, OnSelectionChange 等事件监控窗口状态。 具体查看Editor window 官方文档

另有本人一个开源项目,实现了一个EXCEL 读取的窗口,可以参考。

在自定义窗口中显示 Preview

有时想在自定义窗口显示预览窗口,查看模型等资源,可以通过 Editor.OnPreviewGUI 实现。

  
using UnityEngine;
using UnityEditor;

public class EditorTest7 : EditorWindow
{
    private GameObject m_MyGo;
    private Editor m_MyEditor;

    [MenuItem("Window/Open My Window")]
    static void Init()
    {
        GetWindow(typeof(EditorTest7)).Show();
    }
    
    void OnGUI() {
        
        //设置一个游戏对象

        m_MyGo = (GameObject) EditorGUILayout.ObjectField(m_MyGo, typeof(GameObject), true);

        if (m_MyGo != null) {
            if (m_MyEditor == null) {
                //创建Editor实例

                m_MyEditor = Editor.CreateEditor (m_MyGo);
            }
            //预览它

            m_MyEditor.OnPreviewGUI(GUILayoutUtility.GetRect(500, 500), EditorStyles.whiteLabel);
        }
    }
}

点击按钮过程

我们通过点击一个Button组件为例,了解一下UGUI怎么实现点击检测的。

首先我们知道要实现点击等交互功能,场景中必须有 EventSystem 组件。可以看到其上挂载了EventSystem和 StandaloneInputModule 两个脚本。

在 EventSystem 的 Update() 函数中,m_CurrentInputModule.Process() 调用了InputModule的执行函数,而在 StandaloneInputModule 中则通过override对其进行了具体的实现:

我们查看 ProcessMouseEvent(),最终会调用ProcessMousePress函数,而这个函数中就进入了点击检测的关键:

该函数会执行ExecuteEvents脚本的 Execute静态函数。

Execute函数:

我们可以知道,pointerEvent.pointerDrag 就是我们点击的物体,pointerEvent是 eventData,而 ExecuteEvents.pointerClickHandler 则是点击委托,我们在ExecuteEvents中可以看到其定义:

回头查看Execute函数,从而我们了解到,GetEventList 会在target 物体上查找继承了 IEventSystemHandler 接口的组件,然后对每个组件执行委托函数。对于此时的点击事件,就执行上面的 pointerClickHandler。

综上,我们可以知道,只要组件继承了 IEventSystemHandler, 在不同情况(点击、抬起、拖动等)触发时,就会执行对应接口的相应委托事件,从而实现事件交互。

获取组件

我们再思考一下,为什么EventSystem可以知道我们点击到了组件,或者说事件系统是如何获得点击的物体的。

我们回头看 StandaloneInputModule 中的 ProcessMouseEvent 函数,其中第一句执行了 var mouseData = GetMousePointerEventData(id); ,进入函数可以知道这是其父类实现的一个函数。

在其中我们看到一个熟悉的函数,eventSystem.RaycastAll(leftData, m_RaycastResultCache); 之前我们就说过,事件点击的本质就是射线检测,因此很可能这就是实现射线检测的地方。 进入后可以看到其中调用了 modules 的 Raycast 函数, 而modules就是 挂载在 Canvas 上的 GraphicRaycaster 组件,这样解释了为什么没有此组件也无法实现点击交互。

最终我们到达了射线检测的核心部分,Raycast函数。先根据Canvas Render Mode 及Block相关设置进行射线检测。然后调用了重载函数Raycast,遍历Canvas下每个Graphic组件,把鼠标位置点击到的组件加入列表。进行 ignoreReversedGraphics 的操作。最后获取每个组件的信息。

ps. 通过 GraphicRaycaster 源码可以详细了解 Blocking Objects 和 Blocking Mask的作用。通过 blockingObjects 和 m_BlockingMask 的设置进行射线检测,会获得一个hitDistance,这个距离是到阻塞物体的距离。之后又会计算物体到摄像机的距离 distance, 如果distance 大于 hitDistance,这个物体就不算检测到,从而实现阻塞。

整个过程如下:

绘制过程

以Image为例进行分析。每次对图片进行更改的时候,都会调用 SetVerticesDirty 函数,在其中最主要是调用此函数 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);

这个函数会将绘制的元素加入到一个rebuild队列 m_GraphicRebuildQueue,对其的操作主要在 PerformUpdate 函数中进行,PerformUpdate 是一个注册到 Canvas.willRenderCanvases 的事件,根据官方手册描述,该事件会在Canvas rendering 前发生。

这是PerformUpdate中对 m_GraphicRebuildQueue队列的操作:

其中元素的 Rebuild函数是 ICanvasElement接口定义的,具体的实现位置是在 Graphic类中:

而在 UpdateGeometry() 中的函数 则执行了绘制的核心功能,类似我们上个例子中实现的那样,进行创建网格,设置参数等操作。 其中, OnPopulateMesh 是填充网格的函数,并且是虚函数,Image继承自Graphic,对其进行了override,可以看到,Image 分别对不同 Filltype 进行了不同的网格绘制:

Canvas Update 的过程:

另外,在 UpdateMaterial() 函数中,则对进行的材质、贴图进行了设置。

绘制和点击

UI模块的主要两大组成就是绘制和交互。
绘制的本质其实和绘制模型一样,就是对 2D mesh 进行渲染,UI图片就是贴图,设置成sprite只是为了方便打包图集及进行其他UI相关操作,而对图集图片的显示就是通过UV的控制。
点击的本质跟简单, 其实就是射线检测,摄像机发射射线,对是否点击到UI layer的物体进行检测。 据此,我们写一个简单例子了解一下。

代码实例

####创建物体 创建一个空物体,添加 MeshFilter、MeshRenderer 组件用于绘制,添加 MeshCollider 用于点击检测。

####绘制mesh

  
public Mesh mesh;
public MeshFilter meshFilter;
public VertexHelper vertexHelper;

void InitMesh()
{
    mesh = new Mesh();
    vertexHelper.Clear();
    // 添加矩形四个顶点

    vertexHelper.AddVert(new Vector2(0, 0), Color, new Vector2(0, 0));
    vertexHelper.AddVert(new Vector2(0, 1), Color, new Vector2(0, 1));
    vertexHelper.AddVert(new Vector2(1, 1), Color, new Vector2(1, 1));
    vertexHelper.AddVert(new Vector2(1, 0), Color, new Vector2(1, 0));
    // 指定绘制三角形

    vertexHelper.AddTriangle(0, 1, 2);
    vertexHelper.AddTriangle(2, 3, 0);
    // 填充mesh

    vertexHelper.FillMesh(mesh);

    meshFilter.mesh = mesh;
    meshCollider.sharedMesh = mesh;
}

VertexHelper 是Unity UI 的一个辅助类,可以帮助创建mesh等相关操作。利用此类,创建了一个四个顶点的矩形面片,然后设置两个三角形,以规定绘制的顺序,接着就是填充面片,设置参数。

接着实时更新 MeshRenderer 的参数,这样就能绘制出对应的颜色和图片。

public Color Color;
public Texture2D Texture;

// 在 Update函数调用

void UpdateRenderer()
{
    meshRenderer.material.color = Color;
    meshRenderer.material.mainTexture = Texture;
}

####点击检测 点击检测很简单,就是利用摄像机发出的射线实时检测。

public Camera Cam;    
// 在 Update函数调用

void ClickCheck()
{
    Ray ray = Cam.ScreenPointToRay(Input.mousePosition);
    RaycastHit hitInfo;
    if (Physics.Raycast(ray, out hitInfo))
    {
        if (Input.GetMouseButtonDown(0))
        {
            Debug.Log("click!");
        }
    }            
}

####总结 可以看到这个例子里,两个功能都实现的很简陋,UGUI真正实现肯定没这么简单,但是通过层层分析源码,可以发现最后实现的本质就是这样,理解这个例子对我们后面学习源码有很大帮助。