1.使用举例

参考链接
参考链接中的代码好像无法处理多个超链接以及标签嵌套、点击对象包含空格的问题,在本文中已经优化。

使用举例
效果图
更加复杂的超链接文本
更加复杂的文本
在这里插入图片描述

2.实现代码

using Framework;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class HyperlinkText: Text, IPointerClickHandler
{
    /// <summary>
    /// 超链接信息类,[startIndex,endIndex)
    /// </summary>
    private class HyperlinkInfo
    {
        /// <summary>
        /// 渲染开始的下标
        /// </summary>
        public int startIndex;
        /// <summary>
        /// 结束的下标
        /// </summary>
        public int endIndex;

        public string url;
        /// <summary>
        /// 所有的包围盒,换行的话会有多个
        /// </summary>
        public readonly List<Rect> boxes = new List<Rect>();

    }

    /// <summary>
    /// 文本构造器
    /// </summary>
    protected readonly StringBuilder s_TextBuilder = new StringBuilder();

    /// <summary>
    /// 超链接信息列表
    /// </summary>
    private readonly List<HyperlinkInfo> m_HrefInfos = new List<HyperlinkInfo>();

    //计算定点信息的缓存数组
    private readonly UIVertex[] m_TempVerts = new UIVertex[4];

    /// <summary>
    /// 超链接正则
    /// </summary>
    private static readonly Regex s_HrefRegex = new Regex(@"<url='([^']*)'>(.*?)</url>", RegexOptions.Singleline);


    /// <summary>
    /// 点击超链接的事件
    /// </summary>
    public Action<string> onHyperlinkClick;

    /// <summary>
    /// 超链接文本颜色
    /// </summary>
    public static Color32 innerTextColor = new Color32(0, 0, 255, 255);

    /// <summary>
    /// 超链接下划线颜色
    /// </summary>

    public static Color32 innerLineColor = new Color32(0,0,255,255);

    /// <summary>
    /// 下划线的厚度
    /// </summary>
    public static float width = 8;

    /// <summary>
    /// 下划线偏移位置
    /// </summary>
    public static float innerLineOffset = 0.15f;

    /// <summary>
    /// 解析完最终的文本
    /// </summary>
    private string m_OutputText;




    public override void SetVerticesDirty()
    {
        base.SetVerticesDirty();
        m_OutputText = GetOutputText(text);
    }


    /// <summary>
    /// 构建mesh
    /// </summary>
    protected override void OnPopulateMesh(VertexHelper toFill)
    {
        //为了使Inspector面板显示正确文本
        var orignText = m_Text;//记录原始文本
        m_Text = m_OutputText;//更换要展示的文本
        base.OnPopulateMesh(toFill);//生成需要展示的文本的网格
        m_Text = orignText;//恢复原始文本,使得Inspector面板正确显示


        UIVertex vert = new UIVertex();//获取顶点信息
        // 处理超链接包围框
        foreach (var hrefInfo in m_HrefInfos)
        {
            hrefInfo.boxes.Clear();
            if (hrefInfo.startIndex * 4 >= toFill.currentVertCount)//每个字符四个顶点组成,toFill.currentVertCount是去除标签后实际上渲染的字数*4
            {
                continue;
            }

            toFill.PopulateUIVertex(ref vert, hrefInfo.startIndex * 4); //获取超链接文本第一个字符的第一个顶点的位置
            var pos = vert.position;
            var bounds = new Bounds(pos, Vector3.zero);//并以此位置初始化一个包围盒

            //遍历超链接文本的所有顶点
            for (int i = hrefInfo.startIndex * 4, m = hrefInfo.endIndex * 4; i < m; i++)
            {
                if (i >= toFill.currentVertCount)
                {
                    break;
                }

                toFill.PopulateUIVertex(ref vert, i);//获取这个顶点的信息
                vert.color = innerTextColor;//修改颜色
                toFill.SetUIVertex(vert, i);//重新写会修改的信息

                pos = vert.position;//更新当前顶点的位置

                bool needEncapsulate = true;//是否需要加入包围盒

                if (i > 4 && (i - hrefInfo.startIndex * 4) % 4 == 0)//检查是否换行(只在字符的第一个顶点检测)
                {
                    UIVertex lastV = new UIVertex();//前一个字符的顶点数据
                    toFill.PopulateUIVertex(ref lastV, i - 4);
                    var lastPos = lastV.position;//上一个顶点位置

                    if (pos.x < lastPos.x && pos.y < lastPos.y) //换行判断
                    {
                        hrefInfo.boxes.Add(new Rect(bounds.min, bounds.size));//保存当前行的包围盒
                        bounds = new Bounds(pos, Vector3.zero);//在新一行创建一个包围盒
                        needEncapsulate = false;
                    }
                }
                if (needEncapsulate)
                {
                    bounds.Encapsulate(pos); //扩展包围盒
                }
            }
            hrefInfo.boxes.Add(new Rect(bounds.min, bounds.size));//保存最终的包围盒
        }

        DrwaUnderLine(toFill);

    }


    /// <summary>
    /// 画超链接的下划线
    /// </summary>
    protected virtual void DrwaUnderLine(VertexHelper toFill)
    {

        Vector2 extents = rectTransform.rect.size;//获取文本组件的矩形尺寸
        var settings = GetGenerationSettings(extents);//获取文本配置信息

        TextGenerator underlineText = new TextGenerator();
        underlineText.Populate("——", settings);//作为下划线模板
        IList<UIVertex> tut = underlineText.verts;//四个顶点数据
        foreach (var hrefInfo in m_HrefInfos)
        {
            if (hrefInfo.startIndex * 4 >= toFill.currentVertCount)
            {
                continue;
            }

            for (int i = 0; i < hrefInfo.boxes.Count; i++)
            {
                //计算下划线的位置
                Vector3[] ulPos = new Vector3[4];
                float underlineY = hrefInfo.boxes[i].yMin - fontSize *innerLineOffset;
                ulPos[0] = new Vector3(hrefInfo.boxes[i].xMin, underlineY);
                ulPos[1] = new Vector3(hrefInfo.boxes[i].xMax, underlineY);
                ulPos[2] = new Vector3(hrefInfo.boxes[i].xMax, underlineY + width); 
                ulPos[3] = new Vector3(hrefInfo.boxes[i].xMin, underlineY + width);


                //构建下划线
                for (int j = 0; j < 4; j++)
                {
                    m_TempVerts[j] = tut[j];
                    m_TempVerts[j].color = innerLineColor;
                    m_TempVerts[j].position = ulPos[j];
                }
                toFill.AddUIVertexQuad(m_TempVerts);//添加到网格
            }
        }
    }



    /// <summary>
    /// 获取超链接解析后的最后输出文本
    /// </summary>
    /// <returns></returns>
    protected virtual string GetOutputText(string outputText)
    {
        s_TextBuilder.Length = 0;
        m_HrefInfos.Clear();

        //处理到的原始字符串的下标位置
        int indexText = 0;
        //渲染开始的下标位置
        int StartIndex = 0;
        foreach (Match match in s_HrefRegex.Matches(outputText))
        {
            //这个url到前面匹配的url之间的原始字符串,如何前面没有匹配项就是该url前面的原始字符串
            string appendStr = outputText.Substring(indexText, match.Index - indexText);

            //这个字符串不包含标签的字符串,也就是能够真正展示的字符串(此时包含空格和回车)
            var notagStr = RemoveRichTextTags(appendStr);

            //添加到输出字符串
            s_TextBuilder.Append(appendStr);

            //因为空格会占一个字符串位置,但是不会渲染顶点,所以在顶点处理时要去掉
            //获取空格数量
            int spacecount = GetSpaceCount(notagStr);

            StartIndex =StartIndex+notagStr.Length-spacecount;

            //去除匹配项内的标签
            string trueStr = RemoveRichTextTags(match.Groups[2].Value);

            //超链接标签内的字符串的空格数量
            int urlTagSpaceCount = GetSpaceCount(trueStr);

            var hrefInfo = new HyperlinkInfo
            {
                startIndex = StartIndex, // 超链接里的文本起始顶点索引
                endIndex = StartIndex + (trueStr.Length) - urlTagSpaceCount,
                url = match.Groups[1].Value
            };
            m_HrefInfos.Add(hrefInfo);
            s_TextBuilder.Append(match.Groups[2].Value);
            StartIndex += trueStr.Length- urlTagSpaceCount;
            indexText = match.Index + match.Length;
        }
        //添加剩下的字符串
        s_TextBuilder.Append(outputText.Substring(indexText, outputText.Length - indexText));
        return s_TextBuilder.ToString();
    }

    /// <summary>
    /// 处理一个原始字符串,去掉标签,得到会被展示的字符
    /// </summary>
    public static string RemoveRichTextTags(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        StringBuilder result = new StringBuilder();
        int index = 0;
        int len = input.Length;

        while (index < len)
        {
            if (input[index] == '<')
            {
                // 尝试查找下一个 '>' 的位置
                int endIndex = input.IndexOf('>', index + 1);
                if (endIndex == -1) // 没有闭合的 '>'
                {
                    result.Append(input[index]); // 将 '<' 视为普通字符
                    index++;
                    continue;
                }

                // 提取标签内容(包括 '<' 和 '>')
                string tag = input.Substring(index, endIndex - index + 1);
                if (IsValidTag(tag))
                {
                    // 跳过合法标签
                    index = endIndex + 1;
                    continue;
                }
                else
                {
                    // 非法标签,将 '<' 视为普通字符
                    result.Append(input[index]);
                    index++;
                    continue;
                }
            }
            else
            {
                // 普通字符直接添加
                result.Append(input[index]);
                index++;
            }
        }

        return result.ToString();
    }


    /// <summary>
    /// 是否是合法的标签
    /// </summary>
    /// <param name="tag"></param>
    /// <returns></returns>
    private static bool IsValidTag(string tag)
    {
        // 最小标签长度检查(如 <b> 长度为3)
        if (tag.Length < 3)
            return false;

        // 检查结束标签(如 </i>)
        if (tag.StartsWith("</"))
        {
            // 必须形如 </X>,长度至少4
            if (tag[tag.Length - 1] != '>')
                return false;

            // 提取标签名(去除 </ 和 >)
            string tagName = tag.Substring(2, tag.Length - 3);
            // 标签名必须由纯字母组成
            foreach (char c in tagName)
            {
                if (!char.IsLetter(c))
                    return false;
            }
            return tagName.Length > 0; // 标签名不能为空
        }
        // 检查开始标签(如 <color=red>)
        else
        {
            // 标签名首字符必须是字母
            if (!char.IsLetter(tag[1]))
                return false;

            // 检查标签名部分(直到遇到空格/属性或结束符)
            int pos = 1;
            while (pos < tag.Length - 1 && char.IsLetter(tag[pos]))
            {
                pos++;
            }
            // 标签名长度至少为1,且必须以 '>' 结尾
            return pos > 1 && tag[tag.Length - 1] == '>';
        }
    }


    /// <summary>
    /// 获取一个字符串中空格和回车的数量
    /// </summary>
    private static int GetSpaceCount(string str)
    {
        int Count = 0;

        foreach (char c in str)
        {
            if (c == ' '|| c == '\n' || c == '\r') 
                Count++;
        }
        return Count;
    }


    /// <summary>
    /// 点击事件检测是否点击到超链接文本
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerClick(PointerEventData eventData)
    {
        Vector2 lp = Vector2.zero;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, eventData.position, eventData.pressEventCamera, out lp);

        foreach (var hrefInfo in m_HrefInfos)
        {
            var boxes = hrefInfo.boxes;
            for (var i = 0; i < boxes.Count; ++i)
            {
                if (boxes[i].Contains(lp))
                {
                    Debug.Log("超链接信息:" + hrefInfo.url);
                    URLHandler.OpenURL(hrefInfo.url);
                    return;
                }
            }
        }
    }


#if UNITY_EDITOR
    private void OnDrawGizmos()
    {
        Gizmos.color = Color.cyan;
        foreach (var item in m_HrefInfos)
        {
            var boxs = item.boxes;
            foreach (var box in boxs)
            {
                Vector3 ld = new Vector3(box.xMin, box.yMin) + transform.position;
                Vector3 lt = new Vector3(box.xMin, box.yMax) + transform.position;
                Vector3 rd = new Vector3(box.xMax, box.yMin) + transform.position;
                Vector3 rt = new Vector3(box.xMax, box.yMax) + transform.position;
                Gizmos.DrawLine(ld, lt);
                Gizmos.DrawLine(ld, rd);
                Gizmos.DrawLine(rd, rt);
                Gizmos.DrawLine(lt, rt);
            }
        }
    }
#endif
}

更多推荐