Add typewriter text widget

This commit is contained in:
Steve Streeting 2022-08-17 16:32:49 +01:00
parent 047d90d66b
commit e810f2509e
5 changed files with 490 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@ -0,0 +1,314 @@
// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License)
#include "StevesUI/TypewriterTextWidget.h"
#include "Engine/Font.h"
#include "Styling/SlateStyle.h"
#include "Widgets/Text/SRichTextBlock.h"
//PRAGMA_DISABLE_OPTIMIZATION
TSharedRef<SWidget> URichTextBlockForTypewriter::RebuildWidget()
{
// Copied from URichTextBlock::RebuildWidget
UpdateStyleData();
TArray< TSharedRef< class ITextDecorator > > CreatedDecorators;
CreateDecorators(CreatedDecorators);
TextMarshaller = FRichTextLayoutMarshaller::Create(CreateMarkupParser(), CreateMarkupWriter(), CreatedDecorators, StyleInstance.Get());
MyRichTextBlock =
SNew(SRichTextBlock)
.TextStyle(bOverrideDefaultStyle ? &DefaultTextStyleOverride : &DefaultTextStyle)
.Marshaller(TextMarshaller)
.CreateSlateTextLayout(
FCreateSlateTextLayout::CreateWeakLambda(this, [this] (SWidget* InOwner, const FTextBlockStyle& InDefaultTextStyle) mutable
{
TextLayout = FSlateTextLayout::Create(InOwner, InDefaultTextStyle);
return StaticCastSharedPtr<FSlateTextLayout>(TextLayout).ToSharedRef();
}));
return MyRichTextBlock.ToSharedRef();
}
UTypewriterTextWidget::UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
bHasFinishedPlaying = true;
}
void UTypewriterTextWidget::SetText(const FText& InText)
{
if (IsValid(LineText))
{
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
LineText->SetText(InText);
}
}
FText UTypewriterTextWidget::GetText() const
{
if (IsValid(LineText))
{
return LineText->GetText();
}
return FText();
}
void UTypewriterTextWidget::PlayLine(const FText& InLine)
{
check(GetWorld());
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
CurrentLine = InLine;
CurrentLetterIndex = 0;
CachedLetterIndex = 0;
CurrentSegmentIndex = 0;
MaxLetterIndex = 0;
NumberOfLines = 0;
CombinedTextHeight = 0;
Segments.Empty();
CachedSegmentText.Empty();
if (CurrentLine.IsEmpty())
{
if (IsValid(LineText))
{
LineText->SetText(FText::GetEmpty());
}
bHasFinishedPlaying = true;
OnTypewriterLineFinished.Broadcast(this);
OnLineFinishedPlaying();
SetVisibility(ESlateVisibility::Hidden);
}
else
{
if (IsValid(LineText))
{
LineText->SetText(FText::GetEmpty());
}
bHasFinishedPlaying = false;
FTimerDelegate Delegate;
Delegate.BindUObject(this, &ThisClass::PlayNextLetter);
TimerManager.SetTimer(LetterTimer, Delegate, LetterPlayTime, true);
SetVisibility(ESlateVisibility::SelfHitTestInvisible);
}
}
void UTypewriterTextWidget::SkipToLineEnd()
{
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
CurrentLetterIndex = MaxLetterIndex - 1;
if (IsValid(LineText))
{
LineText->SetText(FText::FromString(CalculateSegments()));
}
bHasFinishedPlaying = true;
OnTypewriterLineFinished.Broadcast(this);
OnLineFinishedPlaying();
}
void UTypewriterTextWidget::PlayNextLetter()
{
if (Segments.Num() == 0)
{
CalculateWrappedString();
}
FString WrappedString = CalculateSegments();
// TODO: How do we keep indexing of text i18n-friendly?
if (CurrentLetterIndex < MaxLetterIndex)
{
if (IsValid(LineText))
{
LineText->SetText(FText::FromString(WrappedString));
}
OnPlayLetter();
++CurrentLetterIndex;
}
else
{
if (IsValid(LineText))
{
LineText->SetText(FText::FromString(CalculateSegments()));
}
FTimerManager& TimerManager = GetWorld()->GetTimerManager();
TimerManager.ClearTimer(LetterTimer);
FTimerDelegate Delegate;
Delegate.BindUObject(this, &ThisClass::SkipToLineEnd);
TimerManager.SetTimer(LetterTimer, Delegate, EndHoldTime, false);
}
}
void UTypewriterTextWidget::CalculateWrappedString()
{
// Rich Text views give you:
// - A blank block at the start for some reason
// - One block per line (newline characters stripped)
// - Split for different runs (decorators)
// - The newlines we add are the only newlines in the output so that's the number of lines
// If we've got here, that means the text isn't empty so 1 line at least
NumberOfLines = 1;
if (IsValid(LineText) && LineText->GetTextLayout().IsValid())
{
TSharedPtr<FSlateTextLayout> Layout = LineText->GetTextLayout();
TSharedPtr<FRichTextLayoutMarshaller> Marshaller = LineText->GetTextMarshaller();
const FGeometry& TextBoxGeometry = LineText->GetCachedGeometry();
const FVector2D TextBoxSize = TextBoxGeometry.GetLocalSize();
Layout->SetWrappingWidth(TextBoxSize.X);
Marshaller->SetText(CurrentLine.ToString(), *Layout.Get());
Layout->UpdateIfNeeded();
bool bHasWrittenText = false;
auto Views = Layout->GetLineViews();
for (int v = 0; v < Views.Num(); ++v)
{
const FTextLayout::FLineView& View = Views[v];
for (int b = 0; b < View.Blocks.Num(); ++b)
{
TSharedRef<ILayoutBlock> Block = View.Blocks[b];
TSharedRef<IRun> Run = Block->GetRun();
FTypewriterTextSegment Segment;
Run->AppendTextTo(Segment.Text, Block->GetTextRange());
// HACK: For some reason image decorators (and possibly other decorators that don't
// have actual text inside them) result in the run containing a zero width space instead of
// nothing. This messes up our checks for whether the text is empty or not, which doesn't
// have an effect on image decorators but might cause issues for other custom ones.
// Instead of emptying text, which might have some unknown effect, just mark it as empty
const bool bTextIsEmpty = Segment.Text.IsEmpty() ||
(Segment.Text.Len() == 1 && Segment.Text[0] == 0x200B);
const int TextLen = bTextIsEmpty ? 0 : Segment.Text.Len();
const bool bRunIsEmpty = Segment.RunInfo.Name.IsEmpty();
Segment.RunInfo = Run->GetRunInfo();
Segments.Add(Segment);
// A segment with a named run should still take up time for the typewriter effect.
MaxLetterIndex += FMath::Max(TextLen, Segment.RunInfo.Name.IsEmpty() ? 0 : 1);
if (!bTextIsEmpty || !bRunIsEmpty)
{
bHasWrittenText = true;
}
}
if (bHasWrittenText)
{
CombinedTextHeight += View.TextHeight;
}
// Add check for an unnecessary newline after ever line even if there's nothing else to do, otherwise
// we end up inserting a newline after a simple single line of text
const bool bHasMoreText = v < (Views.Num() - 1);
if (bHasWrittenText && bHasMoreText)
{
Segments.Add(FTypewriterTextSegment{TEXT("\n")});
++NumberOfLines;
++MaxLetterIndex;
}
}
Layout->SetWrappingWidth(0);
// Set the desired vertical size so that we're already the size we need to accommodate all lines
// Without this, a flexible box size will grow as lines are added
FVector2D Sz = GetMinimumDesiredSize();
Sz.Y = CombinedTextHeight;
SetMinimumDesiredSize(Sz);
LineText->SetText(LineText->GetText());
}
else
{
Segments.Add(FTypewriterTextSegment{CurrentLine.ToString()});
MaxLetterIndex = Segments[0].Text.Len();
}
}
FString UTypewriterTextWidget::CalculateSegments()
{
FString Result = CachedSegmentText;
int32 Idx = CachedLetterIndex;
while (Idx <= CurrentLetterIndex && CurrentSegmentIndex < Segments.Num())
{
const FTypewriterTextSegment& Segment = Segments[CurrentSegmentIndex];
if (!Segment.RunInfo.Name.IsEmpty())
{
Result += FString::Printf(TEXT("<%s"), *Segment.RunInfo.Name);
if (Segment.RunInfo.MetaData.Num() > 0)
{
for (const TTuple<FString, FString>& MetaData : Segment.RunInfo.MetaData)
{
Result += FString::Printf(TEXT(" %s=\"%s\""), *MetaData.Key, *MetaData.Value);
}
}
if (Segment.Text.IsEmpty())
{
Result += TEXT("/>");
++Idx; // This still takes up an index for the typewriter effect.
}
else
{
Result += TEXT(">");
}
}
bool bIsSegmentComplete = true;
if (!Segment.Text.IsEmpty())
{
int32 LettersLeft = CurrentLetterIndex - Idx + 1;
bIsSegmentComplete = LettersLeft >= Segment.Text.Len();
LettersLeft = FMath::Min(LettersLeft, Segment.Text.Len());
Idx += LettersLeft;
Result += Segment.Text.Mid(0, LettersLeft);
if (!Segment.RunInfo.Name.IsEmpty())
{
Result += TEXT("</>");
}
}
if (bIsSegmentComplete)
{
CachedLetterIndex = Idx;
CachedSegmentText = Result;
++CurrentSegmentIndex;
}
else
{
break;
}
}
return Result;
}
//PRAGMA_ENABLE_OPTIMIZATION

