1. 项目概述:为什么我们需要扩充UGUI的富文本?
如果你在Unity里用UGUI的Text组件做过稍微复杂一点的UI,比如聊天框、技能描述或者带点样式的数值飘字,那你肯定对它的富文本功能又爱又恨。爱的是,它确实提供了一些基础能力,比如加粗、斜体、颜色、大小,几个简单的标签一包,就能让文字看起来不那么单调。恨的是,它的功能实在是太基础了,基础到几乎就是个“半成品”。
官方提供的富文本标签,翻来覆去就那么几个:<b>,<i>,<size>,<color>。你想给文字加个描边?不好意思,原生不支持,得靠Shader或者用TextMeshPro。你想在文字中间插入一张小图标,比如一个金币图标或者一个状态标志?原生也不支持,常见的做法是拆成两个Text组件拼起来,或者用更复杂的布局组件,维护起来非常麻烦。你想实现文字渐变色、文字抖动、文字逐个显示(打字机效果)并且每个字颜色还不一样?用原生的Text,要么写一堆脚本来拆字、算位置、动态生成子物体,性能开销大不说,代码也臃肿得不行。
这就是我们今天要讨论的核心:对Unity UGUI中Text组件的富文本功能进行扩充实现。这不是简单地替换成TextMeshPro(虽然TMP很强大),而是在现有UGUI Text的基础上,通过一套相对轻量、可控的扩展机制,让它能支持我们项目中那些“奇奇怪怪”但又非常实际的需求。比如,支持内联图片(Sprite)、支持自定义的样式标签(如描边、阴影、渐变)、甚至支持简单的动画效果。这背后的驱动力,是在保持UGUI轻量、易用特性的同时,弥补其表现力的不足,避免为了一个功能就引入庞大复杂的第三方解决方案,或者陷入手动拼接UI的泥潭。
2. 核心思路与方案选型:从标签解析到顶点篡改
扩充富文本,听起来好像要动UGUI的底层,其实核心思路可以分解为几个相对独立的步骤,我们一步步拆解。
2.1 总体架构设计
我们的目标是在不修改Unity源码的前提下,对UnityEngine.UI.Text组件进行功能增强。这意味着我们不能直接去改Text.OnPopulateMesh这个方法。更可行的思路是“拦截”和“加工”。
方案一:子类化与Mesh重写这是最直接也是我个人最推荐的方式。我们创建一个新的组件,比如叫RichText,它继承自UnityEngine.UI.Text。然后,我们重写关键的OnPopulateMesh(VertexHelper vh)方法。在这个方法里,我们首先调用基类(即原生Text)的OnPopulateMesh,让原生逻辑先生成基础的文本网格(顶点、UV、三角形)。然后,我们拿到这个VertexHelper对象,它里面已经包含了所有文字顶点的信息。接着,我们的核心工作就是:解析文本中的自定义富文本标签,并根据标签的含义,去修改VertexHelper中对应的顶点数据。
比如,我们解析到一个<icon name=coin>标签,我们就需要在这个文本位置,删除代表那个占位字符的顶点,然后插入代表图标Sprite的四个顶点(一个矩形)。如果我们解析到一个<gradient color=#FF0000,#00FF00>标签,我们就需要找到这个标签所影响的那段文字的顶点,逐个修改它们的顶点颜色,以实现渐变效果。
方案二:运行时解析与组件拼接这个方案相对“取巧”,但不够优雅。它不修改网格,而是在文本生成后,通过正则表达式或字符串解析,找出所有自定义标签。然后,根据标签位置,动态创建或启用/禁用子物体(比如Image组件用于显示图标,额外的Text组件用于特殊样式),并精确地定位到文本的相应位置。这种方法本质上是用多个标准UI组件来“模拟”富文本效果。它的优点是实现简单,无需深入顶点操作。但缺点非常明显:性能差(动态生成GameObject)、布局脆弱(依赖坐标计算,容易错位)、内存开销大,不适合文本内容频繁变动的场景。
方案三:预处理器与文本替换在设置Text的text属性之前,先对字符串进行预处理。例如,将<icon=coin>替换为一个特殊的Unicode字符(比如一个几乎不用的私有区域字符),然后为这个特殊字符指定一个特殊的字体,这个字体里对应的字形就是我们的图标。这其实就是一些图标字体(Icon Font)的原理。这种方法对于静态图标集成很不错,但灵活性较差,添加新图标需要重新生成字体文件,且难以支持动态效果和复杂样式。
综合来看,方案一(子类化与Mesh重写)在性能、灵活性和维护性上取得了最好的平衡。它一次性生成网格,效率高;通过操作顶点,可以实现几乎任何视觉效果;并且所有逻辑封装在一个组件内,使用起来和原生Text几乎无差别。我们后续的讨论也将基于这个方案展开。
2.2 关键技术点拆解
确定了方案,我们来看看要实现它,需要攻克哪些技术难关。
标签解析器:我们需要一个能高效、准确解析文本中嵌套标签的解析器。不仅要能识别出自定义标签(如
<gradient>),还要能正确处理与原生标签(如<color>)的混合使用,以及标签的嵌套关系。正则表达式在这里可能力不从心,特别是处理嵌套时。一个基于状态机或栈的简单解析器会更可靠。文本布局与字符信息获取:这是最棘手的部分之一。原生Text在
OnPopulateMesh中生成的顶点,是一连串的四边形(每个字符或每组相同样式的字符)。我们需要知道每个字符(或每组顶点)在文本中的逻辑位置(第几个字符)和几何信息(它的四个顶点在VertexHelper中的索引范围、它的屏幕位置、宽高等)。Unity没有直接提供这个API。我们需要通过TextGenerator类(Text内部使用的类)或者通过分析VertexHelper中的数据流来反推。顶点数据操作:
VertexHelper提供了添加、清除顶点的方法,但修改现有顶点比较麻烦。我们需要直接访问其内部的顶点列表(List<UIVertex>)和三角形列表(List<int>)来进行精细操作。这要求我们对UIVertex的结构(position, color, uv0, uv1等)有清晰的理解。与原生富文本的兼容:我们的扩充不能破坏原生富文本的功能。也就是说,一段同时包含
<color=red>和我们自定义<wave>标签的文本,应该能正确显示为红色的波浪形文字。这要求我们的标签解析和顶点修改逻辑必须考虑原生标签已经产生的影响。
3. 核心细节解析与实操要点
3.1 构建一个健壮的标签解析器
我们不能直接用string.Split或者简单的正则表达式,因为标签可能是嵌套的,比如<b><color=#FF0000>Hello</color></b>。我们需要知道</color>关闭的是哪个<color>。
一个实用的方法是使用栈(Stack)数据结构。遍历文本的每一个字符:
- 当遇到
<时,开始收集标签名和属性,直到遇到>。 - 如果是开始标签(如
<icon name="coin">),我们创建一个标签对象,记录其名称、属性、以及在原始字符串中的起始索引,然后将其压入栈中。 - 如果是结束标签(如
</icon>),我们从栈顶弹出最近的同类型开始标签,并记录这个标签对的结束索引。 - 对于自闭合标签(如
<br/>),直接生成一个标签对象,不需要入栈。
遍历完成后,我们就得到了一系列结构化的标签对象,每个对象都知道它影响原始字符串的哪一段范围(起始索引,结束索引)。这个“范围”信息至关重要,它是我们后续将标签映射到具体顶点的桥梁。
实操心得:在解析时,一定要跳过原生的、我们不打算处理的标签。我们可以维护一个“原生标签白名单”(
b,i,size,color等)。当解析到这些标签时,只记录其位置范围,但不进行任何自定义处理,这样它们就能被Unity原生逻辑正常渲染。我们的自定义处理器只处理白名单之外的标签。
3.2 获取字符与顶点的映射关系
这是整个扩充系统的“心脏”。我们需要在OnPopulateMesh被调用时,建立起“原始字符串中的字符索引”到“VertexHelper中顶点块索引”的映射。
一个相对可行但不完全精确的方法是依赖TextGenerator。在OnPopulateMesh之前,Text组件内部已经用TextGenerator生成了文本的布局信息。我们可以通过Text.cachedTextGenerator获取到IList<UIVertex>和IList<UICharInfo>。
UICharInfo包含了每个字符的宽度和位置(相对于文本原点的偏移)。UIVertex列表则是所有顶点按顺序排列。通常,每个字符(或每组连续相同样式的字符)对应4个顶点(一个四边形)和6个三角形索引(两个三角形)。
我们可以遍历UICharInfo,同时累加顶点索引。假设第一个字符从顶点0开始,它占用顶点0-3,那么第二个字符就从顶点4开始,以此类推。这样,我们就建立了一个粗略的映射:字符串索引 i -> 顶点起始索引i*4。
但是,这里有几个巨大的坑:
富文本标签占位符:原生富文本标签本身在
UICharInfo中是不占位置的!也就是说,字符串"A<b>B</b>C",UICharInfo里只有三个字符A、B、C的信息。标签<b>和</b>被跳过了。这会导致我们的索引计算出现偏差。解决方案是,在计算映射前,需要先预处理原始字符串,生成一个“净文本”字符串(移除所有标签),并记录每个净文本字符对应的原始字符串索引。这个计算需要和我们之前标签解析器的逻辑联动。空格和换行:空格字符 会生成顶点吗?在大多数字体和设置下,会的,但它可能非常窄。换行符
\n会导致后续字符的UICharInfo位置重置,并且它本身不生成顶点。我们的映射计算必须能正确处理这些控制字符。字体纹理和动态合批:如果文本中使用了多种颜色或大小,Unity可能会将相同样式的字符进行合批,但这通常不影响顶点顺序,我们按顺序计算的映射在大多数情况下仍然可用。
由于上述复杂性,一个更稳健但也更复杂的做法是:不依赖UICharInfo,而是在OnPopulateMesh中,通过分析VertexHelper里顶点数据的规律(比如通过uv0的变化来判断是否进入了下一个字符),来动态构建映射关系。但这实现难度很高。
避坑指南:对于大多数扩充需求(如图标、渐变),我们不需要精确到每个字符的映射,只需要知道某个标签的影响范围所对应的顶点块的大致起始和结束索引。因此,我们可以采用一种简化策略:在标签解析阶段,计算标签在“净文本”中的起止索引。然后,在顶点处理阶段,假设每个净文本字符对应4个顶点,用这个简单的乘法来定位顶点块范围。对于像图标这种需要替换的操作,我们允许一个字符的误差,通过后续的布局微调来修正位置。实践证明,在字体大小固定、样式不太复杂的情况下,这种简化策略是可行的,且性能最好。
3.3 操作VertexHelper实现具体效果
拿到了标签信息以及它大致影响的顶点范围,我们就可以动手修改网格了。VertexHelper类有一个PopulateUIVertex方法可以读取单个顶点,但修改需要直接访问内部列表。我们可以通过反射(性能有损耗)或者用一个List<UIVertex>在每一帧接收顶点数据。
// 在OnPopulateMesh中 List<UIVertex> vertexList = new List<UIVertex>(); vh.GetUIVertexStream(vertexList); // 现在可以修改vertexList了 // 例如,修改第100到103个顶点的颜色以实现渐变 for (int i = startVertIndex; i <= endVertIndex; i++) { UIVertex vert = vertexList[i]; // 计算渐变颜色... vert.color = Color.Lerp(startColor, endColor, t); vertexList[i] = vert; } // 修改完后,清空旧的,填入新的 vh.Clear(); vh.AddUIVertexTriangleStream(vertexList);实现内联图标:
- 解析到
<icon=coin>标签,我们在净文本中为其预留一个占位字符(比如一个空格,或者一个特定字符)。 - 在顶点处理阶段,找到这个占位字符对应的四个顶点。
- 删除这四个顶点(以及三角形索引中对应的6个索引)。
- 计算图标应该出现的位置(通常是这个占位字符原本的中心位置)。
- 根据图标的尺寸,生成四个新的顶点,设置它们的
position构成一个矩形,设置uv0为图标在Sprite图集上的UV坐标,color为图标的颜色(通常受父级CanvasGroup或Image影响)。 - 将这4个顶点和对应的6个三角形索引添加到
vertexList中。
实现文字渐变:
- 解析到
<gradient>标签,获取其影响的范围。 - 找到该范围对应的所有顶点。
- 遍历这些顶点,根据每个顶点在水平方向(或垂直方向)上的位置(
position.x),计算一个插值系数t。 - 用
Color.Lerp或更复杂的梯度函数,计算出该顶点应有的颜色,赋值给vert.color。
实现波浪效果:
- 解析到
<wave>标签。 - 找到影响范围内的所有顶点(注意,是每个字符的四个顶点)。
- 根据当前时间
Time.time和每个字符的原始X位置,计算一个Y方向的偏移量offsetY = Mathf.Sin(time * frequency + xPos * waveLength) * amplitude。 - 修改每个顶点的
position.y,加上这个偏移量。
注意事项:直接修改顶点位置来实现动画(如波浪、抖动)会导致文本的布局(如自动换行)在动画过程中看起来是错的,因为布局计算只在文本改变或RectTransform尺寸改变时进行。这是一种“视觉欺骗”,对于简单的HUD效果可以接受,但对于大段正文可能不合适。
4. 实操过程:构建一个支持图标与渐变的RichText组件
下面,我将勾勒一个简化版但可运行的RichText组件的核心框架,重点展示图标和渐变功能的实现。
4.1 定义标签与解析数据结构
首先,我们定义一些类来存储解析结果。
using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class RichTextTag { public string Name; // 标签名,如 "icon", "gradient" public int StartIndex; // 在原始字符串中的开始索引 public int EndIndex; // 在原始字符串中的结束索引 public Dictionary<string, string> Attributes = new Dictionary<string, string>(); // 属性键值对 public bool IsEndTag; // 是否是结束标签 } public class ParsedRichText { public string PlainText; // 去除所有标签后的纯文本 public List<RichTextTag> Tags = new List<RichTextTag>(); // 所有标签(按在字符串中出现的顺序) // 还可以有一个列表,记录每个PlainText字符对应的原始索引,用于精确定位 }然后,我们需要一个解析器。这里展示一个非常简化的、不支持嵌套的解析器作为示意(实际项目需要更复杂的)。
public static class SimpleRichTextParser { public static ParsedRichText Parse(string input) { ParsedRichText result = new ParsedRichText(); System.Text.StringBuilder plainTextBuilder = new System.Text.StringBuilder(); int plainTextIndex = 0; // ... 遍历input,识别标签,填充result.Tags和plainTextBuilder ... // 这是一个复杂的过程,需要处理属性值带引号、自闭合标签、原生标签跳过等情况。 // 此处省略具体实现代码,可以用状态机或递归下降法编写。 result.PlainText = plainTextBuilder.ToString(); return result; } }4.2 扩展Text组件并重写OnPopulateMesh
创建我们的RichText组件。
[AddComponentMenu("UI/RichText", 10)] public class RichText : Text { // 存储解析结果 private ParsedRichText _parsedText; // 图标资源字典,键为图标名,值为Sprite public Dictionary<string, Sprite> IconSprites = new Dictionary<string, Sprite>(); // 重写text属性,在设置时进行解析 public override string text { get => base.text; set { if (base.text != value) { base.text = value; _parsedText = SimpleRichTextParser.Parse(value); SetVerticesDirty(); // 标记顶点需要重建 } } } protected override void OnPopulateMesh(VertexHelper vh) { // 1. 先让基类生成基础网格 base.OnPopulateMesh(vh); if (_parsedText == null || string.IsNullOrEmpty(_parsedText.PlainText)) return; // 2. 获取顶点流 List<UIVertex> verts = new List<UIVertex>(); vh.GetUIVertexStream(verts); // 3. 这里需要实现从标签到顶点索引的映射。 // 假设我们有一个方法,能根据标签在PlainText中的范围,计算出在verts列表中的大致顶点起始和结束索引。 // List<VertexRange> ranges = CalculateVertexRangesForTags(_parsedText, verts.Count); // 由于CalculateVertexRangesForTags实现非常复杂,此处我们用伪代码表示。 // 4. 遍历所有标签,根据类型进行处理 foreach (var tag in _parsedText.Tags) { if (tag.IsEndTag) continue; // 只处理开始标签或自闭合标签 VertexRange range = GetVertexRangeForTag(tag); // 伪方法,获取该标签影响的顶点范围 switch (tag.Name) { case "icon": ProcessIconTag(tag, range, verts); break; case "gradient": ProcessGradientTag(tag, range, verts); break; // 可以扩展更多case... } } // 5. 将修改后的顶点数据写回VertexHelper vh.Clear(); vh.AddUIVertexTriangleStream(verts); } // 处理图标标签 private void ProcessIconTag(RichTextTag tag, VertexRange range, List<UIVertex> verts) { if (!tag.Attributes.TryGetValue("name", out string iconName)) return; if (!IconSprites.TryGetValue(iconName, out Sprite sprite) || sprite == null) return; // 1. 删除占位字符的顶点 // range.StartVertIndex 和 range.EndVertIndex 定义了占位字符的顶点范围(通常是4个顶点) int vertCountToRemove = range.EndVertIndex - range.StartVertIndex + 1; verts.RemoveRange(range.StartVertIndex, vertCountToRemove); // 注意:还需要同步处理三角形索引列表,这里简化了,实际需要维护一个独立的三角形索引列表并同步删除。 // 2. 计算图标位置和大小 // 我们可以用被删除的顶点的平均位置作为图标中心,或者用UICharInfo的信息。 // 假设我们从某个途径获得了图标的预期位置rect。 Rect iconRect = CalculateIconRect(tag, range); float iconWidth = iconRect.width; float iconHeight = iconRect.height; // 3. 创建图标的四个顶点 UIVertex[] iconVerts = new UIVertex[4]; for (int i = 0; i < 4; i++) { iconVerts[i] = UIVertex.simpleVert; iconVerts[i].color = color; // 使用Text组件的整体颜色 } // 设置位置 (构成一个矩形) iconVerts[0].position = new Vector3(iconRect.xMin, iconRect.yMax, 0); // 左上 iconVerts[1].position = new Vector3(iconRect.xMax, iconRect.yMax, 0); // 右上 iconVerts[2].position = new Vector3(iconRect.xMax, iconRect.yMin, 0); // 右下 iconVerts[3].position = new Vector3(iconRect.xMin, iconRect.yMin, 0); // 左下 // 4. 设置UV(从Sprite获取) Rect spriteUV = sprite.rect; spriteUV.x /= sprite.texture.width; spriteUV.width /= sprite.texture.width; spriteUV.y /= sprite.texture.height; spriteUV.height /= sprite.texture.height; iconVerts[0].uv0 = new Vector2(spriteUV.xMin, spriteUV.yMax); iconVerts[1].uv0 = new Vector2(spriteUV.xMax, spriteUV.yMax); iconVerts[2].uv0 = new Vector2(spriteUV.xMax, spriteUV.yMin); iconVerts[3].uv0 = new Vector2(spriteUV.xMin, spriteUV.yMin); // 5. 将新顶点插入到列表的合适位置(通常是删除的位置) verts.InsertRange(range.StartVertIndex, iconVerts); // 6. 更新后续所有标签的顶点范围索引!因为我们在列表中插入了新顶点,后面的索引都偏移了。 // 这是一个关键步骤,需要维护一个索引偏移量来更新所有还未处理的标签的VertexRange。 } // 处理渐变标签 private void ProcessGradientTag(RichTextTag tag, VertexRange range, List<UIVertex> verts) { if (!tag.Attributes.TryGetValue("colors", out string colorsAttr)) return; // 解析颜色字符串,例如 "#FF0000,#00FF00,#0000FF" string[] colorStrs = colorsAttr.Split(','); if (colorStrs.Length < 2) return; List<Color> gradientColors = new List<Color>(); foreach (var cStr in colorStrs) { if (ColorUtility.TryParseHtmlString(cStr.Trim(), out Color col)) gradientColors.Add(col); } if (gradientColors.Count < 2) return; // 获取影响顶点的水平边界 float minX = float.MaxValue, maxX = float.MinValue; for (int i = range.StartVertIndex; i <= range.EndVertIndex; i++) { float x = verts[i].position.x; if (x < minX) minX = x; if (x > maxX) maxX = x; } float width = maxX - minX; if (Mathf.Approximately(width, 0)) return; // 应用渐变 for (int i = range.StartVertIndex; i <= range.EndVertIndex; i++) { UIVertex vert = verts[i]; float t = (vert.position.x - minX) / width; t = Mathf.Clamp01(t); // 简单的线性插值,多色渐变需要更复杂的计算(如根据t选择颜色段) vert.color = Color.Lerp(gradientColors[0], gradientColors[1], t); // 注意:这里直接修改了顶点颜色,会覆盖原生<color>标签的效果。如果需要叠加,需要更复杂的混合逻辑。 verts[i] = vert; } } // 辅助类,表示顶点范围 private struct VertexRange { public int StartVertIndex; public int EndVertIndex; // 包含 } // GetVertexRangeForTag 和 CalculateIconRect 是复杂的辅助方法,此处省略实现。 }4.3 使用示例与配置
在Unity编辑器中,将RichText组件挂到GameObject上,和普通Text一样设置字体、大小等。然后,通过脚本或Inspector为其IconSprites字典赋值,关联图标名称和Sprite资源。
在代码中设置文本:
richTextComponent.text = "获得 <icon name=\"coin\"></icon> x 100,生命值 <gradient colors=\"#00FF00,#FFFF00,#FF0000\">危险!</gradient>";理论上,这会显示为“获得”后面跟着一个金币图标,然后是“x 100”,最后“危险!”一词呈现从绿到黄到红的渐变。
5. 常见问题与排查技巧实录
在实际实现和使用的过程中,你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决办法。
问题1:图标位置错位,或者和文字对不齐。
- 原因分析:计算图标矩形
CalculateIconRect的逻辑不准确。可能的原因有:1) 获取占位字符的屏幕位置或本地位置错误;2) 没有考虑Text组件的对齐方式(Left, Center, Right);3) 图标的锚点(Pivot)设置和计算时使用的基准点不匹配。 - 排查技巧:在
OnPopulateMesh中,用Debug.DrawLine或Gizmos将被替换的占位字符的四个顶点位置画出来。同时,把你计算出来的图标矩形也画出来。对比两者,就能看出偏移量和方向。通常需要将图标矩形的中心点对齐到占位字符的中心点,并考虑字体的基线(Baseline)偏移,图标底部通常和文字基线对齐会更自然。
问题2:使用了自定义富文本后,文本的点击事件(如EventTrigger)失效了。
- 原因分析:Unity UI的点击检测依赖于
Graphic.Raycast方法,该方法默认使用图形的原始网格边界(bounds)或使用顶点数据生成的精确形状。当我们修改了顶点(比如添加了图标),但RichText组件继承的Graphic类可能仍然使用原始的、未修改的边界框进行射线检测,导致图标区域无法点击。 - 解决方案:可以尝试重写
IsRaycastLocationValid方法,提供自定义的点击检测逻辑。更简单的方法是,确保我们添加的图标顶点在空间上是连续的,并且包含在组件原始的矩形边界内。如果图标超出了原始文本边界,可能需要动态调整RectTransform的尺寸,或者接受部分区域无法点击的事实。对于复杂的可点击区域,建议将图标单独作为一个Image子物体来处理交互。
问题3:性能开销大,在滚动列表中使用时卡顿。
- 原因分析:每一帧都在解析字符串、计算顶点映射、修改大量顶点数据。
OnPopulateMesh在文本内容改变、字体改变、尺寸改变时都会被调用,频繁调用开销很大。 - 优化策略:
- 缓存解析结果:只有在
text属性真正改变时才重新解析。我们在重写的text属性setter中已经做了。 - 缓存顶点映射:如果文本内容不变,但组件需要重建网格(如Canvas刷新),
OnPopulateMesh仍会被调用。我们可以缓存标签到顶点范围的映射关系,只有当文本或布局相关属性(fontSize,rectTransform.sizeDelta等)改变时才重新计算。这需要监听更多属性。 - 避免频繁更新:对于有动画效果的富文本(如波浪文字),我们确实需要每帧修改顶点。这时可以考虑使用
MaterialPropertyBlock或者顶点着色器来实现动画,将计算转移到GPU,而不是每帧在CPU修改List<UIVertex>。但这需要一定的Shader知识。 - 分帧处理:对于极长的富文本,可以将顶点修改操作分散到多帧完成,但这会带来视觉上的延迟。
- 缓存解析结果:只有在
问题4:和Mask、RectMask2D等裁剪组件一起使用时,自定义内容显示异常。
- 原因分析:Unity的UI裁剪是基于顶点进行的。我们添加的图标顶点,如果其位置超出了
RichText组件所在的矩形范围,但仍在父级Mask的范围内,可能不会被正确裁剪。这取决于裁剪的实现方式。 - 解决方案:确保我们生成的顶点坐标,始终在
RichText组件本身的矩形边界内。如果需要超出边界,可能需要调整组件的RectTransform大小,或者考虑使用Mask组件而非RectMask2D(后者严格限制在矩形内)。更高级的做法是,在Shader中对顶点进行裁剪,但这更复杂。
问题5:如何支持图文混排的自动换行?
- 原因分析:这是最复杂的问题之一。Unity的原生换行是基于字符宽度计算的。我们的图标占位符(比如一个空格)宽度很小,但实际图标很宽。这会导致图标与后面的文字重叠。
- 思路:这需要深度干预文本生成过程。我们不能只在
OnPopulateMesh阶段做手脚,而需要在文本布局阶段就告诉TextGenerator:“这个占位符的宽度不是字体中空格的宽度,而是图标的宽度”。这涉及到修改或替换TextGenerator的逻辑,或者在其生成UICharInfo时动态调整字符宽度信息。一个折中的方案是:不使用占位符,而是在文本布局完成后,根据当前行的剩余宽度,手动调整图标的位置,如果放不下就强制换行。这需要自己实现一套简单的排版逻辑,复杂度极高。对于大多数需求,约定图标单独占一行,或者放在行首/行尾,是更实际的选择。
扩充UGUI的Text富文本是一个深入UI系统底层的过程,它让你对Unity UI的渲染机制有更深刻的理解。虽然过程中会遇到不少挑战,但最终实现一个既能满足项目特定需求,又保持良好性能的定制化文本组件,带来的成就感和对项目的掌控感是非常强的。对于绝大多数项目,如果需求不是特别怪异,TextMesh Pro已经是足够优秀且标准的选择。但当你需要极致控制,或者项目历史原因无法迁移时,自己动手扩充UGUI Text就成了一条值得探索的路。