From 233cc94c557e0af2fcf7599943ddf75180abf801 Mon Sep 17 00:00:00 2001 From: KurtLa Date: Thu, 28 Mar 2024 17:19:10 +0100 Subject: [PATCH] feat: Add onFinished callback to ScrollTextBoxComponent (#3105) Implemented a callback function to notify when all text is displayed. Removed unnecessary code. --- doc/flame/rendering/text_rendering.md | 9 +- .../components/scroll_text_box_component.dart | 48 ++++++--- .../src/components/text_box_component.dart | 31 +++++- .../scroll_text_box_component_test.dart | 99 +++++++++++++++++++ .../components/text_box_component_test.dart | 44 +++++++++ 5 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 packages/flame/test/components/scroll_text_box_component_test.dart diff --git a/doc/flame/rendering/text_rendering.md b/doc/flame/rendering/text_rendering.md index 86d544793fa..a42b52ba0c1 100644 --- a/doc/flame/rendering/text_rendering.md +++ b/doc/flame/rendering/text_rendering.md @@ -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 diff --git a/packages/flame/lib/src/components/scroll_text_box_component.dart b/packages/flame/lib/src/components/scroll_text_box_component.dart index 45e74777b54..c8e26efdaee 100644 --- a/packages/flame/lib/src/components/scroll_text_box_component.dart +++ b/packages/flame/lib/src/components/scroll_text_box_component.dart @@ -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 @@ -15,6 +16,7 @@ import 'package:flutter/painting.dart'; /// capabilities. class ScrollTextBoxComponent extends PositionComponent { late final _ScrollTextBoxComponent _scrollTextBoxComponent; + late final ValueNotifier newLineNotifier; /// Constructor for [ScrollTextBoxComponent]. /// - [size]: Specifies the size of the text box. @@ -22,6 +24,7 @@ class ScrollTextBoxComponent extends PositionComponent { /// - [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. @@ -39,6 +42,7 @@ class ScrollTextBoxComponent extends PositionComponent { super.priority, super.key, List? children, + void Function()? onComplete, }) : assert( size.x > 0 && size.y > 0, 'size must have positive dimensions: $size', @@ -48,15 +52,29 @@ class ScrollTextBoxComponent 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( 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. @@ -89,12 +107,13 @@ class ScrollTextBoxComponent extends PositionComponent { class _ScrollTextBoxComponent extends TextBoxComponent with DragCallbacks { double scrollBoundsY = 0.0; - int _linesScrolled = 0; late final ClipComponent clipComponent; late ScrollTextBoxComponent _owner; + bool _isOnCompleteExecuted = false; + _ScrollTextBoxComponent({ String? text, T? textRenderer, @@ -104,6 +123,7 @@ class _ScrollTextBoxComponent extends TextBoxComponent super.position, super.scale, double super.angle = 0.0, + super.onComplete, }) : super( text: text ?? '', textRenderer: textRenderer ?? TextPaint(), @@ -113,25 +133,29 @@ class _ScrollTextBoxComponent extends TextBoxComponent @override Future 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 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); } } diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index d753b9c0266..7aece0d0e4d 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -80,6 +80,20 @@ class TextBoxComponent extends TextComponent { @visibleForTesting Image? cache; + /// Notifies when a new line is rendered. + final ValueNotifier newLineNotifier = ValueNotifier(0); + + // Notifies when a new line is rendered with the position of the new line. + @internal + final ValueNotifier newLinePositionNotifier = + ValueNotifier(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; @@ -96,6 +110,7 @@ class TextBoxComponent extends TextComponent { super.anchor, super.children, super.priority, + this.onComplete, super.key, }) : _boxConfig = boxConfig ?? const TextBoxConfig(), _fixedSize = size != null, @@ -300,7 +315,11 @@ class TextBoxComponent 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; } } @@ -334,8 +353,14 @@ class TextBoxComponent extends TextComponent { } _previousChar = currentChar; - if (_boxConfig.dismissDelay != null && finished) { - removeFromParent(); + if (finished) { + if (!_isOnCompleteExecuted) { + _isOnCompleteExecuted = true; + onComplete?.call(); + } + if (_boxConfig.dismissDelay != null) { + removeFromParent(); + } } } diff --git a/packages/flame/test/components/scroll_text_box_component_test.dart b/packages/flame/test/components/scroll_text_box_component_test.dart new file mode 100644 index 00000000000..ef83aec181a --- /dev/null +++ b/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()); + final clipCmp = scrollComponent.children.first as ClipComponent; + + expect(clipCmp.children.length, greaterThan(0)); + expect(clipCmp.children.first, isA()); + 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(); +} diff --git a/packages/flame/test/components/text_box_component_test.dart b/packages/flame/test/components/text_box_component_test.dart index 518f560bb52..db323d953fe 100644 --- a/packages/flame/test/components/text_box_component_test.dart +++ b/packages/flame/test/components/text_box_component_test.dart @@ -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', () { @@ -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 {