2023/05/31 : 初稿
Unity : 2021.3.15f1
やりたいこと
Unity・TextMeshProUGUI文字列中にTMP_SpriteAssetにしていないスプライトを埋め込みたい。
例えば、Addressablesでロードした不特定多数のアイコンを、TMP_SpriteAssetに登録することなくテキストに埋め込みたいとか、そういった状況を想定。
TextMeshPro自体が対応してない・・・ですよね・・・?してるのかな?
実現方法
自力でTextMeshProをカスタマイズするのは心が折れるので、昔のUnityEngine.UI.Text時代のやり方で。
文字列中のスプライト表示したい場所に<img=hogehoge>
というタグを埋め込むとする。
スクリプト側で実行時にTextMeshProUGUIに渡すべき文字列を解析し、上記タグを発見したらhogehoge
に該当するスプライトを表示するUnityEngine.UI.ImageをTextMeshProUGUIの子供として配置する。
UnityEngine.UI.Image表示エリア分、TextMeshProUGUI側にはスペースを入れる。
実際はタグとスペース文字一個が入ってます。スペース文字要らないと思われるでしょうが、これあるといろいろ楽なので。
※全てのTextMeshProの設定に対応できてるわけではありません。
※エスケープシーケンスや一文字ずつ表示などいろいろ対応していません。
もっと賢いやり方あるんだろうなあ。
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System.Text;
using TMPro;
namespace Utils
{
// 抽象化したクラス
public abstract class TextMeshProImageDrawerBase<IMAGE, INSTANCE, USER>
{
// ImageData
public struct ImageData
{
// 元の位置
public int Index;
// タグを抜いた文字列とその中での位置
public int TagStartPos;
public int TagLen;
public int NameStartPos;
public int NameLen;
public USER User;
// 表示すべきもの
public IMAGE Image;
public Vector2 EmSize; // 1で一文字分の横幅
public Vector3 EmOffset;
public INSTANCE Instance;
}
public List<ImageData> Datas { get; private set; } = new List<ImageData>();
// builder
static StringBuilder Builder = new StringBuilder();
// tag
protected virtual string BeginTag { get { return "img="; } }
// space
protected virtual string SpaceBegin { get { return "<nobr><space="; } }
protected virtual string SpaceEnd { get { return "em> </nobr>"; } }
// テキスト設定
public void Set(TMP_Text target, string text)
{
// null check
if (target == null) {
return;
}
// TextMeshProのタグ解析結果取得
#if false // AutoSizeの時うまくいかない
var textInfo = target.GetTextInfo(text);
#else
target.SetText(text);
target.ForceMeshUpdate();
var textInfo = target.textInfo;
#endif
// イメージタグ解析
Analyze(target, textInfo);
// 元の文字列
Builder.Clear();
Builder.Append(text);
// イメージ構築
Build(target);
}
// テキスト設定
public void Set(TMP_Text target, StringBuilder builder)
{
// null check
if (target == null) {
return;
}
// TextMeshProのタグ解析結果取得
target.SetText(builder);
target.ForceMeshUpdate();
var textInfo = target.textInfo;
// イメージタグ解析
Analyze(target, textInfo);
// 元の文字列
Builder.Clear();
Builder.Append(builder);
// イメージ構築
Build(target);
}
// タグ解析
protected void Analyze(TMP_Text target, TMP_TextInfo textInfo)
{
// clear
foreach (var data in Datas) {
DestroyImage(data);
}
Datas.Clear();
// <img=xxx>を検索
for (int i = 0; i < textInfo.characterCount; i++) {
TMP_CharacterInfo charaInfo = textInfo.characterInfo[i];
// '<'を検索
if (charaInfo.character != '<') {
continue;
}
// そのあとが"img="か?
bool match = true;
for (int j = 0; j < BeginTag.Length; j++) {
// 最後を越えてしまうので違うし、これ以上は存在しない
if (i + 1 + j >= textInfo.characterCount) {
return;
}
// 順番?
var currentCharaInfo = textInfo.characterInfo[i + 1 + j];
if (currentCharaInfo.index != charaInfo.index + 1 + j) { // 間に別のタグが挟まったので違う
match = false;
break;
}
// タグ?
if (currentCharaInfo.character != BeginTag[j]) {
match = false;
break;
}
}
if (!match) {
continue;
}
// その後の'>'を検索
int nameStartPos = i + BeginTag.Length + 1;
int TagClosePos = -1; // '>'の位置
var currentPos = nameStartPos;
while (true) {
// 越えてない?
if (currentPos >= textInfo.characterCount) {
break;
}
// 調査位置のデータ
var currentCharaInfo = textInfo.characterInfo[currentPos];
// // そうなる前に別のタグが挟まってないか?
if (currentCharaInfo.index != charaInfo.index + currentPos - i) {
break;
}
// '>'になるまで続ける
if (currentCharaInfo.character != '>') {
currentPos++;
continue;
}
// あった。
TagClosePos = currentPos;
break;
}
if (TagClosePos < 0) {
continue;
}
// そこの状況を取得
var (image, emSize, emOffset, user) = GetImageInfo(textInfo, nameStartPos, TagClosePos - nameStartPos);
ImageData data = default;
data.Index = charaInfo.index;
data.TagStartPos = i;
data.TagLen = TagClosePos - i + 1;
data.NameStartPos = nameStartPos;
data.NameLen = TagClosePos - nameStartPos;
data.Image = image;
data.EmSize = emSize;
data.EmOffset = emOffset;
data.User = user;
Datas.Add(data);
}
}
// IsImageNameMatch
protected bool IsImageNameMatch(TMP_TextInfo textInfo, int nameStartPos, int nameLen, string targetName)
{
// null/length check
if (targetName == null || textInfo == null || nameLen != targetName.Length) {
return false;
}
// index check
if (nameStartPos < 0 || nameStartPos + nameLen >= textInfo.characterCount) {
return false;
}
// 一致判定
for (int i = 0; i < nameLen; i++) {
if (textInfo.characterInfo[nameStartPos + i].character != targetName[i]) {
return false;
}
}
// 全部一致しました
return true;
}
// BuildName
protected bool CreateImageName(StringBuilder dst, TMP_TextInfo textInfo, int nameStartPos, int nameLen)
{
// null check
if (textInfo == null || dst == null) {
return false;
}
// index check
if (nameStartPos < 0 || nameStartPos + nameLen >= textInfo.characterCount) {
return false;
}
// Builderへ登録
dst.Clear();
for (int i = 0; i < nameLen; i++) {
dst.Append(textInfo.characterInfo[nameStartPos + i].character);
}
// OK
return true;
}
// GetImageInfo
protected abstract (IMAGE image, Vector2 emSize, Vector3 emOffset, USER user) GetImageInfo(TMP_TextInfo textInfo, int nameStartPos, int nameLen);
// Builderに元の文字列を入れておくこと。Analyzeした結果を元に文字列変換
void Build(TMP_Text target)
{
//<tex xxx>を全てサイズに合わせたスペースタブに変換し、
// 同じ場所にImageを設定
for (int i = Datas.Count - 1; i >= 0; i--) {
// "<tex xxx>"を"<nobr><space=*em> </nobr>"に置換
var data = Datas[i];
var addedLen = Replace(data);
// 取り除いた文字数分だけ、後ろの位置をずらす
for (int j = i + 1; j < Datas.Count; j++) {
var tmp = Datas[j];
tmp.Index += addedLen;
tmp.TagStartPos += addedLen;
tmp.NameStartPos += addedLen;
Datas[j] = tmp;
}
}
// 最終設定
target.SetText(Builder);
target.ForceMeshUpdate();
// スプライト部分にイメージを配置
var textInfo = target.textInfo;
for (int i = 0; i < Datas.Count; i++) {
var data = Datas[i];
data = AddImage(target, textInfo, data);
Datas[i] = data;
}
// 後始末
Builder.Clear();
}
// Replace
int Replace(ImageData data)
{
// texタグを取り除く
var len = Builder.Length;
Builder.Remove(data.Index, data.TagLen);
var removedLen = len - Builder.Length;
// <nobr><space=xem> </nobr>を追加
Builder.Insert(data.Index, SpaceEnd);
Builder.Insert(data.Index, data.EmSize.x);
Builder.Insert(data.Index, SpaceBegin);
// 表示する文字としてはスペース一個分増えた
return -removedLen + 1;
}
// AddImage
protected virtual ImageData AddImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data)
{
// 置き換えたスペース文字の頂点座標を取得
var charInfo = textInfo.characterInfo[data.TagStartPos];
var posLB = charInfo.bottomLeft;
var posRT = charInfo.topRight;
// 左右
posLB.x = charInfo.origin;
posRT.x = charInfo.xAdvance;
// 上下
posLB.y = charInfo.descender;
posRT.y = charInfo.ascender;
// emサイズ
var emSize = charInfo.pointSize;
// その左端から<space>分左が画像エリアの左端
var min = posLB + Vector3.left * emSize * data.EmSize.x;
// 右端はそのまま右端
var max = posRT;
// image設置
data.Instance = InstantiateImage(target, textInfo, data, (min + max) * 0.5f + data.EmOffset, data.EmSize * emSize);
// OK
return data;
}
// 設置
protected abstract INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size);
protected abstract void DestroyImage(ImageData data);
}
// UGUI/Image
public abstract class TextMeshProImageDrawerUGUIBase<IMAGE, INSTANCE, USER> : TextMeshProImageDrawerBase<IMAGE, INSTANCE, USER> where INSTANCE : Graphic
{
// 設置
protected override INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size)
{
// Imageを追加してスプライトを適用
var image = InstantiateImage(target, textInfo, data, center, size, target.transform);
if (image == null) {
return null;
}
// サイズ設定
SetRectTransform(image.rectTransform, center, size);
// 生成したものを返す
return image;
}
// サイズ設定
protected void SetRectTransform(RectTransform target, Vector3 center, Vector2 size)
{
if (target == null) {
return;
}
target.SetLocalPositionAndRotation(center, Quaternion.identity);
target.localScale = Vector3.one;
target.sizeDelta = size;
}
// Instantiate
protected abstract INSTANCE InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent);
// Destroy
protected override void DestroyImage(ImageData data)
{
if (data.Instance == null) {
return;
}
UnityEngine.Object.Destroy(data.Instance.gameObject);
}
}
// UGUI/Image
public abstract class TextMeshProImageDrawerUGUI<USER> : TextMeshProImageDrawerUGUIBase<Sprite, Image, USER>
{
// Instantiate
protected override Image InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent)
{
// Imageを追加してスプライトを適用
var go = new GameObject("img");
go.transform.SetParent(parent);
var image = go.AddComponent<Image>();
if (data.Image != null) {
image.sprite = data.Image;
}
return image;
}
}
// UGUI/RawImage
public abstract class TextMeshProImageDrawerUGUIRaw<USER> : TextMeshProImageDrawerUGUIBase<Texture2D, RawImage, USER>
{
protected override RawImage InstantiateImage(TMP_Text target, TMP_TextInfo textInfo, ImageData data, Vector3 center, Vector2 size, Transform parent)
{
// RawImageを追加してテクスチャを適用
var go = new GameObject("img");
go.transform.SetParent(parent);
var image = go.AddComponent<RawImage>();
if (data.Image != null) {
image.texture = data.Image;
}
return image;
}
}
}
使い方のサンプルとしてはこんな感じ。
下記のファイルを上のファイルと一緒にプロジェクトに配置して、
どこかのTextMeshProUGUIのついたGameObjectにAddComponent()すれば動くはず。
using UnityEngine;
using TMPro;
using System;
using System.Collections.Generic;
namespace Utils
{
// サンプル
public class TextMeshProImageDrawerUGUIRawSample : TextMeshProImageDrawerUGUIRaw<int>
{
// テクスチャリスト
[Serializable]
public struct TexData
{
public string Name;
public Texture2D Tex;
public float Scale; // defaultは1
public Vector3 EmOffset; // defaultはゼロ
// コンストラクタ
public TexData(string name, Texture2D tex)
{
this = default;
Name = name;
Tex = tex;
Scale = 1;
}
// コンストラクタ
public TexData(string name, Texture2D tex, float scale)
{
this = default;
Name = name;
Tex = tex;
Scale = scale;
}
// コンストラクタ
public TexData(string name, Texture2D tex, float scale, Vector3 emOffset)
{
this = default;
Name = name;
Tex = tex;
Scale = scale;
EmOffset = emOffset;
}
}
List<TexData> TexDatas;
// Set
public void Set(TextMeshProUGUI target, string text, List<TexData> spriteDatas)
{
TexDatas = spriteDatas;
Set(target, text);
TexDatas = null;
}
// GetImageInfo
protected override (Texture2D image, Vector2 emSize, Vector3 emOffset, int user) GetImageInfo(TMP_TextInfo textInfo, int nameStartPos, int nameLen)
{
// コード整理
if (TexDatas == null) {
return default;
}
// 名前が一致するものを検索
foreach (var texData in TexDatas) {
// 一致判定
if (!IsImageNameMatch(textInfo, nameStartPos, nameLen, texData.Name)) {
continue;
}
// 画像
var tex = texData.Tex;
// 画像なし
if (tex == null) {
return default;
}
// サイズに合わせる
var w = tex.width;
var h = tex.height;
if (w <= 0 || h <= 0) {
return default;
}
return (tex, new Vector2((float)w / h, 1) * texData.Scale, texData.EmOffset, default);
}
// なかった
return default;
}
}
// Mono
public class TextMeshProImageDrawerSample : MonoBehaviour
{
// inspector
[SerializeField] string SetOnAwake = "あいうえ<color=red>お<img=gray>か</color>きくけこ<img=white>さ";
[SerializeField] List<TextMeshProImageDrawerUGUIRawSample.TexData> TexDatas;
// 適当
List<TextMeshProImageDrawerUGUIRawSample.TexData> SampleTexDatas;
// Target
TextMeshProUGUI Target;
// core
TextMeshProImageDrawerUGUIRawSample Core = new TextMeshProImageDrawerUGUIRawSample();
// Awake
void Awake()
{
// get target
Target = GetComponent<TextMeshProUGUI>();
// sample
SampleTexDatas = new List<TextMeshProImageDrawerUGUIRawSample.TexData>()
{
new TextMeshProImageDrawerUGUIRawSample.TexData("gray", Texture2D.grayTexture),
new TextMeshProImageDrawerUGUIRawSample.TexData("white", Texture2D.whiteTexture),
};
if (TexDatas != null)
{
SampleTexDatas.AddRange(TexDatas);
}
// 自動設定
if (!string.IsNullOrEmpty(SetOnAwake))
{
SetText(SetOnAwake);
}
}
// テキスト設定
public void SetText(string text)
{
Core.Set(Target, text, SampleTexDatas);
}
}
}