UE4のRichTextBlockを拡張してみる

#はじめに
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">RichTextを少し拡張してStyleSetのデータテーブルIDからデフォルトのStyleSetを持ってくるようにしてみた。<a href="https://twitter.com/hashtag/UE4Study?src=hash&amp;ref_src=twsrc%5Etfw">#UE4Study</a> <a href="https://t.co/DEJYNFyZia">pic.twitter.com/DEJYNFyZia</a></p>&mdash; Naotsun (@Naotsun_UE) <a href="https://twitter.com/Naotsun_UE/status/1274334353103831052?ref_src=twsrc%5Etfw">June 20, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

最近、RichTextBlockを触っていていくつか発見があったのでまとめてみようと思います。
また、標準のRichTextBlockを使っていて不便だと思った部分を拡張してみます。
こちらの記事で紹介するプロジェクトのUE4のバージョンは4.25です。
RichTextの基本的な使い方を理解している前提で話が進みますので以下の記事に目を通してから読み進めて頂ければと思います。

[Rich Text Block を使ったテキストの高度なスタイル設定](https://www.unrealengine.com/ja/tech-blog/advanced-text-styling-with-rich-text-block)

#拡張して実装する機能
![1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325493/210f309a-57ec-1141-1052-9716d18a84a3.png)

- **Default以外のTextStyleSetを初期値として設定する機能**
  標準のRichTextBlockではTextStyleSetで指定したデータテーブルの`Default`以外のTextStyleSetを使用する場合、対象の箇所を`<'RowName'></>`で囲う必要があります。一部分だけならこれでいいのですが、全体を変えたい場合は全部をタグで囲うか個別のデータテーブルを作成するかの対応が必要です。

- **TextStyleSetのデータテーブルに`Default`のRowがない時にコンパイルエラーを出す機能**
  標準のRichTextBlockには`DefaultTextStyleOverride`という項目があり、ここで指定した情報でTextStyleSetの情報を上書きできます。そのため、TextStyleSetのデータテーブルに`Default`のRowが無くても動作させることができます。エディタ上では問題なく動作しますが、パッケージ化すると`RichTextBlockImageDecorator`を使用して挿入しているテクスチャが表示されなくなるため、データテーブルに`Default`のRowを追加しないとコンパイルが通らないようにします。

- **DefaultTextStyleOverrideを封印**
  こちらの機能は便利ですが、上記のような不具合が生じたり、データテーブルでの管理から漏れるデータが出てきてしまいますし、拡張版RichTextBlockで内部的に`DefaultTextStyleOverride`を使用するため封印します。

- **4.20以前のRichTextBlockで使えたインライン装飾タグを復活させる**
  [Acren/RichTextBlockInlineDecorator](https://github.com/Acren/RichTextBlockInlineDecorator)
こちらを参考にさせて頂き、
[[UE4] 部分的に色付きのテキストをUMGで作成する](https://historia.co.jp/archives/8078/)
こちらの記事で紹介されているcolorやsizeなどのインライン装飾タグを使用できるようにします。

- **ImageDecoratorで挿入するテクスチャのサイズをDetailsから変更できるようにする**
  標準のRichTextBlockImageDecoratorでは、挿入するテクスチャのサイズは`width`タグと`height`タグを指定して変更できますが、テクスチャごとに毎回タグを付けるのも面倒なのでデフォルト値を設定できるようにします。

- **デフォルトのRichTextBlockImageDecoratorを使用できないようにする**
  上記の機能を実装するために拡張版RichTextBlockでは、拡張版RichTextBlockImageDecoratorのみをサポートするようにします。

#実装方法の紹介
色々と機能を追加しましたが、全部説明すると長くなるので一部掻い摘んで紹介します。

---

- **Default以外のTextStyleSetを初期値として設定する機能**  
[CustomRichTextBlock.h](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Public/CustomRichTextBlock.h)  
[CustomRichTextBlock.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/CustomRichTextBlock.cpp)

```CustomRichTextBlock.h
protected:
	// デフォルトで使用するスタイルセットのデータテーブルID.
	UPROPERTY(EditAnywhere, Category = "Appearance")
	FName DefaultStyleSetID;

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

protected:
	// UObject Interface.
	virtual void PostLoad() override;
#if WITH_EDITOR
	virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;
	virtual bool CanEditChange(const FProperty* InProperty) const override;
#endif
	// End of UObject Interface.

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    virtual void OnChageDefaultStyleSetID();
```

```CustomRichTextBlock.cpp
void UCustomRichTextBlock::PostLoad()
{
	Super::PostLoad();

	OnChageDecoratorClasses();
	
	// データテーブル更新時に変更を反映させる.
	if (IsValid(TextStyleSet))
	{
		TextStyleSet->OnDataTableChanged().AddUObject(this, &UCustomRichTextBlock::OnChageDefaultStyleSetID);
	}

	OnChageInlineImageSize();
}

#if WITH_EDITOR
void UCustomRichTextBlock::PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent)
{
	Super::PostEditChangeProperty(PropertyChangedEvent);

	// DecoratorClassesが変更されたらInlineDecoratorがあるかチェック.
	if (PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, DecoratorClasses))
	{
		OnChageDecoratorClasses();
	}

	// DefaultStyleSetIDの変更を適用.
	if (PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, DefaultStyleSetID) ||
		PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, DefaultTextStyleOverride))
	{
		OnChageDefaultStyleSetID();
	}

	// InlineImageSizeの変更を適用.
	if (PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, InlineImageSize))
	{
		OnChageInlineImageSize();
	}
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

void UCustomRichTextBlock::OnChageDefaultStyleSetID()
{
	// データテーブルにあるIDなら変更を適用し、値をDefaultにする.
	if (auto StyleRow = TextStyleSet->FindRow<FRichTextStyleRow>(DefaultStyleSetID, StaticClass()->GetName()))
	{
		SetDefaultTextStyle(StyleRow->TextStyle);
	}
	else
	{
		DefaultStyleSetID = DefaultRowName;
	}
}
```

色々とやっていますが、流れ的には`UDataTable::OnDataTableChanged`と`UObject::PostEditChangeProperty`のタイミングで`OnChageDefaultStyleSetID`関数を呼んでいます。
`OnChageDefaultStyleSetID`関数ではTextStyleSetのデータテーブルの指定のRowをDefaultTextStyleとして設定します。`URichTextBlock::SetDefaultTextStyle`を使用すると`DefaultTextStyleOverride`に設定されます。なので、拡張版RichTextBlockでは`DefaultTextStyleOverride`を封印します。

---

- **TextStyleSetのデータテーブルに`Default`のRowがない時にコンパイルエラーを出す機能**  
- **デフォルトのRichTextBlockImageDecoratorを使用できないようにする**  

![2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325493/ede46915-0729-a278-c6b4-3ed8ccc3a2a9.png)

[CustomRichTextBlock.h](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Public/CustomRichTextBlock.h)
[CustomRichTextBlock.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/CustomRichTextBlock.cpp)

```CustomRichTextBlock.h
	// UWidget interface.
#if WITH_EDITOR
	virtual void ValidateCompiledDefaults(IWidgetCompilerLog& CompileLog) const override;
#endif
	// End of UWidget interface.
```

```CustomRichTextBlock.cpp
void UCustomRichTextBlock::ValidateCompiledDefaults(IWidgetCompilerLog& CompileLog) const
{
	Super::ValidateCompiledDefaults(CompileLog);
	if (TextStyleSet)
	{
		// データテーブルにDefaultのRowがない.
		auto DefaultStyleRow = TextStyleSet->FindRow<FRichTextStyleRow>(DefaultRowName, StaticClass()->GetName());
		if (!DefaultStyleRow)
		{
			CompileLog.Error(
				FText::Format(LOCTEXT("RichTextBlock_NotFountDefaultRow", "{0} has no Row in it named {1}."),
					FText::FromString(TextStyleSet->GetName()), FText::FromName(DefaultRowName)
				));
		}
	}
	else
	{
		// データテーブルがセットされていない.
		CompileLog.Error(
			FText::Format(LOCTEXT("RichTextBlock_NotFountStyleSetDataTable", "The data table of Style Set is not set for {0}."),
				FText::FromString(GetName())
			));
	}

	// 通常のRichTextBlockImageDecoratorはサポートしない.
	for (auto DecoratorClass : DecoratorClasses)
	{
		if (DecoratorClass->IsChildOf(URichTextBlockImageDecorator::StaticClass()) &&
			!DecoratorClass->IsChildOf(UCustomRichTextBlockImageDecorator::StaticClass()))
		{
			CompileLog.Error(
				FText::Format(LOCTEXT("RichTextBlock_NotSupportURichTextBlockImageDecorator", "{0} does not support decorators that inherit {1}. Use a class that inherits from {2}."),
					FText::FromString(StaticClass()->GetName()), FText::FromString(URichTextBlockImageDecorator::StaticClass()->GetName()), FText::FromString(UCustomRichTextBlockImageDecorator::StaticClass()->GetName())
				));
		}
	}
}
```
`UWidget::ValidateCompiledDefaults`関数はコンパイル時にデータが正常かを確認して、コンパイルエラーやワーニングを出すことができます。
今回はここで「データテーブルがセットされていないか」と「データテーブルにDefaultのRowがあるか」と「通常のRichTextBlockImageDecoratorが使われていないか」を確認し、問題があればコンパイルエラーになるようにしています。
ワーニングを出したい場合は`CompileLog::Error`ではなく`CompileLog::Warning`を使用します。

---

- **DefaultTextStyleOverrideを封印**  
[CustomRichTextBlock.h](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Public/CustomRichTextBlock.h)  
[CustomRichTextBlock.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/CustomRichTextBlock.cpp)

```CustomRichTextBlock.h
   virtual bool CanEditChange(const FProperty* InProperty) const override;
```

```CustomRichTextBlock.cpp
bool UCustomRichTextBlock::CanEditChange(const FProperty* InProperty) const
{
	// bOverrideDefaultStyleとDefaultTextStyleOverrideの使用は禁止.
	bool bCanEdit = true;
	if (InProperty->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, bOverrideDefaultStyle) ||
		InProperty->GetFName() == GET_MEMBER_NAME_CHECKED(UCustomRichTextBlock, DefaultTextStyleOverride))
	{
		bCanEdit = false;
	}

	return (Super::CanEditChange(InProperty) && bCanEdit);
}
```
`UObject::CanEditChange`関数はエディタ上でそのプロパティが変更できるかを設定できます。同様のことが[UPROPERTYのメタ指定子`EditCondition`](https://docs.unrealengine.com/ja/Programming/UnrealArchitecture/Reference/Properties/Specifiers/index.html)でもできますが、既に親クラスで定義されている変数については適用できません。(ベースクラスのコードを変える必要がある...)
`UObject::CanEditChange`関数を使用することにより子クラスから親クラスの変数の変更の可否を切り替えることができます。
今回は`bOverrideDefaultStyle`と`DefaultTextStyleOverride`を使えないようにしたいため、この二つのプロパティの時だけfalseを返すようにしています。
ちなみに、プロパティは`FProperty::GetFName`と`GET_MEMBER_NAME_CHECKED(対象のクラス, 対象のプロパティ)`を比較することで該当のプロパティかを判定できます。（4.24以前では`UProperty::GetFName`）

---

- **4.20以前のRichTextBlockで使えたインライン装飾タグを復活させる**  
[RichTextBlockInlineDecorator.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/RichTextBlockInlineDecorator.cpp)

```RichTextBlockInlineDecorator.cpp
TSharedPtr<ITextDecorator> URichTextBlockInlineDecorator::CreateDecorator(URichTextBlock* InOwner)
{
	FSlateFontInfo DefaultFont = InOwner->GetCurrentDefaultTextStyle().Font;
	FLinearColor DefaultColor = InOwner->GetCurrentDefaultTextStyle().ColorAndOpacity.GetSpecifiedColor();

	return MakeShareable(new FDefaultRichTextDecorator(this, DefaultFont, DefaultColor));
}
```
[Acren/RichTextBlockInlineDecorator](https://github.com/Acren/RichTextBlockInlineDecorator)
基本的にはこちらのコードを使わせていただきましたが、一部拡張版RichTextBlockに対応するため変更しました。
元のコードでは`DefaultFont`と`DefaultColor`は`URichTextBlock::GetDefaultTextStyle`から設定していましたが、こちらの関数では`DefaultTexrStyleOverride`の値は取得できず、インライン装飾タグを使用すると`DefaultTexrStyleOverride`の設定が無視されてしまうため`URichTextBlock::GetCurrentDefaultTextStyle`を使用します。

---

- **ImageDecoratorで挿入するテクスチャのサイズをDetailsから変更できるようにする**  
[CustomRichTextBlock.h](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Public/CustomRichTextBlock.h)  

```CustomRichTextBlock.h
protected:
	//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

	// 挿入される画像サイズ.
	UPROPERTY(EditAnywhere, Category = "Appearance")
	int32 InlineImageSize;

public:
	//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

	// 挿入される画像サイズを取得.
	UFUNCTION(BlueprintPure, Category = "Widget")
	int32 GetInlineImageSize() const { return InlineImageSize; }
```

まずは`UCustomRichTextBlock`側にテクスチャのサイズを指定する変数とそのゲッターを作成します。

[CustomRichTextBlockImageDecorator.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/CustomRichTextBlockImageDecorator.cpp)

```CustomRichTextBlock.cpp
class FCustomRichInlineImage : public FRichTextDecorator
{
private:
	UCustomRichTextBlockImageDecorator* Decorator;
	UCustomRichTextBlock* CastedOwner;

public:
	FCustomRichInlineImage(URichTextBlock* InOwner, UCustomRichTextBlockImageDecorator* InDecorator)
		: FRichTextDecorator(InOwner)
		, Decorator(InDecorator)
	{
		CastedOwner = Cast<UCustomRichTextBlock>(InOwner);
	}

	virtual bool Supports(const FTextRunParseResults& RunParseResult, const FString& Text) const override
	{
		// 拡張版RichTextBlockと使うことが前提.
		if (IsValid(CastedOwner) &&
			RunParseResult.Name == CustomRichTextBlockImageDecorator::TriggerWord &&
			RunParseResult.MetaData.Contains(CustomRichTextBlockImageDecorator::ImageMetaWord))
		{
			const FTextRange& IdRange = RunParseResult.MetaData[CustomRichTextBlockImageDecorator::ImageMetaWord];
			const FString TagId = Text.Mid(IdRange.BeginIndex, IdRange.EndIndex - IdRange.BeginIndex);

			const bool bWarnIfMissing = false;
			return Decorator->FindImageBrush(*TagId, bWarnIfMissing) != nullptr;
		}

		return false;
	}

protected:
	virtual TSharedPtr<SWidget> CreateDecoratorWidget(const FTextRunInfo& RunInfo, const FTextBlockStyle& TextStyle) const override
	{
		const bool bWarnIfMissing = true;
		const FSlateBrush* Brush = Decorator->FindImageBrush(*RunInfo.MetaData[CustomRichTextBlockImageDecorator::ImageMetaWord], bWarnIfMissing);

		// 特に横幅の指定がなければ拡張版RichTextBlockのInlineImageSizeを適用.
		TOptional<int32> Width = CastedOwner->GetInlineImageSize();
		if (const FString* WidthString = RunInfo.MetaData.Find(CustomRichTextBlockImageDecorator::WidthMetaWord))
		{
			int32 WidthTemp;
			Width = FDefaultValueHelper::ParseInt(*WidthString, WidthTemp) ? WidthTemp : TOptional<int32>();
		}

		// 特に縦幅の指定がなければ拡張版RichTextBlockのInlineImageSizeを適用.
		TOptional<int32> Height = CastedOwner->GetInlineImageSize();
		if (const FString* HeightString = RunInfo.MetaData.Find(CustomRichTextBlockImageDecorator::HeightMetaWord))
		{
			int32 HeightTemp;
			Height = FDefaultValueHelper::ParseInt(*HeightString, HeightTemp) ? HeightTemp : TOptional<int32>();
		}

		EStretch::Type Stretch = EStretch::ScaleToFit;
		if (const FString* SstretchString = RunInfo.MetaData.Find(CustomRichTextBlockImageDecorator::StretchMetaWord))
		{
			static const UEnum* StretchEnum = StaticEnum<EStretch::Type>();
			int64 StretchValue = StretchEnum->GetValueByNameString(*SstretchString);
			if (StretchValue != INDEX_NONE)
			{
				Stretch = static_cast<EStretch::Type>(StretchValue);
			}
		}

		return SNew(SCustomRichInlineImage, Brush, TextStyle, Width, Height, Stretch);
	}
};
```
`FCustomRichInlineImage`のコンストラクタでOwnerを`UCustomRichTextBlock`にキャストして使用しているクラスが拡張版のRichTextBlockかを判定します。（デコレーターの性質上相互の依存関係があるのは如何なものか...と思いますが、今回は便利さ優先で行きます。インターフェースを用意すればこのあたりも改善できます。）
`FCustomRichInlineImage::CreateDecoratorWidget`で各種タグの反映を行っているのでここに手を入れます。
今回は`width`タグや`height`タグがない場合のみ`UCustomRichTextBlock::InlineImageSize`の値を設定するようにしました。
これで`UCustomRichTextBlock`のDetailsからサイズを変更できるようになりました。

##番外編
ここまでで「拡張して実装する機能」で示した機能の実装方法は紹介しました。
ここで１つおまけで「挿入されるテクスチャの位置を変えたい」場合の対処方法を紹介します。

[CustomRichTextBlockImageDecorator.cpp](https://github.com/Naotsun19B/CustomRichText/blob/master/Plugins/CustomRichText/Source/CustomRichText/Private/CustomRichTextBlockImageDecorator.cpp)

```CustomRichTextBlock.cpp
class SCustomRichInlineImage : public SCompoundWidget
{
public:
	SLATE_BEGIN_ARGS(SCustomRichInlineImage)
	{}
	SLATE_END_ARGS()

public:
	void Construct(const FArguments& InArgs, const FSlateBrush* Brush, const FTextBlockStyle& TextStyle, TOptional<int32> Width, TOptional<int32> Height, EStretch::Type Stretch)
	{
		if (ensure(Brush))
		{
			const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
			float IconHeight = FMath::Min((float)FontMeasure->GetMaxCharacterHeight(TextStyle.Font, 1.0f), Brush->ImageSize.Y);
			float IconWidth = IconHeight;

			if (Width.IsSet())
			{
				IconWidth = Width.GetValue();
			}

			if (Height.IsSet())
			{
				IconHeight = Height.GetValue();
			}

			// ここでテクスチャの部分が作られる.
			ChildSlot
			[
				SNew(SBox)
				.HeightOverride(IconHeight)
				.WidthOverride(IconWidth)
				[
					SNew(SScaleBox)
					.Stretch(Stretch)
					.StretchDirection(EStretchDirection::DownOnly)
					.VAlign(VAlign_Center)
					[
						SNew(SImage)
						.Image(Brush)
					]
				]
			];
		}
	}
};
```
上記コードの`SCustomRichInlineImage::Construct`内の`ChildSlot`のSlateで挿入されるテクスチャの部分が組み立てられています。
そのため、以下のように`SScaleBox`の上に`SCanvas`を挿み、`SCanvas`の`Position`で位置をずらすことができます。

```CustomRichTextBlock.cpp
ChildSlot
[
	SNew(SBox)
	.HeightOverride(IconHeight)
	.WidthOverride(IconWidth)
	[
		SNew(SCanvas)
		+ SCanvas::Slot()
		.Size(FVector2D(IconWidth, IconHeight))
		.Position(FVector2D(/* ここでずらしたい分の座標を入れる */))
		[
			SNew(SScaleBox)
			.Stretch(Stretch)
			.StretchDirection(EStretchDirection::DownOnly)
			.VAlign(VAlign_Center)
			[
				SNew(SImage)
				.Image(Brush)
			]
		]
	]
];
```

#おわりに
少し長くなりましたが以上です。
どうでもいい話ですが、Slateを触っているとインデントが正常に入らないので結構ストレスを感じますね...w
RichTextBlockについてはあまり情報がなかったので、この記事が今後RichTextBlockを使う方の参考になれば幸いです。

この記事で紹介したプロジェクトは以下でダウンロードできます。（プラグインの形になっているので入れるだけでも動きます！）
https://github.com/Naotsun19B/CustomRichText