項目中原來的富文本組件不太好用,做了一些修改,記述主要思路。缺陷很多。
僅適用於沒用TextMeshPro,且不打算用的項目,否則請直接用TextMeshPro
原組件特點:
- 使用占位符模式,創建新的GameObject,掛載Image組件實現圖文混排
- 主要通過正則匹配分析語法,擴展不便
- 固定RectTransform的anchor、pivot,Text的alignment,修改排版后需要手動計算相關位置,不能實現自動布局
新組件目標
- 通過逐步讀取的方式分析語法
- 實現混排內容位置的自動計算
主要實現思路
需要實現的混排功能
- 靜態圖片(sprite)
- 動態表情
- 鏈接點擊響應
- 顏色(簡略代號和#FFFFFF)
- 下划線
- 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的<>修改為#)
語法分析步驟
- 預處理輸入的字符串,清除UGUI Text的
<>標記內容,進行一些其他需要的前期處理 - 清理分析棧、根節點、已存儲的數據,根節點入棧
- 按順序讀取預處理后的字符串
- 如果下一個字符不是'#',讀到下一個'#n',存為一個普通字符類型標記
- 如果下一個字符是'#',如果是'#n',結束上一個標記,否則讀到下一個'#n'
- 讀取到字符串結束
- 檢驗分析結果,若中間有標記不匹配,或最后分析棧不是只包含根節點,分析結果錯誤,直接輸出原字符串;否則正確,開始生成特殊標記
一些實現細節
[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注意
- Canvas的RenderMode設置為ScreenSpace - Camera\Overlay時,若因CanvasScaler設置了縮放,TextGenerator.characters得到的坐標、寬度數據會有縮放,位置計算出現偏差,需要除一次縮放比率
