RichTextBlock で遊んでみよう第二弾!
前回に引き続き RichTextBlock の機能を拡張して作っていきます。
RichTextBlock には Decorator クラスというものがあってぢゃな~!
RichTextBlock はデータ テーブル アセットを使用して追加したスタイルとカスタマイズ機能を管理します。データ テーブル アセットによって、自作のスタイルを作成したり、プロジェクトに必要な動作を定義する Decorator クラスを書くことができます。自作の Decorator クラスを書く際の取り掛かりとして使用していただけるように RichTextBlockImageDecorator というサンプル Decorator クラスを含めました。UMG の RichTextBlock についてもっと学んで自作の Decorator クラスを書けるようになるためには以下の手順に従ってください。
公式ドキュメントでは絵文字の実装が解説されてますが、調べてみるとテキストブロックの中に独自の Slate が実装できるみたいなんですね。なんでもできるっていいですね~。
URichTextBlockDecorator の実装
<ruby label="わがはい">吾輩</>は猫である。
Decorator の基本構文は <ElementName AttributeName="AttributeValue">Content</> となっています。FDefaultRichTextMarkupParser::ParseLineRanges を参照。
今回は タグ名 :ruby、ルビ文字 :label で実装します。
タグ名に一致した部分に Decorator の実装で Slate が生成されて置換されるって感じです。
URichTextBlockDecorator の拡張
URichTextBlockDecorator を継承した URichTextBlockRubyDecorator を作成します。
今回は実装クラスをまるまる載せるしかないので、長いですね。
URichTextBlockRubyDecorator 実装コード
UCLASS( Blueprintable )
class URichTextBlockRubyDecorator : public URichTextBlockDecorator
{
GENERATED_BODY()
friend class FRichTextRuby;
protected:
virtual TSharedPtr<ITextDecorator> CreateDecorator( URichTextBlock* InOwner ) override;
protected:
UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
FString ParseTagName = TEXT( "ruby" );
UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
FString AttributeLabelName = TEXT( "label" );
UPROPERTY( EditAnywhere, BlueprintReadWrite, AdvancedDisplay )
FString AttributeStyleName = TEXT( "style" );
UPROPERTY( EditAnywhere, BlueprintReadWrite )
FName RubyTextStyleNameFormat = TEXT( "{0}.Ruby" );
UPROPERTY( EditAnywhere, BlueprintReadWrite )
FVector2D RubyOffset = FVector2D::ZeroVector;
};
class SRichTextRuby : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS( SRichTextRuby )
: _DisplayText()
, _DisplayTextStyle( &FCoreStyle::Get().GetWidgetStyle<FTextBlockStyle>( "NormalText" ) )
, _RubyText()
, _RubyTextStyle( &FCoreStyle::Get().GetWidgetStyle<FTextBlockStyle>( "NormalText" ) )
{
}
SLATE_ATTRIBUTE( FText, DisplayText )
SLATE_STYLE_ARGUMENT( FTextBlockStyle, DisplayTextStyle )
SLATE_ATTRIBUTE( FText, RubyText )
SLATE_STYLE_ARGUMENT( FTextBlockStyle, RubyTextStyle )
SLATE_END_ARGS()
public:
void Construct( const FArguments& InArgs, const FVector2D& RubyOffset )
{
DisplayText = InArgs._DisplayText;
const FTextBlockStyle* DisplayTextStyle = InArgs._DisplayTextStyle;
if ( !DisplayText.Get().IsEmptyOrWhitespace() )
{
RubyText = InArgs._RubyText;
const FTextBlockStyle* RubyTextStyle = InArgs._RubyTextStyle;
const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
FVector2D DisplayTextSize = FontMeasure->Measure( DisplayText.Get(), DisplayTextStyle->Font, 1.0f );
FVector2D RubyTextSize = FontMeasure->Measure( RubyText.Get(), RubyTextStyle->Font, 1.0f );
TSharedPtr<SOverlay> Overlay = //
SNew( SOverlay ) + //
SOverlay::Slot()[ //
SNew( SConstraintCanvas ) + //
SConstraintCanvas::Slot()
.Offset( FMargin( 0.f, 0.f, //
DisplayTextSize.X, FontMeasure->GetMaxCharacterHeight( DisplayTextStyle->Font, 1.0f ) ) )
.Anchors( FAnchors( 0.5f, 0.5f, 0.5f, 0.5f ) )
.Alignment( FVector2D( 0.5f, 0.5f ) )[ //
SNew( STextBlock ) //
.TextStyle( DisplayTextStyle )
.Text( this, &SRichTextRuby::GetDisplayText )]];
auto IsEmptyOrContainsWhitespace = [&]( const FText& TextData ) {
const FString& DisplayString = TextData.ToString();
return DisplayString.IsEmpty() || DisplayString.Contains( TEXT( "\u0020" ) );
};
if ( !IsEmptyOrContainsWhitespace( DisplayText.Get() ) )
{
Overlay->AddSlot()[ //
SNew( SConstraintCanvas ) + //
SConstraintCanvas::Slot()
.Offset( FMargin( RubyOffset.X, RubyOffset.Y, //
RubyTextSize.X, FontMeasure->GetMaxCharacterHeight( RubyTextStyle->Font, 1.0f ) ) )
.Anchors( FAnchors( 0.5f, 0.f, 0.5f, 0.f ) )
.Alignment( FVector2D( 0.5f, 1.f ) )[ //
SNew( STextBlock ) //
.TextStyle( RubyTextStyle )
.Text( this, &SRichTextRuby::GetRubyText )]];
}
ChildSlot[ //
SNew( SBox )
.WidthOverride( FMath::Max( DisplayTextSize.X, RubyTextSize.X ) )
.HeightOverride( DisplayTextSize.Y - 1.f )[ // FIXME: -1 を入れることでなぜか改行時の行間サイズが合う。なぜ。
Overlay.ToSharedRef()]];
}
}
FText GetDisplayText() const { return DisplayText.Get(); }
FText GetRubyText() const { return RubyText.Get(); }
private:
TAttribute<FText> DisplayText;
TAttribute<FText> RubyText;
};
class FRichTextRuby : public ITextDecorator
{
public:
FRichTextRuby( URichTextBlock* InOwner, URichTextBlockRubyDecorator* InDecorator )
: Owner( InOwner ), Decorator( InDecorator )
{
}
protected:
virtual bool Supports( const FTextRunParseResults& RunParseResult, const FString& Text ) const override
{
if ( RunParseResult.Name == Decorator->ParseTagName && //
RunParseResult.MetaData.Contains( Decorator->AttributeLabelName ) )
{
const FTextRange& WordRange = RunParseResult.MetaData[Decorator->AttributeLabelName];
const FString TagWord = Text.Mid( WordRange.BeginIndex, WordRange.EndIndex - WordRange.BeginIndex );
return !FText::FromString( TagWord ).IsEmptyOrWhitespace();
}
return false;
}
// CreateDecoratorWidgetにISlateStyle*Styleを渡すためにコピペ
virtual TSharedRef<ISlateRun> Create( const TSharedRef<class FTextLayout>& TextLayout,
const FTextRunParseResults& RunParseResult, const FString& DisplayText, const TSharedRef<FString>& InOutModelText,
const ISlateStyle* Style ) override final
{
FTextRange ModelRange;
ModelRange.BeginIndex = InOutModelText->Len();
FTextRunInfo RunInfo(
RunParseResult.Name, FText::FromString( DisplayText.Mid( RunParseResult.ContentRange.BeginIndex,
RunParseResult.ContentRange.EndIndex - RunParseResult.ContentRange.BeginIndex ) ) );
for ( const TPair<FString, FTextRange>& Pair : RunParseResult.MetaData )
{
RunInfo.MetaData.Add( Pair.Key, DisplayText.Mid( Pair.Value.BeginIndex, Pair.Value.EndIndex - Pair.Value.BeginIndex ) );
}
const FTextBlockStyle& TextStyle = Owner->GetCurrentDefaultTextStyle();
TSharedPtr<ISlateRun> SlateRun;
TSharedPtr<SWidget> DecoratorWidget = CreateDecoratorWidget( RunInfo, TextStyle, Style );
if ( DecoratorWidget.IsValid() )
{
*InOutModelText += TEXT( '\u200B' ); // Zero-Width Breaking Space
ModelRange.EndIndex = InOutModelText->Len();
// Calculate the baseline of the text within the owning rich text
const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
int16 WidgetBaseline = FontMeasure->GetBaseline( TextStyle.Font ) - FMath::Min( 0.0f, TextStyle.ShadowOffset.Y );
FSlateWidgetRun::FWidgetRunInfo WidgetRunInfo( DecoratorWidget.ToSharedRef(), WidgetBaseline );
SlateRun = FSlateWidgetRun::Create( TextLayout, RunInfo, InOutModelText, WidgetRunInfo, ModelRange );
}
return SlateRun.ToSharedRef();
}
protected:
TSharedPtr<SWidget> CreateDecoratorWidget(
const FTextRunInfo& RunInfo, const FTextBlockStyle& TextStyle, const ISlateStyle* Style ) const
{
FName TextStyleName = TEXT( "Default" );
FName RubyTextStyleName = *FString::Format( *Decorator->RubyTextStyleNameFormat.ToString(), { TextStyleName.ToString() } );
FTextBlockStyle DisplayTextStyle = TextStyle;
FTextBlockStyle RubyTextStyle = Style->HasWidgetStyle<FTextBlockStyle>( RubyTextStyleName )
? Style->GetWidgetStyle<FTextBlockStyle>( RubyTextStyleName )
: TextStyle;
if ( RunInfo.MetaData.Contains( Decorator->AttributeStyleName ) )
{
TextStyleName = *RunInfo.MetaData[Decorator->AttributeStyleName];
if ( Style->HasWidgetStyle<FTextBlockStyle>( TextStyleName ) )
{
DisplayTextStyle = Style->GetWidgetStyle<FTextBlockStyle>( TextStyleName );
}
RubyTextStyleName = *FString::Format( *Decorator->RubyTextStyleNameFormat.ToString(), { TextStyleName.ToString() } );
if ( Style->HasWidgetStyle<FTextBlockStyle>( RubyTextStyleName ) )
{
RubyTextStyle = Style->GetWidgetStyle<FTextBlockStyle>( RubyTextStyleName );
}
}
return SNew( SRichTextRuby, Decorator->RubyOffset )
.DisplayText( RunInfo.Content )
.DisplayTextStyle( &DisplayTextStyle )
.RubyText( FText::FromString( RunInfo.MetaData[Decorator->AttributeLabelName] ) )
.RubyTextStyle( &RubyTextStyle );
}
private:
TWeakObjectPtr<URichTextBlock> Owner;
TWeakObjectPtr<URichTextBlockRubyDecorator> Decorator;
};
TSharedPtr<ITextDecorator> URichTextBlockRubyDecorator::CreateDecorator( URichTextBlock* InOwner )
{
return MakeShareable( new FRichTextRuby( InOwner, this ) );
}
Slate 黒魔術
SNew( SOverlay ) + // clang-format 使ってると
SOverlay::Slot()[ // 変なとこで改行しちゃうのでコメントで制御
SNew( SConstraintCanvas ) + //
SConstraintCanvas::Slot()
.Offset( FMargin( 0.f, 0.f, //
DisplayTextSize.X, FontMeasure->GetMaxCharacterHeight( DisplayTextStyle->Font, 1.0f ) ) )
.Anchors( FAnchors( 0.5f, 0.5f, 0.5f, 0.5f ) )
.Alignment( FVector2D( 0.5f, 0.5f ) )[ //
SNew( STextBlock ) //
.TextStyle( DisplayTextStyle )
.Text( this, &SRichTextRuby::GetDisplayText )]];
最初見たときは、すごい!なんじゃこら!ってなりましたが、一周回ると逆に分かりやすい、不思議なコードです。
UMG のツリー構造と同じで SNew して Slot()[] を繰り返すだけ・・・!
あとインデントが大事、Python みたいですね。最初は UMG 開きながらコーディングするのをおススメします。
今回実装した Slate はこんな感じ。なんで CanvasPanel 入れ子になっているのかは後述。
タイプライターと組み合わせるとピクピク動く
今回本当に苦労したところはココなんですが、Slate の領域計算で微妙に誤差がある(深くは追えていない)らしく、表示がガタついてしまいます。
自動計算系の処理(STextBlock::Justification や SOverlay::HAlignment などのセンタリング)は使わず、SBox や SConstraintCanvas で値を指定して固めましょう。
TextStyle を指定する
ここには重要な<ruby label="きょうくん" style="Important">教訓</>があります。
色変えと併用したい場合もありますよね。Attribute で指定しましょう。
ここには重要な<Important><ruby label="きょうくん">教訓</></>があります。
もしかすると、本当はこう書きたいかもしれません。
しかし実装(正規表現の構文解析)の都合上、入れ子構造は難しいのです。
TextBlock 縦方向のセンタリング
UTextLayoutWidget::LineHeightPercentage という非常に便利なパラメータがあるのですが、
これの縦方向の Alignment が Top 固定なのです。
本当はこうしたい。
エンジンを改造する以外には方法が思いつかなかったので、修正部分を乗せときます。。誰かタスケテ!
// UE4.26 L409 あたり
float CurrentHorizontalPos = 0.0f;
for (int32 Index = 0; Index < OutSoftLine.Num(); Index++)
{
const TSharedRef< ILayoutBlock > Block = OutSoftLine[ Index ];
const TSharedRef< IRun > Run = Block->GetRun();
const int16 BlockBaseline = Run->GetBaseLine(Scale);
const int16 VerticalOffset = MaxAboveBaseline - Block->GetSize().Y - BlockBaseline;
const int8 BlockKerning = Run->GetKerning(Block->GetTextRange().BeginIndex, Scale, RunTextContext);
Block->SetLocationOffset(FVector2D(CurrentOffset.X + CurrentHorizontalPos + BlockKerning, CurrentOffset.Y + VerticalOffset));
//@third party code BEGIN koorinonaka
// LineHeightPercentageを変更したときのVAlignをTopではなくCenter基準にする
FVector2D BlockLocationOffset = Block->GetLocationOffset();
Block->SetLocationOffset( FVector2D(
BlockLocationOffset.X,
BlockLocationOffset.Y - Block->GetSize().Y * 0.5f * ( 1.f - LineHeightPercentage ) ) );
//@third party code END koorinonaka
CurrentHorizontalPos += Block->GetSize().X;
}
吾輩は猫である。名前はまだ無い。
青空文庫から拝借しました。ルビも一緒にコピペできるなんて便利な世の中だ。