公司动态
Unity TextMeshPro超链接实现可点击文本:5分钟搞定交互式UI
1. 项目概述为什么Unity原生UI文本不够用在Unity里做UI新手大概率是从UI - Text开始的。简单拖个Text组件改改字体、颜色、大小文本就显示出来了。但稍微深入一点你就会发现这个原生的Text组件简直是“功能简陋”的代名词。字体模糊、排版混乱、特效支持差更别提实现什么“可点击文本”或者“鼠标悬停变色”这种稍微带点交互的需求了。你可能会想那我给Text外面套个Button组件不就行了确实可以但这意味着每个可点击的文本都需要一个完整的Button GameObject层级结构瞬间变得臃肿性能开销增加而且灵活性极差——你很难实现一段文字里只有几个词能点击的效果。这就是TextMeshPro简称TMP登场的原因。它本质上是一个功能极其强大的文本渲染系统完全替代了Unity的原生UI Text和旧版TextMesh。它带来的最直观好处就是矢量字体渲染永不模糊无论你把它放大多少倍边缘都清晰锐利。但对我们开发者而言它真正的宝藏在于其超链接Hyperlink功能。这个功能允许你在同一段文本中将任意字符或词语标记为一个可交互的区域并为其绑定点击、悬停等事件。这完美契合了“可点击文本”的需求比如游戏里的技能说明、对话选项、用户协议链接或者任何需要局部交互的文本场景。所以这个“5分钟搞定”的项目核心就是利用TextMeshPro的Hyperlink功能结合Unity的EventSystem快速构建一套轻量、高效、美观的可交互文本系统。它不仅仅是“能点”还要“点得好看”有悬停反馈这才是完整的用户体验。2. 环境准备与TextMeshPro基础配置2.1 安装与导入TextMeshPro Essentials首先确保你的Unity版本支持Package Manager。从2018.3版本开始TextMeshPro就已经作为官方支持的包来管理了。打开Package Manager在Unity编辑器顶部菜单栏点击Window - Package Manager。切换视图在Package Manager窗口左上角将视图从In Project切换到Unity Registry。这样你就能看到Unity官方维护的所有可用包。搜索与安装在搜索框中输入TextMeshPro列表中会出现TextMeshPro这个包。选中它点击右下角的Install按钮。Unity会自动下载并安装这个包及其依赖。注意安装过程中Unity可能会弹出一个名为“Import TMP Essentials”的对话框。务必点击“Import”。这个操作会导入TMP运行所必需的核心资源包括默认字体材质、样式表和着色器。如果错过了这个对话框你可以在Window - TextMeshPro - Import TMP Essential Resources手动导入。安装并导入资源后你会在GameObject - UI的菜单下看到新增的TextMeshPro - Text和TextMeshPro - Input Field选项。这标志着TMP已经就绪。2.2 创建你的第一个TMP文本对象让我们创建一个基础的TMP文本对象来熟悉一下。在Hierarchy面板右键选择UI - TextMeshPro - Text。Unity会自动为你创建一个Canvas和一个名为TextMeshPro - Text的GameObject。选中这个新建的TMP文本对象查看Inspector面板。你会看到TextMeshPro - Text (UI)组件其界面比原生Text丰富得多。在Text Input文本框里输入一些测试文字比如“欢迎来到Unity TextMeshPro世界”。尝试调整Font Asset、Font Size、Color等属性并观察Scene视图和Game视图的变化。你会发现无论怎么缩放文字始终清晰。到这里基础环境就搭建好了。但此时的文本还只是静态的显示元素不具备任何交互能力。接下来我们将进入核心部分——让文本“活”起来。3. 核心原理TextMeshPro超链接与事件系统3.1 超链接标签的语法与解析TextMeshPro实现可点击文本的魔法源于其富文本标签系统。对于超链接它使用link标签。其基本语法是link idYourLinkID可点击的文本/link例如如果你想将“用户协议”四个字做成可点击的你的文本内容应该这样写请仔细阅读我们的link idUserAgreement用户协议/link以了解相关条款。idUserLinkID是核心。这个ID是一个自定义的字符串标识符它有两个关键作用标记范围它告诉TMPlink和/link之间的文本是一个独立的可交互区域。事件传递当用户与这个区域交互点击、悬停时TMP会把这个ID作为参数传递给相应的事件函数。这样我们就可以在代码里根据不同的ID来执行不同的逻辑。TMP在渲染时会解析这些标签并为每个link包裹的区域计算一个包围盒Rect并记录其ID。当输入事件如鼠标点击发生时EventSystem会与TMP组件协作判断点击位置是否落在某个链接的包围盒内如果是则触发链接点击事件。3.2 事件响应的桥梁TMP_TextEventHandlerUnity的UGUI系统通过EventTrigger组件或实现IPointerClickHandler等接口来响应事件。对于TMP的超链接我们需要一个专门的“桥梁”来监听和转发这些链接级别的事件。Unity官方并没有提供一个开箱即用的组件来完成这件事但我们可以自己创建一个。通常我们会创建一个名为TMP_TextEventHandler或ClickableText的脚本挂载到我们的TMP文本对象上。这个脚本的核心职责是获取到该GameObject上的TextMeshProUGUI组件引用。订阅TMP组件提供的链接特定事件主要是OnPointerClick用于点击和利用OnPointerEnter/OnPointerExit来模拟悬停。在事件触发时通过TMP提供的方法如TMP_TextUtilities.FindIntersectingLink来判断交互点是否落在某个链接上并提取出该链接的ID。最后调用我们自定义的事件如OnLinkClicked并将链接ID传递出去或者直接执行与ID对应的业务逻辑。下面我们就来一步步实现这个核心脚本。4. 实操过程实现可点击与悬停效果4.1 创建可点击文本脚本在Project面板中右键选择Create - C# Script将其命名为ClickableTextPro。双击打开进行编辑。using UnityEngine; using UnityEngine.EventSystems; using TMPro; using UnityEngine.Events; // 定义一个自定义事件类型用于传递链接ID [System.Serializable] public class LinkClickEvent : UnityEventstring { } public class ClickableTextPro : MonoBehaviour, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler { // 对外暴露的TMP组件引用方便在Inspector中关联或代码获取 public TextMeshProUGUI targetText; // 自定义的链接点击事件可以在Inspector中动态绑定方法 public LinkClickEvent onLinkClicked; // 悬停时链接文本的颜色 public Color hoverColor new Color(0.3f, 0.6f, 1.0f, 1.0f); // 浅蓝色 // 存储链接的原始颜色用于悬停离开后恢复 private Color[] originalLinkColors; // 存储当前正在悬停的链接索引 private int hoveringLinkIndex -1; void Start() { // 如果未手动指定尝试从当前GameObject上获取 if (targetText null) { targetText GetComponentTextMeshProUGUI(); } if (targetText null) { Debug.LogError(ClickableTextPro: 未找到TextMeshProUGUI组件, this); enabled false; // 禁用脚本 return; } // 初始化颜色存储数组 originalLinkColors new Color[0]; } // 处理鼠标点击事件 public void OnPointerClick(PointerEventData eventData) { if (targetText null) return; // 获取点击位置在文本矩形内的局部坐标 Vector3 mousePosition eventData.position; Camera eventCamera eventData.pressEventCamera; // 对于UI通常是Canvas的渲染相机 // 核心方法查找与点击位置相交的链接 int linkIndex TMP_TextUtilities.FindIntersectingLink(targetText, mousePosition, eventCamera); if (linkIndex ! -1) // 找到了一个链接 { // 通过索引获取链接信息 TMP_LinkInfo linkInfo targetText.textInfo.linkInfo[linkIndex]; string linkId linkInfo.GetLinkID(); // 提取我们在标签中设置的ID Debug.Log($链接被点击ID: {linkId}); // 触发自定义事件传递链接ID onLinkClicked?.Invoke(linkId); // 这里也可以使用switch或字典来根据linkId执行不同逻辑 // HandleLinkClick(linkId); } } // 处理鼠标进入事件用于悬停效果 public void OnPointerEnter(PointerEventData eventData) { // 悬停逻辑主要在OnPointerMove中处理但这里可以初始化状态 hoveringLinkIndex -1; } // 处理鼠标移动事件持续检测悬停 // 注意IPointerEnterHandler接口不包含Move我们需要在Update中模拟或者用更复杂的方式。 // 为了简化我们可以在OnPointerEnter和OnPointerExit之间在Update里检测。 // 但更高效的做法是利用OnPointerEnter/Exit来设置一个检测标志这里我们用另一种常见方法 // 在OnPointerEnter时开始检测在OnPointerExit时停止并恢复。 // 由于Unity UI事件系统没有直接的悬停持续事件我们下面采用一个在Update中检测的简化方案。 void Update() { if (targetText null) return; // 获取当前鼠标位置屏幕坐标 Vector3 mousePos Input.mousePosition; // 将屏幕坐标转换到Text的局部坐标需要Canvas的渲染模式支持 // 注意这个方法对于Screen Space - Overlay模式的Canvas是有效的。 // 对于其他模式需要更复杂的转换。这里假设是Overlay模式。 RectTransformUtility.ScreenPointToLocalPointInRectangle( targetText.rectTransform, mousePos, null, // Overlay模式相机为null out Vector2 localMousePos ); // 检测当前鼠标位置下的链接 int currentLinkIndex TMP_TextUtilities.FindIntersectingLink(targetText, mousePos, null); // 如果悬停的链接发生了变化 if (currentLinkIndex ! hoveringLinkIndex) { // 先恢复之前悬停链接的颜色 RestoreLinkColor(); // 更新当前悬停链接索引 hoveringLinkIndex currentLinkIndex; // 如果鼠标移到了一个新的链接上应用悬停颜色 if (hoveringLinkIndex ! -1) { ApplyHoverColorToLink(hoveringLinkIndex); } } } // 处理鼠标离开事件 public void OnPointerExit(PointerEventData eventData) { // 鼠标离开文本区域恢复所有链接颜色并重置索引 RestoreLinkColor(); hoveringLinkIndex -1; } // 应用悬停颜色到指定链接 private void ApplyHoverColorToLink(int linkIndex) { TMP_LinkInfo linkInfo targetText.textInfo.linkInfo[linkIndex]; // 确保颜色数组足够大 if (originalLinkColors.Length linkIndex) { System.Array.Resize(ref originalLinkColors, linkIndex 1); } // 遍历该链接包含的所有字符 for (int i 0; i linkInfo.linkTextLength; i) { int characterIndex linkInfo.linkTextfirstCharacterIndex i; if (characterIndex targetText.textInfo.characterInfo.Length) break; int meshIndex targetText.textInfo.characterInfo[characterIndex].materialReferenceIndex; int vertexIndex targetText.textInfo.characterInfo[characterIndex].vertexIndex; Color32[] vertexColors targetText.textInfo.meshInfo[meshIndex].colors32; // 存储原始颜色只存第一个顶点的因为一个字符的四个顶点颜色通常相同 if (i 0) // 简单处理只存一次 { originalLinkColors[linkIndex] vertexColors[vertexIndex]; } // 应用悬停颜色 vertexColors[vertexIndex] hoverColor; vertexColors[vertexIndex 1] hoverColor; vertexColors[vertexIndex 2] hoverColor; vertexColors[vertexIndex 3] hoverColor; } // 通知TMP需要更新顶点数据 targetText.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); } // 恢复指定链接的颜色如果当前有悬停的链接 private void RestoreLinkColor() { if (hoveringLinkIndex ! -1 hoveringLinkIndex originalLinkColors.Length) { TMP_LinkInfo linkInfo targetText.textInfo.linkInfo[hoveringLinkIndex]; Color32 originalColor originalLinkColors[hoveringLinkIndex]; for (int i 0; i linkInfo.linkTextLength; i) { int characterIndex linkInfo.linkTextfirstCharacterIndex i; if (characterIndex targetText.textInfo.characterInfo.Length) break; int meshIndex targetText.textInfo.characterInfo[characterIndex].materialReferenceIndex; int vertexIndex targetText.textInfo.characterInfo[characterIndex].vertexIndex; Color32[] vertexColors targetText.textInfo.meshInfo[meshIndex].colors32; vertexColors[vertexIndex] originalColor; vertexColors[vertexIndex 1] originalColor; vertexColors[vertexIndex 2] originalColor; vertexColors[vertexIndex 3] originalColor; } targetText.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32); } } // 示例根据链接ID处理点击逻辑 private void HandleLinkClick(string linkId) { switch (linkId) { case UserAgreement: Debug.Log(打开用户协议页面...); // Application.OpenURL(https://yourwebsite.com/agreement); break; case PrivacyPolicy: Debug.Log(打开隐私政策页面...); // Application.OpenURL(https://yourwebsite.com/privacy); break; case StartGame: Debug.Log(开始游戏); // SceneManager.LoadScene(GameScene); break; default: Debug.LogWarning($未知的链接ID: {linkId}); break; } } }4.2 在Unity编辑器中配置与测试脚本编写完成后回到Unity编辑器。创建UI如果你的场景里还没有Canvas创建一个GameObject - UI - Canvas。确保其Render Mode为Screen Space - Overlay以简化坐标转换。创建TMP文本在Canvas下创建一个TextMeshPro - Text对象命名为ClickableText。挂载脚本将我们刚写好的ClickableTextPro脚本拖拽到ClickableText对象上。配置组件脚本的Target Text字段会自动关联上同对象的TextMeshProUGUI组件。你可以调整Hover Color为你喜欢的颜色。编写带链接的文本在TextMeshProUGUI组件的Text输入框中输入以下内容欢迎体验请阅读link idUserAgreement用户协议/link和link idPrivacyPolicy隐私政策/link然后link idStartGame开始游戏/link。运行测试点击播放按钮。将鼠标移动到“用户协议”上你应该能看到它变成你设置的悬停颜色。点击它Console窗口会输出“链接被点击ID: UserAgreement”。同时由于我们脚本里onLinkClicked事件目前没有绑定任何方法所以只会执行HandleLinkClick中的Debug逻辑。4.3 使用UnityEvent进行动态绑定我们脚本中定义的public LinkClickEvent onLinkClicked是一个UnityEvent。这允许我们不修改代码直接在Inspector中为不同的链接点击事件绑定不同的响应函数非常灵活。在ClickableText对象的Inspector中找到ClickableTextPro组件。你会看到On Link Clicked (String)事件列表。点击号添加一个新的回调。将需要响应此事件的对象比如一个管理UI的GameObject拖到None (Object)区域。在下拉菜单中选择该对象上脚本的某个函数。例如你可以选择一个GameManager脚本的OnUserAgreementClicked方法。这样当任何链接被点击时你绑定的函数就会被调用并且传入链接的ID字符串。你可以在函数内部根据ID来判断具体执行什么操作。这种方法实现了表现层文本与逻辑层点击响应的解耦是更推荐的做法。5. 进阶优化与性能考量5.1 悬停检测的性能优化我们上面的实现在Update中每帧都进行链接相交检测这在文本链接很多或项目性能敏感时可能成为瓶颈。一个更优化的方案是使用协程Coroutine进行节流检测不必每帧都检测可以每0.1秒检测一次因为鼠标移动速度是有限的。private IEnumerator CheckHoverCoroutine() { while (true) { if (isPointerInside) // 需要用一个布尔值在OnPointerEnter/Exit中控制 { // ... 执行检测逻辑 ... } yield return new WaitForSeconds(0.1f); // 100毫秒检测一次 } }仅在鼠标移动时检测监听IPointerMoveHandler接口的OnPointerMove事件只有鼠标在文本区域内移动时才进行检测离开区域后停止。public void OnPointerMove(PointerEventData eventData) { // 在这里执行FindIntersectingLink检测 int currentLinkIndex TMP_TextUtilities.FindIntersectingLink(targetText, eventData.position, eventData.pressEventCamera); // ... 后续颜色更新逻辑 ... }这种方法比在Update中检测更高效但需要处理好OnPointerExit时恢复颜色的逻辑。5.2 支持多样式与下划线效果单纯的变色可能不够醒目。我们可以修改悬停逻辑为链接添加下划线、改变字体样式等。添加下划线TMP本身支持通过标签u添加下划线。我们可以在检测到悬停时动态修改文本内容为对应的链接区域包裹上u标签。但这涉及到字符串的频繁操作和文本的重解析性能开销较大且需要精确记录原始文本和链接位置实现复杂。更实用的方法——使用材质属性块MaterialPropertyBlock对于高级效果如描边、发光我们可以通过修改链接所在字符的材质属性来实现。但这属于更高级的Shader编程范畴对于简单的悬停颜色变化通常已足够。预定义样式TMP的Font Asset允许你定义多种字体样式如加粗、斜体、不同颜色。你可以在设计时创建好“默认链接样式”和“悬停链接样式”然后在代码中通过切换样式索引来实现效果切换。这需要预先在字体资产中配置好。5.3 处理富文本与复杂布局如果你的文本内容动态生成或者包含其他富文本标签如b,i,color需要确保link标签的正确嵌套和解析。TMP的富文本解析器遵循XML-like的规则标签必须正确闭合。一个常见的问题是动态生成的链接文本中可能包含特殊字符如,这会破坏标签解析。在将字符串赋值给targetText.text前需要使用System.Security.SecurityElement.Escape或自定义方法对文本进行转义。string linkText 点击这里; string linkId DynamicLink; // 错误如果linkText包含会破坏XML结构 // string richText $请link id\{linkId}\{linkText}/link; // 正确应先转义 string escapedText EscapeForRichText(linkText); // 需要自己实现转义函数 string richText $请link id\{linkId}\{escapedText}/link; targetText.text richText;6. 常见问题与排查技巧实录在实际使用中你可能会遇到以下问题问题1链接点击了没反应Console也没有输出。检查1EventSystem是否存在可交互的UI必须依赖于EventSystem。检查场景中是否有EventSystemGameObject通常创建Canvas时会自动生成。如果没有通过GameObject - UI - Event System创建一个。检查2Raycast Target是否开启确保你的TextMeshProUGUI组件上Raycast Target复选框是勾选的。这是组件接收点击事件的前提。检查3是否有其他UI元素遮挡检查链接区域是否被其他带有Image且Raycast Target开启的UI元素完全覆盖。可以通过暂时隐藏其他UI元素来排查。检查4脚本事件绑定是否正确确保ClickableTextPro脚本已正确挂载且targetText字段指向了正确的TMP组件。问题2悬停颜色效果错乱或者恢复不到原来的颜色。原因这通常是由于顶点颜色修改逻辑不严谨造成的。我们的示例代码为了清晰做了简化只存储了链接的第一个字符颜色。在极端情况下如果两个链接的顶点颜色缓存索引冲突就会出错。解决方案更精确的颜色存储不要只存一个颜色而是为每个链接的每个字符存储原始颜色。可以定义一个Dictionaryint, Color32[]键是链接索引值是该链接所有字符的原始颜色数组。在修改前强制更新文本信息在ApplyHoverColorToLink之前调用targetText.ForceMeshUpdate()。确保textInfo中的数据是最新的。因为TMP的网格更新有时是延迟的。使用TMP自带的颜色标签一个更稳定但略有限制的方法是不直接修改顶点颜色而是通过修改文本字符串动态添加或移除color标签。例如悬停时用color#4D96FF包裹链接文本。这避免了直接操作顶点数据但会触发文本重解析。问题3在Scroll View或Mask下的链接无法点击。原因TMP_TextUtilities.FindIntersectingLink使用的坐标是屏幕坐标并且计算相交时会考虑文本的矩形变换RectTransform范围。如果文本被父级的Mask或Scroll Rect裁剪落在裁剪区域外的链接部分即使鼠标在屏幕坐标上位于其“理论位置”也会因为被裁剪而无法被检测到。解决方案这是一个已知限制。一种解决思路是对于可滚动的文本可以考虑将每个链接单独提取出来作为独立的、带Collider的UI元素来处理但这失去了TMP富文本排版的便利性。另一种方法是接受这个限制确保可点击链接出现在可视区域内。问题4打包后尤其是WebGL链接交互失效。检查平台差异Input.mousePosition在不同平台如Standalone, WebGL, Mobile的坐标系可能略有差异。确保你的坐标转换逻辑是平台无关的。对于UI尽量使用EventSystem.current提供的PointerEventData中的位置信息它通常已经过系统处理。WebGL的输入延迟WebGL平台由于运行在浏览器中输入事件可能有轻微延迟。如果感觉点击响应不灵敏可以适当增加点击判定的时间容差但TMP内部没有提供这个参数。问题5动态更改文本后旧的链接仍然响应。原因当你直接修改targetText.text时TMP会重新解析文本并生成新的网格。但是我们脚本中可能还缓存着之前链接的索引或颜色信息如hoveringLinkIndex,originalLinkColors。解决方案在每次修改targetText.text之后或之前重置脚本中的缓存状态。public void SetText(string newTextWithLinks) { if (targetText ! null) { // 恢复当前可能存在的悬停效果 RestoreLinkColor(); hoveringLinkIndex -1; // 清空颜色缓存 originalLinkColors new Color[0]; // 设置新文本 targetText.text newTextWithLinks; // 强制立即更新网格以便新的链接信息可用 targetText.ForceMeshUpdate(); } }最后分享一个我个人的调试小技巧在开发阶段可以在OnPointerClick方法里不仅输出链接ID还把linkInfo.GetLinkText()也打印出来。这能帮你确认点击的确实是预期的文本范围避免因标签嵌套错误或空格等问题导致的链接范围偏差。