View File

@ -0,0 +1,131 @@
// Original Copyright (c) Sam Bloomberg https://github.com/redxdev/UnrealRichTextDialogueBox (MIT License)
// Updates:
// 1. Fixed adding a spurious newline to single-line text
// 2. Expose line finished as event
// 3. Changed names of classes to indicate functionality better (typewriter not dialogue)
// 4. Update minimum desired size on calculate so that flexi boxes can start at the correct size
// instead of growing when the newline is added
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/RichTextBlock.h"
#include "Framework/Text/RichTextLayoutMarshaller.h"
#include "Framework/Text/SlateTextLayout.h"
#include "TypewriterTextWidget.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnTypewriterLineFinished, class UTypewriterTextWidget*, Widget);
/**
* A text block that exposes more information about text layout for typewriter widget.
*/
UCLASS()
class URichTextBlockForTypewriter : public URichTextBlock
{
GENERATED_BODY()
public:
FORCEINLINE TSharedPtr<FSlateTextLayout> GetTextLayout() const
{
return TextLayout;
}
FORCEINLINE TSharedPtr<FRichTextLayoutMarshaller> GetTextMarshaller() const
{
return TextMarshaller;
}
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
private:
TSharedPtr<FSlateTextLayout> TextLayout;
TSharedPtr<FRichTextLayoutMarshaller> TextMarshaller;
};
UCLASS(Blueprintable)
class STEVESUEHELPERS_API UTypewriterTextWidget : public UUserWidget
{
GENERATED_BODY()
public:
UTypewriterTextWidget(const FObjectInitializer& ObjectInitializer);
/// Event called when a line has finished playing, whether on its own or when skipped to end
UPROPERTY(BlueprintAssignable)
FOnTypewriterLineFinished OnTypewriterLineFinished;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
URichTextBlockForTypewriter* LineText;
// The amount of time between printing individual letters (for the "typewriter" effect).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter")
float LetterPlayTime = 0.025f;
// The amount of time to wait after finishing the line before actually marking it completed.
// This helps prevent accidentally progressing dialogue on short lines.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Typewriter")
float EndHoldTime = 0.15f;
/// Set Text immediately
UFUNCTION(BlueprintCallable)
void SetText(const FText& InText);
/// Set Text immediately
UFUNCTION(BlueprintCallable)
FText GetText() const;
UFUNCTION(BlueprintCallable, Category = "Typewriter")
void PlayLine(const FText& InLine);
UFUNCTION(BlueprintCallable, Category = "Typewriter")
void GetCurrentLine(FText& OutLine) const { OutLine = CurrentLine; }
UFUNCTION(BlueprintCallable, Category = "Typewriter")
bool HasFinishedPlayingLine() const { return bHasFinishedPlaying; }
UFUNCTION(BlueprintCallable, Category = "Typewriter")
void SkipToLineEnd();
protected:
UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter")
void OnPlayLetter();
UFUNCTION(BlueprintImplementableEvent, Category = "Typewriter")
void OnLineFinishedPlaying();
private:
void PlayNextLetter();
void CalculateWrappedString();
FString CalculateSegments();
UPROPERTY()
FText CurrentLine;
struct FTypewriterTextSegment
{
FString Text;
FRunInfo RunInfo;
};
TArray<FTypewriterTextSegment> Segments;
// The section of the text that's already been printed out and won't ever change.
// This lets us cache some of the work we've already done. We can't cache absolutely
// everything as the last few characters of a string may change if they're related to
// a named run that hasn't been completed yet.
FString CachedSegmentText;
int32 CachedLetterIndex = 0;
int32 CurrentSegmentIndex = 0;
int32 CurrentLetterIndex = 0;
int32 MaxLetterIndex = 0;
int32 NumberOfLines = 0;
float CombinedTextHeight = 0;
uint32 bHasFinishedPlaying : 1;
FTimerHandle LetterTimer;
};

