重点提示:当前的文本扩展支持多个超链接,支持修改超链接规则和支持修改超链接颜色。
近期在邮件文本中用到了超链接。最初是在邮件窗口中新加一个按钮用来超链接跳转,之后发现效果表现不如直接在文本中添加,后经过几个小时的资料查询将遇到的解决方法和问题贴出来。
方案一:换用TMP组件
问题:需要制作字体库等额外操作,改动较大不太适合。
方案二:网上找相关Text组件扩展
问题:在大多数扩展中,仅支持一个超链接文本。当文本中出现多个超链接文本时,只会响应第一个匹配的超链接点击事件。
最后在GitHub中找到了一个比较适合的Text扩展,支持多个正则超链接规则:GitHub - setchi/uGUI-Hypertext: Hypertext for uGUI
这里的解决方案,就是在这个脚本上进行修改而来的。原脚本中,每个超链接对应独立的点击事件,以及超链接颜色修改。更详细可直接看支持库中的例子。
根据需要,绑定超链接唯一点击事件,添加颜色开关等等,具体可直接查看代码:
///
/// 《超链接文本》支持多个链接 支持正则表达式
/// 当前版本修改于 uGUI-Hypertext GitHub:https://github.com/setchi/uGUI-Hypertext/tree/master
/// 新增超链接颜色修改控制。
/// 统一事件点击回调
/// 默认支持href匹配
/// 版本:0.10.0
///
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Hyperlink
{
/// <summary>
/// 顶点池子
/// </summary>
/// <typeparam name="T"></typeparam>
internal class ObjectPool<T> where T : new()
{
private readonly Stack<T> _stack = new Stack<T>();
private readonly Action<T> _getAction;
private readonly Action<T> _releaseAction;
/// <summary>
/// 总数
/// </summary>
public int Count { get; set; }
/// <summary>
/// 没有被使用的数量
/// </summary>
public int UnusedCount => _stack.Count;
/// <summary>
/// 已经使用的数量
/// </summary>
public int UsedCount => Count - UnusedCount;
public ObjectPool(Action<T> onGetAction, Action<T> onRelease)
{
_getAction = onGetAction;
_releaseAction = onRelease;
}
public T Get()
{
T element;
if (_stack.Count == 0)
{
element = new T();
Count++;
}
else
{
element = _stack.Pop();
}
_getAction?.Invoke(element);
return element;
}
public void Release(T element)
{
if (_stack.Count > 0 && ReferenceEquals(_stack.Peek(), element))
{
UnityEngine.Debug.LogError("试图归还已经归还的对象。");
}
_releaseAction?.Invoke(element);
_stack.Push(element);
}
}
/// <summary>
/// 超链接信息块
/// </summary>
internal class LinkInfo
{
public readonly int StartIndex;
public readonly int Length;
public readonly string Link = null;
public readonly string Text;
public readonly Color Color;
public readonly bool OverwriteColor = false;
public readonly ClickLinkEvent Callback;
public List<Rect> Boxes;
public LinkInfo(int startIndex, int length, Color? color, ClickLinkEvent callback)
{
StartIndex = startIndex;
Length = length;
Link = null;
Text = null;
OverwriteColor = color.HasValue;
if (color.HasValue)
{
Color = color.Value;
}
Callback = callback;
Boxes = new List<Rect>();
}
public LinkInfo(int startIndex, int length, string link, string text, Color? color, ClickLinkEvent callback)
{
StartIndex = startIndex;
Length = length;
Link = link;
Text = text;
OverwriteColor = color.HasValue;
if (color.HasValue)
{
Color = color.Value;
}
Callback = callback;
Boxes = new List<Rect>();
}
public LinkInfo(int startIndex, string link, string text, Color? color, ClickLinkEvent callback) : this(startIndex, text.Length, link, text, color,
callback)
{
}
public LinkInfo(int startIndex, string link, string text, ClickLinkEvent callback) : this(startIndex, link, text, Color.blue,
callback)
{
}
}
/// <summary>
/// 超链接点击事件
/// </summary>
[Serializable]
public class ClickLinkEvent : UnityEvent<string,string>
{
}
/// <summary>
/// 超链接正则表达式
/// </summary>
[Serializable]
public class RegexPattern
{
public string pattern;
public Color color;
public bool overwriteColor = false;
public RegexPattern(string regexPattern, Color color,bool overwriteColor = true)
{
this.pattern = regexPattern;
this.overwriteColor = overwriteColor;
this.color = color;
}
public RegexPattern(string regexPattern,bool overwriteColor = true):this(regexPattern,Color.blue,overwriteColor)
{
}
}
public class TextHyperlink : Text, IPointerClickHandler
{
private const int CharVertex = 6;
private const char Tab = '\t', LineFeed = '\n', Space = ' ', LesserThan = '<', GreaterThan = '>';
/// <summary>
/// 看不见顶点的字符
/// </summary>
private readonly char[] _invisibleChars =
{
Space,
Tab,
LineFeed
};
/// <summary>
/// 超链接信息块
/// </summary>
private readonly List<LinkInfo> _links = new List<LinkInfo>();
/// <summary>
/// 字符顶点池
/// </summary>
private static readonly ObjectPool<List<UIVertex>> UIVerticesPool = new ObjectPool<List<UIVertex>>(null, l => l.Clear());
/// <summary>
/// 字符索引映射
/// </summary>
private int[] _charIndexMap;
private Canvas _root;
private Canvas RootCanvas => _root ? _root : (_root = GetComponentInParent<Canvas>());
/// <summary>
/// 超链接匹配规则
/// </summary>
public List<RegexPattern> linkRegexPattern = new List<RegexPattern>()
{
new(@"<a href=([^>\n\s]+)>(.*?)(</a>)"),
};
[SerializeField]
private ClickLinkEvent _onClickLink = new ClickLinkEvent();
/// <summary>
/// 超链接点击事件
/// </summary>
public ClickLinkEvent onClickLink
{
get => _onClickLink;
set => _onClickLink = value;
}
#region PopulateMesh
private readonly UIVertex[] _tempVerts = new UIVertex[4];
protected override void OnPopulateMesh(VertexHelper toFill)
{
if (font == null)
{
return;
}
m_DisableFontTextureRebuiltCallback = true;
var extents = rectTransform.rect.size;
var settings = GetGenerationSettings(extents);
settings.generateOutOfBounds = true;
cachedTextGenerator.PopulateWithErrors(text, settings, gameObject);
var verts = cachedTextGenerator.verts;
var unitsPerPixel = 1 / pixelsPerUnit;
var vertCount = verts.Count;
if (vertCount <= 0)
{
toFill.Clear();
return;
}
var roundingOffset = new Vector2(verts[0].position.x, verts[0].position.y) * unitsPerPixel;
roundingOffset = PixelAdjustPoint(roundingOffset) - roundingOffset;
toFill.Clear();
if (roundingOffset != Vector2.zero)
{
for (var i = 0; i < vertCount; ++i)
{
var tempVertsIndex = i & 3;
_tempVerts[tempVertsIndex] = verts[i];
_tempVerts[tempVertsIndex].position *= unitsPerPixel;
_tempVerts[tempVertsIndex].position.x += roundingOffset.x;
_tempVerts[tempVertsIndex].position.y += roundingOffset.y;
if (tempVertsIndex == 3)
{
toFill.AddUIVertexQuad(_tempVerts);
}
}
}
else
{
for (var i = 0; i < vertCount; ++i)
{
var tempVertsIndex = i & 3;
_tempVerts[tempVertsIndex] = verts[i];
_tempVerts[tempVertsIndex].position *= unitsPerPixel;
if (tempVertsIndex == 3)
{
toFill.AddUIVertexQuad(_tempVerts);
}
}
}
var vertices = UIVerticesPool.Get();
toFill.GetUIVertexStream(vertices);
GenerateCharIndexMap(vertices.Count < text.Length * CharVertex);
_links.Clear();
TryAddMatchLink();
GenerateHrefBoxes(ref vertices);
toFill.Clear();
toFill.AddUIVertexTriangleStream(vertices);
UIVerticesPool.Release(vertices);
m_DisableFontTextureRebuiltCallback = false;
}
/// <summary>
/// 生成超链接包围框
/// </summary>
/// <param name="vertices"></param>
private void GenerateHrefBoxes(ref List<UIVertex> vertices)
{
var verticesCount = vertices.Count;
for (var i = 0; i < _links.Count; i++)
{
var linkInfo = _links[i];
var startIndex = _charIndexMap[linkInfo.StartIndex];
var endIndex = _charIndexMap[linkInfo.StartIndex + linkInfo.Length - 1];
for (var textIndex = startIndex; textIndex <= endIndex; textIndex++)
{
var vertexStartIndex = textIndex * CharVertex;
if (vertexStartIndex + CharVertex > verticesCount)
{
break;
}
var min = Vector2.one * float.MaxValue;
var max = Vector2.one * float.MinValue;
for (var vertexIndex = 0; vertexIndex < CharVertex; vertexIndex++)
{
var vertex = vertices[vertexStartIndex + vertexIndex];
if (linkInfo.OverwriteColor)
{
vertex.color = linkInfo.Color;
}
vertices[vertexStartIndex + vertexIndex] = vertex;
var pos = vertices[vertexStartIndex + vertexIndex].position;
if (pos.y < min.y)
{
min.y = pos.y;
}
if (pos.x < min.x)
{
min.x = pos.x;
}
if (pos.y > max.y)
{
max.y = pos.y;
}
if (pos.x > max.x)
{
max.x = pos.x;
}
}
linkInfo.Boxes.Add(new Rect {min = min, max = max});
}
linkInfo.Boxes = CalculateLineBoxes(linkInfo.Boxes);
}
}
/// <summary>
/// 计算行包围框
/// </summary>
/// <param name="boxes"></param>
/// <returns></returns>
private static List<Rect> CalculateLineBoxes(List<Rect> boxes)
{
var lineBoxes = new List<Rect>();
var lineStartIndex = 0;
for (var i = 1; i < boxes.Count; i++)
{
if (boxes[i].xMin >= boxes[i - 1].xMin)
{
continue;
}
lineBoxes.Add(CalculateAABB(boxes.GetRange(lineStartIndex, i - lineStartIndex)));
lineStartIndex = i;
}
if (lineStartIndex < boxes.Count)
{
lineBoxes.Add(CalculateAABB(boxes.GetRange(lineStartIndex, boxes.Count - lineStartIndex)));
}
return lineBoxes;
}
private static Rect CalculateAABB(IReadOnlyList<Rect> rects)
{
var min = Vector2.one * float.MaxValue;
var max = Vector2.one * float.MinValue;
for (var i = 0; i < rects.Count; i++)
{
if (rects[i].xMin < min.x)
{
min.x = rects[i].xMin;
}
if (rects[i].yMin < min.y)
{
min.y = rects[i].yMin;
}
if (rects[i].xMax > max.x)
{
max.x = rects[i].xMax;
}
if (rects[i].yMax > max.y)
{
max.y = rects[i].yMax;
}
}
return new Rect {min = min, max = max};
}
/// <summary>
/// 生成字节索引映射
/// </summary>
/// <param name="verticesReduced"></param>
private void GenerateCharIndexMap(bool verticesReduced)
{
if (_charIndexMap == null || _charIndexMap.Length < text.Length)
{
Array.Resize(ref _charIndexMap, text.Length);
}
if (!verticesReduced)
{
for (var i = 0; i < _charIndexMap.Length; i++)
{
_charIndexMap[i] = i;
}
return;
}
var offset = 0;
var inTag = false;
for (var i = 0; i < text.Length; i++)
{
var character = text[i];
if (inTag)
{
offset--;
if (character == GreaterThan)
{
inTag = false;
}
}
else if (supportRichText && character == LesserThan)
{
offset--;
inTag = true;
}
else if (_invisibleChars.Contains(character))
{
offset--;
}
_charIndexMap[i] = Mathf.Max(0, i + offset);
}
}
#endregion
private Vector3 CalculateLocalPosition(Vector3 position, Camera pressEventCamera)
{
if (!RootCanvas)
{
return Vector3.zero;
}
if (RootCanvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
return transform.InverseTransformPoint(position);
}
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform,
position,
pressEventCamera,
out var localPosition
);
return localPosition;
}
void IPointerClickHandler.OnPointerClick(PointerEventData eventData)
{
var localPosition = CalculateLocalPosition(eventData.position, eventData.pressEventCamera);
foreach (var linkInfo in _links)
{
if (!linkInfo.Boxes.Any(t => t.Contains(localPosition))) continue;
var subText = text.Substring(linkInfo.StartIndex, linkInfo.Length);
var link = linkInfo.Link ?? subText;
var content = linkInfo.Text ?? subText;
linkInfo.Callback?.Invoke(link,content);
}
}
#region Add Text Link
/// <summary>
/// 尝试添加超链接
/// </summary>
private void TryAddMatchLink()
{
foreach (var entry in linkRegexPattern)
{
var matches = Regex.Matches(text, entry.pattern, RegexOptions.Singleline);
foreach (Match match in matches)
{
var regex = new Regex(entry.pattern, RegexOptions.Singleline);
var regexMatch = regex.Match(match.Value);
var overwriteColor = entry.overwriteColor == true ? entry.color : (Color?)null;
if (regexMatch.Success)
{
var group = match.Groups[1];
AddLink(match.Index, group.Value,match.Value, overwriteColor, _onClickLink);
}
else
{
AddLink(match.Index, match.Value.Length, overwriteColor, _onClickLink);
}
}
}
}
private void CheckLinkException(int startIndex, int length, ClickLinkEvent onClick)
{
if (onClick == null)
{
throw new ArgumentNullException(nameof(onClick));
}
if (startIndex < 0 || startIndex > text.Length - 1)
{
throw new ArgumentOutOfRangeException(nameof(startIndex));
}
if (length < 1 || startIndex + length > text.Length)
{
throw new ArgumentOutOfRangeException(nameof(length));
}
}
private void AddLink(int startIndex, int length, Color? linkColor, ClickLinkEvent onClick)
{
CheckLinkException(startIndex, length, onClick);
_links.Add(new LinkInfo(startIndex, length, linkColor, onClick));
}
private void AddLink(int startIndex, string link, string content, Color? linkColor, ClickLinkEvent onClick)
{
CheckLinkException(startIndex, content.Length, onClick);
_links.Add(new LinkInfo(startIndex, link, content, linkColor, onClick));
}
protected void AddLink(int startIndex, string link, string content, ClickLinkEvent onClick)
{
CheckLinkException(startIndex, content.Length, onClick);
_links.Add(new LinkInfo(startIndex, link, content, onClick));
}
protected void CleanLink()
{
_links.Clear();
linkRegexPattern.Clear();
}
#endregion
#region Hyperlink_Test
#if Hyperlink_Test
protected override void OnEnable()
{
base.OnEnable();
onClickLink.AddListener(OnClickLinkText);
}
protected override void OnDisable()
{
base.OnDisable();
onClickLink.RemoveListener(OnClickLinkText);
}
/// <summary>
/// 当前点击超链接回调
/// </summary>
private void OnClickLinkText(string link,string content)
{
Debug.Log($"超链接信息:{link}\n{content}");
Application.OpenURL(link);
}
#endif
#endregion
}
}
编辑器面板扩展:
继承自UGUI-Text面板,在原有数据显示上,添加可编辑的扩展属性。
using UnityEditor;
using UnityEditor.UI;
namespace Hyperlink.Editor
{
[CustomEditor(typeof(TextHyperlink), true)]
[CanEditMultipleObjects]
public class TextHyperlinkEditor : TextEditor
{
private SerializedProperty _linkRegexPattern;
private SerializedProperty _onClickLink;
protected override void OnEnable()
{
base.OnEnable();
_linkRegexPattern = serializedObject.FindProperty("linkRegexPattern");
_onClickLink = serializedObject.FindProperty("_onClickLink");
}
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
serializedObject.Update();
EditorGUILayout.PropertyField(_linkRegexPattern);
EditorGUILayout.Space();
EditorGUILayout.PropertyField(_onClickLink);
serializedObject.ApplyModifiedProperties();
}
}
}
效果图: