Skip to content

Commit

Permalink
feat: Add onFinished callback to ScrollTextBoxComponent (#3105)
Browse files Browse the repository at this point in the history
Implemented a callback function to notify when all text is displayed.
Removed unnecessary code.
  • Loading branch information
KurtLa committed Mar 28, 2024
1 parent 6c8190b commit 233cc94
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 20 deletions.
9 changes: 4 additions & 5 deletions doc/flame/rendering/text_rendering.md
Expand Up @@ -10,11 +10,10 @@ components:

- `TextComponent` for rendering a single line of text
- `TextBoxComponent` for bounding multi-line text within a sized box, including the possibility of a
typing effect
- `ScrollTextBoxComponent` enhances the functionality of `TextBoxComponent` by adding scrolling
capability when the text exceeds the boundaries of the enclosing box.

Use the `onFinished` callback to get notified when the text is completely printed.
typing effect. You can use the `newLineNotifier` to be notified when a new line is added. Use the
`onComplete` callback to execute a function when the text is completely printed.
- `ScrollTextBoxComponent` enhances the functionality of `TextBoxComponent` by adding vertical
scrolling capability when the text exceeds the boundaries of the enclosing box.


All components are showcased in
Expand Down
48 changes: 36 additions & 12 deletions packages/flame/lib/src/components/scroll_text_box_component.dart
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/text.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';

/// [ScrollTextBoxComponent] configures the layout and interactivity of a
Expand All @@ -15,13 +16,15 @@ import 'package:flutter/painting.dart';
/// capabilities.
class ScrollTextBoxComponent<T extends TextRenderer> extends PositionComponent {
late final _ScrollTextBoxComponent<T> _scrollTextBoxComponent;
late final ValueNotifier<int> newLineNotifier;

/// Constructor for [ScrollTextBoxComponent].
/// - [size]: Specifies the size of the text box.
/// Must have positive dimensions.
/// - [text]: The text content to be displayed.
/// - [textRenderer]: Handles the rendering of the text.
/// - [boxConfig]: Configuration for the text box appearance.
/// - [onComplete]: Callback will be executed after all text is displayed.
/// - Other parameters include alignment, pixel ratio, and positioning
/// settings.
/// An assertion ensures that the [size] has positive dimensions.
Expand All @@ -39,6 +42,7 @@ class ScrollTextBoxComponent<T extends TextRenderer> extends PositionComponent {
super.priority,
super.key,
List<Component>? children,
void Function()? onComplete,
}) : assert(
size.x > 0 && size.y > 0,
'size must have positive dimensions: $size',
Expand All @@ -48,15 +52,29 @@ class ScrollTextBoxComponent<T extends TextRenderer> extends PositionComponent {
final marginBottom = boxConfig?.margins.bottom ?? 0;
final innerMargins = EdgeInsets.fromLTRB(0, marginTop, 0, marginBottom);

boxConfig = (boxConfig ?? const TextBoxConfig()).copyWith(maxWidth: size.x);

boxConfig ??= const TextBoxConfig();
boxConfig = TextBoxConfig(
timePerChar: boxConfig.timePerChar,
dismissDelay: boxConfig.dismissDelay,
growingBox: boxConfig.growingBox,
maxWidth: size.x,
margins: EdgeInsets.fromLTRB(
boxConfig.margins.left,
0,
boxConfig.margins.right,
0,
),
);
_scrollTextBoxComponent = _ScrollTextBoxComponent<T>(
text: text,
textRenderer: textRenderer,
boxConfig: boxConfig,
align: align,
pixelRatio: pixelRatio,
onComplete: onComplete,
);
newLineNotifier = _scrollTextBoxComponent.newLineNotifier;

_scrollTextBoxComponent.setOwnerComponent = this;
// Integrates the [ClipComponent] for managing
// the text box's scrollable area.
Expand Down Expand Up @@ -89,12 +107,13 @@ class ScrollTextBoxComponent<T extends TextRenderer> extends PositionComponent {
class _ScrollTextBoxComponent<T extends TextRenderer> extends TextBoxComponent
with DragCallbacks {
double scrollBoundsY = 0.0;
int _linesScrolled = 0;

late final ClipComponent clipComponent;

late ScrollTextBoxComponent<TextRenderer> _owner;

bool _isOnCompleteExecuted = false;

_ScrollTextBoxComponent({
String? text,
T? textRenderer,
Expand All @@ -104,6 +123,7 @@ class _ScrollTextBoxComponent<T extends TextRenderer> extends TextBoxComponent
super.position,
super.scale,
double super.angle = 0.0,
super.onComplete,
}) : super(
text: text ?? '',
textRenderer: textRenderer ?? TextPaint(),
Expand All @@ -113,25 +133,29 @@ class _ScrollTextBoxComponent<T extends TextRenderer> extends TextBoxComponent
@override
Future<void> onLoad() {
clipComponent = parent! as ClipComponent;
newLinePositionNotifier.addListener(() {
if (newLinePositionNotifier.value > clipComponent.size.y) {
position.y = -newLinePositionNotifier.value + clipComponent.size.y;
}
});
return super.onLoad();
}

@override
Future<void> redraw() async {
if ((currentLine + 1 - _linesScrolled) * lineHeight >
clipComponent.size.y) {
_linesScrolled++;
position.y -= lineHeight;
scrollBoundsY = -position.y;
void update(double dt) {
if (!_isOnCompleteExecuted && finished) {
_isOnCompleteExecuted = true;
scrollBoundsY = clipComponent.size.y - size.y;
}
await super.redraw();

super.update(dt);
}

@override
void onDragUpdate(DragUpdateEvent event) {
if (finished && _linesScrolled > 0) {
if (finished && scrollBoundsY < 0) {
position.y += event.localDelta.y;
position.y = position.y.clamp(-scrollBoundsY, 0);
position.y = position.y.clamp(scrollBoundsY, 0);
}
}

Expand Down
31 changes: 28 additions & 3 deletions packages/flame/lib/src/components/text_box_component.dart
Expand Up @@ -80,6 +80,20 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
@visibleForTesting
Image? cache;

/// Notifies when a new line is rendered.
final ValueNotifier<int> newLineNotifier = ValueNotifier<int>(0);

// Notifies when a new line is rendered with the position of the new line.
@internal
final ValueNotifier<double> newLinePositionNotifier =
ValueNotifier<double>(0);

double _currentLinePosition = 0.0;
bool _isOnCompleteExecuted = false;

/// Callback function to be executed after all text is displayed.
void Function()? onComplete;

TextBoxConfig get boxConfig => _boxConfig;
double get lineHeight => _lineHeight;

Expand All @@ -96,6 +110,7 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
super.anchor,
super.children,
super.priority,
this.onComplete,
super.key,
}) : _boxConfig = boxConfig ?? const TextBoxConfig(),
_fixedSize = size != null,
Expand Down Expand Up @@ -300,7 +315,11 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
i * _lineHeight,
);
textElement.render(canvas, position);

if (position.y > _currentLinePosition) {
_currentLinePosition = position.y;
newLineNotifier.value = newLineNotifier.value + 1;
newLinePositionNotifier.value = _currentLinePosition + _lineHeight;
}
charCount += lines[i].length;
}
}
Expand Down Expand Up @@ -334,8 +353,14 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
}
_previousChar = currentChar;

if (_boxConfig.dismissDelay != null && finished) {
removeFromParent();
if (finished) {
if (!_isOnCompleteExecuted) {
_isOnCompleteExecuted = true;
onComplete?.call();
}
if (_boxConfig.dismissDelay != null) {
removeFromParent();
}
}
}

Expand Down
99 changes: 99 additions & 0 deletions packages/flame/test/components/scroll_text_box_component_test.dart
@@ -0,0 +1,99 @@
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

void main() {
group('ScrollTextBoxComponent', () {
testWithFlameGame(
'onComplete is called when no scrolling is required',
(game) async {
final onComplete = MockOnCompleteCallback();

when(onComplete).thenReturn(null);

final component = ScrollTextBoxComponent(
size: Vector2(200, 100),
text: 'Short text',
onComplete: onComplete,
);
await game.ensureAdd(component);

game.update(0.1);

verify(onComplete).called(1);
},
);

testWithFlameGame(
'onComplete is called when scrolling is required',
(game) async {
final onComplete = MockOnCompleteCallback();

when(onComplete).thenReturn(null);

final component = ScrollTextBoxComponent(
size: Vector2(200, 100),
text: '''Long text that will definitely require scrolling to be
fully visible in the given size of the ScrollTextBoxComponent.''',
onComplete: onComplete,
);
await game.ensureAdd(component);

game.update(0.1);

verify(onComplete).called(1);
},
);

testWithFlameGame(
'Text position moves to <0 when scrolled',
(game) async {
final scrollComponent = ScrollTextBoxComponent(
size: Vector2(50, 50),
text: '''This is a test text that is long enough to require scrolling
to see the entire content. It should test whether the scrolling
functionality properly adjusts the text position.''',
onComplete: () {},
);

expect(scrollComponent.children.length, greaterThan(0));
expect(scrollComponent.children.first, isA<ClipComponent>());
final clipCmp = scrollComponent.children.first as ClipComponent;

expect(clipCmp.children.length, greaterThan(0));
expect(clipCmp.children.first, isA<PositionComponent>());
final innerScrollComponent =
clipCmp.children.first as PositionComponent;

expect(innerScrollComponent.position.y, equals(0));
await game.ensureAdd(scrollComponent);

expect(innerScrollComponent.position.y, lessThan(0));
},
);

testWithFlameGame('Text notifies if a new line is added', (game) async {
var newLineCount = 0;
final scrollComponent = ScrollTextBoxComponent(
size: Vector2(50, 50),
text: '''This
test
has
five
lines.''',
);
expect(scrollComponent.newLineNotifier.value, equals(0));

scrollComponent.newLineNotifier.addListener(() {
newLineCount++;
});
await game.ensureAdd(scrollComponent);
expect(newLineCount, equals(5));
});
});
}

class MockOnCompleteCallback extends Mock {
void call();
}
44 changes: 44 additions & 0 deletions packages/flame/test/components/text_box_component_test.dart
Expand Up @@ -5,6 +5,9 @@ import 'package:flame/components.dart';
import 'package:flame/palette.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

import 'scroll_text_box_component_test.dart';

void main() {
group('TextBoxComponent', () {
Expand Down Expand Up @@ -121,6 +124,47 @@ void main() {
},
);

testWithFlameGame(
'onComplete is called when no scrolling is required',
(game) async {
final onComplete = MockOnCompleteCallback();

when(onComplete).thenReturn(null);

final component = ScrollTextBoxComponent(
size: Vector2(200, 100),
text: 'Short text',
onComplete: onComplete,
);
await game.ensureAdd(component);

game.update(0.1);

verify(onComplete).called(1);
},
);

testWithFlameGame(
'TextBoxComponent notifies if a new line is added and requires space',
(game) async {
var lineSize = 0.0;
final textBoxComponent = TextBoxComponent(
size: Vector2(50, 50),
text: '''This
test
has
five
lines.''',
);
expect(textBoxComponent.newLinePositionNotifier.value, equals(0));

textBoxComponent.newLinePositionNotifier.addListener(() {
lineSize += textBoxComponent.newLinePositionNotifier.value;
});
await game.ensureAdd(textBoxComponent);
expect(lineSize, greaterThan(0));
});

testGolden(
'Alignment options',
(game) async {
Expand Down

0 comments on commit 233cc94

Please sign in to comment.