38
doc/TypewriterText.md Normal file
View File

@ -0,0 +1,38 @@
# Typewriter Rich Text Widget
This base User Widget provides you with a rich text box that plays text like
a typewriter, one character at a time, with a configurable speed. You can
skip to the end of the text if needed.
![Typewriter Text](../Resources/typewriterexample.gif)
## Notable features
* Pre-calculates line breaks so that the text doesn't start to play a word near
the end of the line then "jump down", it always knows where to start
* Pre-calculates the desired height of the text so that if embedded in a flexible
height container, the height is correct before anything is played
* Supports decorators like inline images
* Respects explicit line breaks in your text
## Usage
Due to a quirk of the API used for sizing, this isn't a straight `URichTextBlock`
subclass you can drop straight in. Instead, you need to:
1. Create a Widget Blueprint, subclassed from `UTypewriterTextWidget`
2. Place within it a `URichTextBlockForTypewriter`, named `LineText` (for binding)
3. Style the nested rich text box the way you want
4. Then, in your other Widget Blueprints, make use of this `UTypewriterTextWidget`
subclass in place of a rich text box
5. Call the `Play Line` and `Skip To Line End` functions instead of `Set Text`
to make use of the typewriter effect.
6. Listen to the `On Typewriter Line Finished` event to know when the typewriter
effect has finished, or the line has been skipped to the end.
## Credits
This was derived from [Sam Bloomberg's
work](https://github.com/redxdev/UnrealRichTextDialogueBox) with some
enhancements / adjustments. Both works are released under the MIT license.

View File

@ -24,6 +24,13 @@ Several custom widgets are supplied to assist with some common challenges:
the input control which is bound to that input action right now.
Again this dynamically switches as input method changes.
* [Typewriter Rich Text Widget](TypewriterText.md)
A widget that allows you to use the "typewriter effect" to display text
(displaying one character at a time). Pre-calculates line breaks so text
doesn't try to fit in then jump down, and pre-calculates desired height so
your flexible boxes are always the right size *before* the text is played.
* [Menu System](Menus.md)
A couple of classes to make it easy to create multi-level on-screen menus