mirror of
				https://github.com/flame-engine/flame.git
				synced 2025-10-31 17:06:50 +08:00 
			
		
		
		
	feat: Aligned text in the TextBoxComponent (#1620)
- Added option align in the TextBoxComponent which controls the alignment of text. - Added option for the TextBoxComponent to have a fixed size (before the only mode was for the textbox to automatically expand/shrink to fit the text).
This commit is contained in:
		| @ -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); | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| @ -10,32 +10,20 @@ class TextExample extends FlameGame { | ||||
|  | ||||
|   @override | ||||
|   Future<void> 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); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -68,26 +68,52 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent { | ||||
|     String? text, | ||||
|     T? textRenderer, | ||||
|     TextBoxConfig? boxConfig, | ||||
|     Anchor? align, | ||||
|     double? pixelRatio, | ||||
|     Vector2? position, | ||||
|     Vector2? size, | ||||
|     Vector2? scale, | ||||
|     double? angle, | ||||
|     Anchor? anchor, | ||||
|     Iterable<Component>? 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<T extends TextRenderer> extends TextComponent { | ||||
|  | ||||
|   @override | ||||
|   @mustCallSuper | ||||
|   Future<void> onLoad() async { | ||||
|     await super.onLoad(); | ||||
|     await redraw(); | ||||
|   Future<void> onLoad() { | ||||
|     return redraw(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @ -117,12 +142,13 @@ class TextBoxComponent<T extends TextRenderer> 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<T extends TextRenderer> 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<T extends TextRenderer> 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<void> redraw() async { | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								packages/flame/test/_goldens/text_box_component_test_1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/flame/test/_goldens/text_box_component_test_1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 36 KiB | 
| @ -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); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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<void> Function(FlameGame game); | ||||
|  | ||||
| @ -37,5 +37,12 @@ void main() { | ||||
|       }, | ||||
|       goldenFile: 'golden_test.png', | ||||
|     ); | ||||
|  | ||||
|     testGolden( | ||||
|       'skipped test', | ||||
|       (game) async {}, | ||||
|       goldenFile: 'golden_test.png', | ||||
|       skip: true, | ||||
|     ); | ||||
|   }); | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Pasha Stetsenko
					Pasha Stetsenko