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. the input control which is bound to that input action right now.
Again this dynamically switches as input method changes. 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) * [Menu System](Menus.md)
A couple of classes to make it easy to create multi-level on-screen menus A couple of classes to make it easy to create multi-level on-screen menus