一个Unity富文本插件的实现思路

项目中原来的富文本组件不太好用,做了一些修改,记述主要思路。缺陷很多。
仅适用于没用TextMeshPro,且不打算用的项目,否则请直接用TextMeshPro

原组件特点:

  1. 使用占位符模式,创建新的GameObject,挂载Image组件实现图文混排
  2. 主要通过正则匹配分析语法,扩展不便
  3. 固定RectTransform的anchor、pivot,Text的alignment,修改排版后需要手动计算相关位置,不能实现自动布局

新组件目标

  1. 通过逐步读取的方式分析语法
  2. 实现混排内容位置的自动计算

主要实现思路

需要实现的混排功能

  1. 静态图片(sprite)
  2. 动态表情
  3. 链接点击响应
  4. 颜色(简略代号和#FFFFFF)
  5. 下划线
  6. UGUI原生Text标记(斜体、粗体、大小)

混排位置计算的实现原理

通过Text组件中的字符顶点信息,计算对应位置

主要代码结构

RichText.cs - 接口和生命周期处理
RichText.MarkItem.cs - 定义结构类型和对象池处理
RichText.MarkType.cs - 定义类型枚举
RichText.Utils.cs - 辅助函数
RichText.Analyzor.cs - 语法分析
RichText.Generator.cs - 生成数据结构
RichText.Drawer.cs - 绘制额外内容
RichText.LinkListener.cs - 链接点击响应处理

语法分析

标记结构

private class MarkItem
{
    public int markId;
    public int markType;
    public string value;
    public int startIndex;    // 起始字符位置
    public int endIndex;      // 结束字符位置

    // 对象池略
}

语法主要模式

#f001#n - 动态表情
#c#FF0000#n红色文字#n - 文字颜色

(即把Text的<>修改为#)

语法分析步骤

  1. 预处理输入的字符串,清除UGUI Text的<>标记内容,进行一些其他需要的前期处理
  2. 清理分析栈、根节点、已存储的数据,根节点入栈
  3. 按顺序读取预处理后的字符串
  4. 如果下一个字符不是'#',读到下一个'#n',存为一个普通字符类型标记
  5. 如果下一个字符是'#',如果是'#n',结束上一个标记,否则读到下一个'#n'
  6. 读取到字符串结束
  7. 检验分析结果,若中间有标记不匹配,或最后分析栈不是只包含根节点,分析结果错误,直接输出原字符串;否则正确,开始生成特殊标记

一些实现细节

[RequireComponent(typeof(Text))]
public partial class RichText : MonoBehavior
{
    private MarkItem m_TreeRoot;
    private readonly Stack<int> m_AnalyzeStack = new Stack<int>();
    private readonly Dictionary<int, int> m_ParentDict = new Dictionary<int, int>();
    private readonly Dictionary<int, List<int>> m_ChildrenDict = new Dictionary<int, List<int>>();

    private void AnalyzeOriginalText()
    {
        string tempText = m_OriginalText;
        tempText = s_UnityMarkRegex.Replace(tempText, ""); // <.*?>
        // 其他处理
        Clear();
        
        int pos = 0;
        int length = tempText.Length;
        bool success = true;

        while (pos < length)
        {
            int curLength = 0;
            if (tempText[pos] != '#')
            {
                int nextSharpPos = tempText.IndexOf('#', pos);
                if (nextSharpPos < 0)
                {
                    curLength = length - pos;
                }
                else
                {
                    curLength = nextSharpPos - pos;
                }

                success = CreateNewMarkItem(MARK_NORMAL, tempText.Substring(pos, curLength));
                if (!success) break;
            }
            else
            {
                if (endMarkPos < 0)
                {
                    curLength = length - pos;
                    success = CreateNewMarkItem(MARK_NORMAL, tempText.Substring(pos, curLength));
                    if (!success) break;
                }
                else {
                    curLength = endMarkPos - pos + 2;
                    success = CreateStyleMarkItem(tempText.Substring(pos, curLength));
                    if (!success) break;
                }
            }

            pos += curLength;
        }
        
        if (m_AnalyzeStack.Count != 1)
        {
            Clear();
            CreateNewMarkItem(MARK_NORAML, tempText);
        }
    }

    private bool CreateNewMarkItem(int markType, string value)
    {
        int markId = m_MarkItemList.Count;
        MarkItem item = MarkItem.Get();
        item.markType = markType;
        item.markId = m_MarkItemList.Count;
        item.value = value;

        m_MarkItemList.Add(item);
        if (m_AnalyzeStack.Count == 0)
        {
            return false; //分析栈中根节点已经弹出,语法错误
        }

        int parentId = m_AnalyzeStack.Peek();
        if (!m_ChildrenDict.ContainsKey(parentId))
        {
            m_ChildrenDict[parentId] = new List<int>();
        }
        m_ChildrenDict[parentId].Add(item.markId);

        return true;
    }

    private bool CreateStyleMarkItem(string text)
    {
        int markType = MARK_NORMAL;
        string value = "";
        int length = text.Length;
        switch(text[1])
        {
            //...标记类型
        }
        
        if (length > 4)
        {
            value = text.Substring(2, length - 4);
        }

        bool success = CreateNewMarkItem(markType, value);
        if (!success)
        {
            return false;
        }

        switch (markType)
        {
            //可包含子节点的标记类型,入m_AnalyzeStack
        }

        return true;
    }
}

为markId使用自增id存入列表,使用字典存储父子关系,还有优化的地方

结构生成

主要遍历上一步生成的语法树

//RichText.Generator.cs

private readonly m_StringBuilder = new StringBuilder();

private void GeneratorDisplayContent()
{
    TraversalMarkItemNode(ROOT_ID);
    m_Text.text = m_StringBuilder.ToString();
}

private void TraversalMarkItemNode(int nodeId)
{
    MarkItem node = m_MarkItemList[nodeId];
    int startIndex = m_StringBuilder.Length;
    node.startIndex = startIndex;

    switch(node.markType)
    {
        //普通类型略
        case MARK_PHOTO:
        case MARK_FACE:
            m_StringBuilder.Append("<color=#ffffff00>");
            float width, height;
            // 即得出需要使用几个占位符
            int placeholderCount = GetSpriteParams(node.markType, node.value, out width, out height);
            m_StringBuilder.Insert(m_StringBuilder.Length, PLACEHOLDER, placeholderCount);
            m_StringBuilder.Append("</color>");

            m_ActiveImageCount++;
            if (m_ImageItemList == null)
            {
                m_ImageItemList = new List<ImageItem>();
            }
            ImageItem iItem = ImageItem.Get();
            iItem.markId = node.markId;
            iItem.startIndex = startIndex;
            iItem.endIndex = m_StringBuilder.Length;
            iItem.width = width;
            iItem.height = height;
            m_ImageItemList.Add(iItem);

            if (node.markType == MARK_FACE)
            {
                m_ActiveFaceCount++;
            }
            break;
    }

    if (m_ChildrenDict.ContainsKey(nodeId))
    {
        List<int> list = m_ChildrenDict[nodeId];
        int size = list.Count;
        for (int i = 0; i < size; i++)
        {
            TraversalMarkItemNode(list[i]);
        }
    }

    //标记闭合处理
    int endIndex = m_StringBuilder.Length;
    node.endIndex = endIndex;
    switch(node.markType)
    {
        //普通类型略
        case MARK_LINK:
            //和上面Image类似,生成LinkItem,有参数可以做一些处理
            break;
        case MARK_UNDERLINE:
            //同上
            break;
    }
}

绘制额外内容

首先解决在合适的位置绘制额外内容的问题

// RichText.Drawer.cs

private float m_PlaceholderPixelWidth = 0;
private IList<UICharInfo> m_CurrentUICharInfoList = null;
private IList<UILineInfo> m_CurrentUILineInfoList = null;
private readonly List<int> m_CurrentLineStartIndexList = new List<int>();
private readonly List<int> m_CurrentLineEndIndexList = new List<int>();
private int m_CurrentCharactersCount = 0;
private int m_CUrrentLinesCount = 0;


private void RefreshExtraContents()
{
    RefreshGeneratorResults();
    ResetImageGameObjects();
    ResetLinkGameObjects();
    ResetUnderlineGameObjects();
}

// 修改字体大小时调用,计算占位符宽度
private void RefreshGeneratorParams()
{
    TextGenerator textGenerator = new TextGenerator();
    Rect rect = m_RectTransform.rect;
    Vector2 extents = new Vector2(rect.width, rect.height);
    TextGenerationSettings settings = m_Text.GetGenerationSettings(extents);
    m_PlaceholderPixelWidth = textGenerator.GetPreferredWidth(PLACEHOLDER, settings);
}

// 拷贝生成器结果
private void RefreshGeneratorResults()
{
    TextGenerator generator = m_Text.cachedTextGenerator;

    // 第一次传值时未及时生成
    if (generator.characterCount == 0)
    {
        Rect rect = m_RectTransform.rect;
        Vector2 extents = new Vector2(rect.width, rect.height);
        TextGenerationSettings settings = m_Text.GetGenerationSettings(extents);
        generator.Populate(m_Text.text, setting);
    }
    m_CurrentCharactersCount = generator.characterCount; //显示部分(生成的)的字符数量,有点坑
    m_CurrentUICharInfoList = generator.characters;
    m_CurrentUILineInfoList = generator.lines;

    //刷新m_CurrentLineStartIndexList, m_CurrentLineEndIndexList
}

// 对象重用略

//重设图片位置
private void ResetImageGameObjects()
{
    if (m_ActiveImageCount == 0)
    {
        if (m_ImageGo != null)
        {
            m_ImageGo.SetActive(false);
        }

        return;
    }

    if (m_ImageGo == null)
    {
        m_ImageGo = CreateUIGameObject(transform, IMAGE_GO_NAME, true);
    }
    m_ImageGo.SetActive(true);

    for (int i = 0; i < m_ActiveImageCount; i++)
    {
        ImageItem item = m_ImageItemList[i];
        GameObject go = item.gameObject;

        if (go == null)
        {
            go = GetImageGameObject(m_ImageGo.transform, i, i.ToString());
            item.gameObject = go;
        }

        Image image = go.GetComponent<Image>();
        MarkItem node = m_MarkItemList[item.markId];

        image.sprite = GetSprite(node.markType, node.value);
        RectTransform rectTransform = go.GetComponent<RectTransform>();
        rectTransform.sizeDelta = new Vector2(item.width, item.height);
        float sl, sr, st, sb, el, er, et, eb;
        // 一些字符是不显示的,如“<color=#ffffff>”, 获取实际位置
        int realStartIndex, readEndIndex;
        bool success = true;
        success &= TryGetNextValidCharPos(item.startIndex, out sl, out sr, out st, out sb, out realStartIndex);
        success &= TryGetPrevValidCharPos(item.endIndex, out el, out er, out et, out eb, out realEndIndex);
        success &= realStartIndex <= realEndIndex;
        if (!success)
        {
            item.active = false;
            go.SetActive(false);
        }
        else
        {
            item.active = true;
            go.SetActive(true);
        }

        float x = (sl + er) / 2;
        float y = (st + sb) / 2;
        rectTransform.localPosition = new Vector2(x, y);

        if (node.markType == MARK_FACE)
        {
            // 创建动画
        }
    }

    Transform container = m_ImageGo.transform;
    for (int i = m_ActiveImageCount; i < container.childCount; i++)
    {
        container.GetChild(i).gameObject.SetActive(false);
    }
}

//重设链接位置,外层基本和ResetImageGameObjects相同
private void ResetLinkGameObjects()
{
    //略

    for (int i = 0; i < m_ActiveLinkCount; i++)
    {
        //略

        //多行处理
        int curValidLines = 0;
        for (int line = 0; line < m_CUrrentLinesCount; line++)
        {
            int lineStartIndex = m_CurrentLineStartIndexList[line];
            int lineEndIndex = m_CurrentLineEndIndexList[line];
            if (startIndex > lineEndIndex) continue;
            if (lineStartIndex > endIndex) break;
            UILineInfo info = m_CurrentUILineInfoList[line];
            int curLineStartIndex = startIndex > lineStartIndex ? startIndex : lineStartIndex;
            int curLineEndIndex = endIndex < lineEndIndex ? endIndex : lineEndIndex;
            float sl, sr, st, sb, el, er, et, eb;
            int realStartIndex, realEndIndex;
            bool success = true;
            success &= TryGetNextValidCharPos(item.startIndex, out sl, out sr, out st, out sb, out realStartIndex);
            success &= TryGetPrevValidCharPos(item.endIndex, out el, out er, out et, out eb, out realEndIndex);
            success &= realStartIndex <= realEndIndex;
            success &= realStartIndex <= curLineEndIndex;
            success &= realEndIndex >= curLineStartIndex;
            if (!success)
            {
                continue;
            }

            curValidLines++;
            float x = (sl + er) / 2;
            float y = info.topY - info.height / 2;
            float width = er - sl;
            if (width <= 0) continue;
            float height = info.height;

            GameObejct curGo = GetLinkGameObject(curContainer, curValidLines, string.Format("{0}_{1}", i, curValidLines));
            curGo.SetActive(true);
            RectTransform rectTransform = curGo.GetComponent<RectTransform>();
            rectTransform.localPosition = new Vector2(x, y);
            rectTransform.sizeDelta = new Vector2(width, height);
        }

        // 略
    }

    // 略
}

// ResetUnderlineGameObjects() 和 ResetLinkGameObjects() 基本相同,略

// RichText.Utils.cs

private bool TryGetPrevValidCharPos(int index, out float left, out float right, out float top, out float bottom, out int realIndex)
{
    int size = m_CurrentUICharInfoList.Count;
    while (true)
    {
        if (index >= size || index < 0)
        {
            left = 0;
            right = 0;
            top = 0;
            bottom = 0;
            realIndex = 0;
            return false;
        }

        UICharInfo info = m_CurrentUICharInfoList[index];
        if (info.charWidth == 0)
        {
            index--;
            continue;
        }

        realIndex = index;
        left = info.cursorPos.x;
        top = info.cursorPos.y;
        right = left + info.charWidth;
        UILineInfo lineInfo;
        if (TryGetUILineInfoByCharacterIndex(realIndex, out linInfo))
        {
            bottom = top - lineInfo.fontSize;
        }
        else
        {
            bottom = top - m_Text.fontSize;
        }

        return true;
    }
}

在不设置RectTransform的anchor的情况下,上面的代码基本满足功能,待要修改布局的话,需要针对RectTransform的参数修改做相关处理

private Vector2 m_CachedPivot;
private Vector2 m_CachedAnchorMin;
private Vector2 m_CachedAnchorMax;

private void RefreshGameObjectPositions()
{
    Vector2 pivot = m_RectTransform.pivot;
    Vector2 anchorMin = m_RectTransform.anchorMin;
    Vector2 anchorMax = m_RectTransform.anchorMax;

    if (pivot == m_CachedPivot && anchorMin == m_CachedAnchorMin && anchorMax == m_CachedAnchorMax)
    {
        return;
    }

    m_CachedPivot = pivot;
    m_CachedAnchorMin = anchorMin;
    m_CachedAnchorMax = anchorMax;

    if (m_ImageGo)
    {
        // 传递下去
    }

    // 后略
}

private void OnRectTransformDimensionsChange()
{
    if (!m_Inited)
    {
        return;
    }

    RefreshExtraContents();
}

修改后可以随RectTransform的变化而变化,但发现很容易出现错位现象,原因是unity的自动布局等常在下一帧生效,延时一会儿即可

public int repaintDelayFrame = 3;
private int m_NextRepaintFrame = -1;

private void Update()
{
    if (!m_Inited)
    {
        return;
    }

    if (m_Text.cachedTextGenerator.characterCount != m_CurrentCharactersCount)
    {
        m_NextRepaintFrame = repaintDelayFrame;
        m_CurrentCharactersCount = m_Text.cachedTextGenerator.characterCount;
    }

    if (m_NextRepaintFrame > 0)
    {
        m_NextRepaintFrame--;
    }
    else if (m_NextRepaintFrame == 0)
    {
        Repaint();
        m_NextRepaintFrame--;
    }

    // 略
}

private void OnRectTransformDimensionsChange()
{
    if (!m_Inited)
    {
        return;
    }

    m_NextRepaintFrame = repaintDelayFrame;
}

bug注意

  1. Canvas的RenderMode设置为ScreenSpace - CameraOverlay时,若因CanvasScaler设置了缩放,TextGenerator.characters得到的坐标、宽度数据会有缩放,位置计算出现偏差,需要除一次缩放比率