diff --git a/doc/flame/rendering/text.md b/doc/flame/rendering/text.md index bb67acacc..596d13e7f 100644 --- a/doc/flame/rendering/text.md +++ b/doc/flame/rendering/text.md @@ -98,7 +98,12 @@ class MyGame extends FlameGame { text inside a bounding box, creating line breaks according to the provided box size. You can decide if the box should grow as the text is written or if it should be static by the -`growingBox` variable in the `TextBoxConfig`. +`growingBox` variable in the `TextBoxConfig`. A static box could either have a fixed size (setting +the `size` property of the `TextBoxComponent`), or to automatically shrink to fit the text content. + +In addition, the `align` property allows you to control the the horizontal and vertical alignment +of the text content. For example, setting `align` to `Anchor.center` will center the text within +its bounding box both vertically and horizontally. If you want to change the margins of the box use the `margins` variable in the `TextBoxConfig`. @@ -106,17 +111,18 @@ Example usage: ```dart class MyTextBox extends TextBoxComponent { - MyTextBox(String text) : super(text: text, textRenderer: tiny, boxConfig: TextBoxConfig(timePerChar: 0.05)); + MyTextBox(String text) + : super(text: text, textRenderer: tiny, boxConfig: TextBoxConfig(timePerChar: 0.05)); + + final bgPaint = Paint()..color = Color(0xFFFF00FF); + final borderPaint = Paint()..color = Color(0xFF000000)..style = PaintingStyle.stroke; @override - void drawBackground(Canvas c) { + void render(Canvas canvas) { Rect rect = Rect.fromLTWH(0, 0, width, height); - c.drawRect(rect, Paint()..color = Color(0xFFFF00FF)); - c.drawRect( - rect.deflate(boxConfig.margin), - BasicPalette.black.Paint() - ..style = PaintingStyle.stroke, - ); + canvas.drawRect(rect, bgPaint); + canvas.drawRect(rect.deflate(boxConfig.margin), borderPaint); + super.render(canvas); } } ``` diff --git a/examples/lib/stories/rendering/text_example.dart b/examples/lib/stories/rendering/text_example.dart index 030170580..39ac706d5 100644 --- a/examples/lib/stories/rendering/text_example.dart +++ b/examples/lib/stories/rendering/text_example.dart @@ -10,32 +10,20 @@ class TextExample extends FlameGame { @override Future onLoad() async { - add( + addAll([ TextComponent(text: 'Hello, Flame', textRenderer: _regular) ..anchor = Anchor.topCenter ..x = size.x / 2 ..y = 32.0, - ); - - add( TextComponent(text: 'Text with shade', textRenderer: _shaded) ..anchor = Anchor.topRight ..position = size - Vector2.all(100), - ); - - add( TextComponent(text: 'center', textRenderer: _tiny) ..anchor = Anchor.center ..position.setFrom(size / 2), - ); - - add( TextComponent(text: 'bottomRight', textRenderer: _tiny) ..anchor = Anchor.bottomRight ..position.setFrom(size), - ); - - add( MyTextBox( '"This is our world now. The world of the electron and the switch; ' 'the beauty of the baud. We exist without nationality, skin color, ' @@ -45,7 +33,23 @@ class TextExample extends FlameGame { ) ..anchor = Anchor.bottomLeft ..y = size.y, - ); + MyTextBox( + 'Let A be a finitely generated torsion-free abelian group. Then ' + 'A is free.', + align: Anchor.center, + size: Vector2(300, 200), + timePerChar: 0, + margins: 10, + )..position = Vector2(10, 50), + MyTextBox( + 'Let A be a torsion abelian group. Then A is the direct sum of its ' + 'subgroups A(p) for all primes p such that A(p) ≠ 0.', + align: Anchor.bottomRight, + size: Vector2(300, 200), + timePerChar: 0, + margins: 10, + )..position = Vector2(10, 260), + ]); } } @@ -72,21 +76,29 @@ final _shaded = TextPaint( ); class MyTextBox extends TextBoxComponent { - MyTextBox(String text) - : super( + MyTextBox( + String text, { + Anchor? align, + Vector2? size, + double? timePerChar, + double? margins, + }) : super( text: text, textRenderer: _box, + align: align, + size: size, boxConfig: TextBoxConfig( maxWidth: 400, - timePerChar: 0.05, + timePerChar: timePerChar ?? 0.05, growingBox: true, - margins: const EdgeInsets.all(25), + margins: EdgeInsets.all(margins ?? 25), ), ); @override - void drawBackground(Canvas c) { + void render(Canvas canvas) { final rect = Rect.fromLTWH(0, 0, width, height); - c.drawRect(rect, Paint()..color = Colors.white10); + canvas.drawRect(rect, Paint()..color = Colors.white10); + super.render(canvas); } } diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index 0d079ab72..5bc44c931 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -68,26 +68,52 @@ class TextBoxComponent extends TextComponent { String? text, T? textRenderer, TextBoxConfig? boxConfig, + Anchor? align, double? pixelRatio, Vector2? position, + Vector2? size, Vector2? scale, double? angle, Anchor? anchor, Iterable? children, int? priority, }) : _boxConfig = boxConfig ?? TextBoxConfig(), + _fixedSize = size != null, + align = align ?? Anchor.topLeft, pixelRatio = pixelRatio ?? window.devicePixelRatio, super( text: text, textRenderer: textRenderer, position: position, scale: scale, + size: size, angle: angle, anchor: anchor, children: children, priority: priority, ); + /// Alignment of the text within its bounding box. + /// + /// This property combines both the horizontal and vertical alignment. For + /// example, setting this property to `Align.center` will make the text + /// centered inside its box. Similarly, `Align.bottomRight` will render the + /// text that's aligned to the right and to the bottom of the box. + /// + /// Custom alignment anchors are supported too. For example, if this property + /// is set to `Anchor(0.1, 0)`, then the text would be positioned such that + /// its every line will have 10% of whitespace on the left, and 90% on the + /// right. You can use an `AnchorEffect` to make the text gradually transition + /// between different alignment values. + Anchor align; + + /// If true, the size of the component will remain fixed. If false, the size + /// will expand or shrink to the fit the text. + /// + /// This property is set to true if the user has explicitly specified [size] + /// in the constructor. + final bool _fixedSize; + @override set text(String value) { if (text != value) { @@ -99,9 +125,8 @@ class TextBoxComponent extends TextComponent { @override @mustCallSuper - Future onLoad() async { - await super.onLoad(); - await redraw(); + Future onLoad() { + return redraw(); } @override @@ -117,12 +142,13 @@ class TextBoxComponent extends TextComponent { void updateBounds() { _lines.clear(); double? lineHeight; + final maxBoxWidth = _fixedSize ? width : _boxConfig.maxWidth; text.split(' ').forEach((word) { final possibleLine = _lines.isEmpty ? word : '${_lines.last} $word'; lineHeight ??= textRenderer.measureTextHeight(possibleLine); final textWidth = textRenderer.measureTextWidth(possibleLine); - if (textWidth <= _boxConfig.maxWidth - _boxConfig.margins.horizontal) { + if (textWidth <= maxBoxWidth - _boxConfig.margins.horizontal) { if (_lines.isNotEmpty) { _lines.last = possibleLine; } else { @@ -176,7 +202,9 @@ class TextBoxComponent extends TextComponent { } Vector2 _recomputeSize() { - if (_boxConfig.growingBox) { + if (_fixedSize) { + return size; + } else if (_boxConfig.growingBox) { var i = 0; var totalCharCount = 0; final _currentChar = currentChar; @@ -225,23 +253,32 @@ class TextBoxComponent extends TextComponent { /// Override this method to provide a custom background to the text box. void drawBackground(Canvas c) {} - void _fullRender(Canvas c) { - drawBackground(c); + void _fullRender(Canvas canvas) { + drawBackground(canvas); - final _currentLine = currentLine; + final nLines = currentLine + 1; + final boxWidth = size.x - boxConfig.margins.horizontal; + final boxHeight = size.y - boxConfig.margins.vertical; var charCount = 0; - var dy = _boxConfig.margins.top; - for (var line = 0; line < _currentLine; line++) { - charCount += _lines[line].length; - _drawLine(c, _lines[line], dy); - dy += _lineHeight; + for (var i = 0; i < nLines; i++) { + var line = _lines[i]; + if (i == nLines - 1) { + final nChars = math.min(currentChar - charCount, line.length); + line = line.substring(0, nChars); + } + textRenderer.render( + canvas, + line, + Vector2( + boxConfig.margins.left + + (boxWidth - textRenderer.measureTextWidth(line)) * align.x, + boxConfig.margins.top + + (boxHeight - nLines * _lineHeight) * align.y + + i * _lineHeight, + ), + ); + charCount += _lines[i].length; } - final max = math.min(currentChar - charCount, _lines[_currentLine].length); - _drawLine(c, _lines[_currentLine].substring(0, max), dy); - } - - void _drawLine(Canvas c, String line, double dy) { - textRenderer.render(c, line, Vector2(_boxConfig.margins.left, dy)); } Future redraw() async { diff --git a/packages/flame/test/_goldens/text_box_component_test_1.png b/packages/flame/test/_goldens/text_box_component_test_1.png new file mode 100644 index 000000000..800c7f40a Binary files /dev/null and b/packages/flame/test/_goldens/text_box_component_test_1.png differ diff --git a/packages/flame/test/components/text_box_component_test.dart b/packages/flame/test/components/text_box_component_test.dart index 212cdf4f0..7d5920479 100644 --- a/packages/flame/test/components/text_box_component_test.dart +++ b/packages/flame/test/components/text_box_component_test.dart @@ -1,10 +1,10 @@ -import 'dart:ui'; +import 'dart:ui' hide TextStyle; import 'package:canvas_test/canvas_test.dart'; import 'package:flame/components.dart'; import 'package:flame/palette.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:test/test.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { group('TextBoxComponent', () { @@ -20,7 +20,7 @@ void main() { expect(c.size.y, greaterThan(1)); }); - flameGame.test('onLoad waits for cache to be done', (game) async { + testWithFlameGame('onLoad waits for cache to be done', (game) async { final c = TextBoxComponent(text: 'foo bar'); await game.ensureAdd(c); @@ -72,5 +72,77 @@ void main() { expect(c.cache!.debugDisposed, isFalse); }, ); + + testGolden( + 'Alignment options', + (game) async { + game.addAll([ + _FramedTextBox( + text: 'I strike quickly, being moved.', + position: Vector2(10.5, 10), + size: Vector2(390, 100), + align: Anchor.topLeft, + ), + _FramedTextBox( + text: 'But thou art not quickly moved to strike.', + position: Vector2(10, 120), + size: Vector2(390, 115), + align: Anchor.topCenter, + ), + _FramedTextBox( + text: 'A dog of the house of Montague moves me.', + position: Vector2(10, 245), + size: Vector2(390, 115), + align: Anchor.topRight, + ), + _FramedTextBox( + text: 'To move is to stir, and to be valiant is to stand. ' + 'Therefore, if thou art moved, thou runn‘st away.', + position: Vector2(10, 370), + size: Vector2(390, 225), + align: Anchor.bottomRight, + ), + _FramedTextBox( + text: 'A dog of that house shall move me to stand. I will take ' + 'the wall of any man or maid of Montague‘s.', + position: Vector2(410, 10), + size: Vector2(380, 300), + align: Anchor.center, + ), + _FramedTextBox( + text: 'That shows thee a weak slave; for the weakest goes to the ' + 'wall.', + position: Vector2(410, 320), + size: Vector2(380, 270), + align: Anchor.centerRight, + ), + ]); + }, + goldenFile: '../_goldens/text_box_component_test_1.png', + skip: true, + ); }); } + +class _FramedTextBox extends TextBoxComponent { + _FramedTextBox({ + required String text, + Anchor? align, + Vector2? position, + Vector2? size, + }) : super(text: text, align: align, position: position, size: size); + + final Paint _borderPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = const Color(0xff00ff00); + + @override + void render(Canvas canvas) { + canvas.drawRRect( + RRect.fromRectAndRadius(size.toRect(), const Radius.circular(5)), + _borderPaint, + ); + super.render(canvas); + } +} diff --git a/packages/flame_test/lib/src/test_golden.dart b/packages/flame_test/lib/src/test_golden.dart index e1311c970..c14ee75a5 100644 --- a/packages/flame_test/lib/src/test_golden.dart +++ b/packages/flame_test/lib/src/test_golden.dart @@ -29,23 +29,28 @@ void testGolden( PrepareGameFunction testBody, { required String goldenFile, FlameGame? game, + bool skip = false, }) { - testWidgets(testName, (tester) async { - final gameInstance = game ?? FlameGame(); + testWidgets( + testName, + (tester) async { + final gameInstance = game ?? FlameGame(); - await tester.runAsync(() async { - await tester.pumpWidget(GameWidget(game: gameInstance)); - await tester.pump(); - await testBody(gameInstance); - await gameInstance.ready(); - await tester.pump(); - }); + await tester.runAsync(() async { + await tester.pumpWidget(GameWidget(game: gameInstance)); + await tester.pump(); + await testBody(gameInstance); + await gameInstance.ready(); + await tester.pump(); + }); - await expectLater( - find.byWidgetPredicate((widget) => widget is GameWidget), - matchesGoldenFile(goldenFile), - ); - }); + await expectLater( + find.byWidgetPredicate((widget) => widget is GameWidget), + matchesGoldenFile(goldenFile), + ); + }, + skip: skip, + ); } typedef PrepareGameFunction = Future Function(FlameGame game); diff --git a/packages/flame_test/test/golden_test.dart b/packages/flame_test/test/golden_test.dart index 2898560b2..2b10e5874 100644 --- a/packages/flame_test/test/golden_test.dart +++ b/packages/flame_test/test/golden_test.dart @@ -37,5 +37,12 @@ void main() { }, goldenFile: 'golden_test.png', ); + + testGolden( + 'skipped test', + (game) async {}, + goldenFile: 'golden_test.png', + skip: true, + ); }); }