mirror of
				https://github.com/flame-engine/flame.git
				synced 2025-11-01 01:18:38 +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. | 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 | 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`. | If you want to change the margins of the box use the `margins` variable in the `TextBoxConfig`. | ||||||
|  |  | ||||||
| @ -106,17 +111,18 @@ Example usage: | |||||||
|  |  | ||||||
| ```dart | ```dart | ||||||
| class MyTextBox extends TextBoxComponent { | 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 |   @override | ||||||
|   void drawBackground(Canvas c) { |   void render(Canvas canvas) { | ||||||
|     Rect rect = Rect.fromLTWH(0, 0, width, height); |     Rect rect = Rect.fromLTWH(0, 0, width, height); | ||||||
|     c.drawRect(rect, Paint()..color = Color(0xFFFF00FF)); |     canvas.drawRect(rect, bgPaint); | ||||||
|     c.drawRect( |     canvas.drawRect(rect.deflate(boxConfig.margin), borderPaint); | ||||||
|         rect.deflate(boxConfig.margin), |     super.render(canvas); | ||||||
|         BasicPalette.black.Paint() |  | ||||||
|           ..style = PaintingStyle.stroke, |  | ||||||
|     ); |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
| ``` | ``` | ||||||
|  | |||||||
| @ -10,32 +10,20 @@ class TextExample extends FlameGame { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<void> onLoad() async { |   Future<void> onLoad() async { | ||||||
|     add( |     addAll([ | ||||||
|       TextComponent(text: 'Hello, Flame', textRenderer: _regular) |       TextComponent(text: 'Hello, Flame', textRenderer: _regular) | ||||||
|         ..anchor = Anchor.topCenter |         ..anchor = Anchor.topCenter | ||||||
|         ..x = size.x / 2 |         ..x = size.x / 2 | ||||||
|         ..y = 32.0, |         ..y = 32.0, | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     add( |  | ||||||
|       TextComponent(text: 'Text with shade', textRenderer: _shaded) |       TextComponent(text: 'Text with shade', textRenderer: _shaded) | ||||||
|         ..anchor = Anchor.topRight |         ..anchor = Anchor.topRight | ||||||
|         ..position = size - Vector2.all(100), |         ..position = size - Vector2.all(100), | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     add( |  | ||||||
|       TextComponent(text: 'center', textRenderer: _tiny) |       TextComponent(text: 'center', textRenderer: _tiny) | ||||||
|         ..anchor = Anchor.center |         ..anchor = Anchor.center | ||||||
|         ..position.setFrom(size / 2), |         ..position.setFrom(size / 2), | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     add( |  | ||||||
|       TextComponent(text: 'bottomRight', textRenderer: _tiny) |       TextComponent(text: 'bottomRight', textRenderer: _tiny) | ||||||
|         ..anchor = Anchor.bottomRight |         ..anchor = Anchor.bottomRight | ||||||
|         ..position.setFrom(size), |         ..position.setFrom(size), | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     add( |  | ||||||
|       MyTextBox( |       MyTextBox( | ||||||
|         '"This is our world now. The world of the electron and the switch; ' |         '"This is our world now. The world of the electron and the switch; ' | ||||||
|         'the beauty of the baud. We exist without nationality, skin color, ' |         'the beauty of the baud. We exist without nationality, skin color, ' | ||||||
| @ -45,7 +33,23 @@ class TextExample extends FlameGame { | |||||||
|       ) |       ) | ||||||
|         ..anchor = Anchor.bottomLeft |         ..anchor = Anchor.bottomLeft | ||||||
|         ..y = size.y, |         ..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 { | class MyTextBox extends TextBoxComponent { | ||||||
|   MyTextBox(String text) |   MyTextBox( | ||||||
|       : super( |     String text, { | ||||||
|  |     Anchor? align, | ||||||
|  |     Vector2? size, | ||||||
|  |     double? timePerChar, | ||||||
|  |     double? margins, | ||||||
|  |   }) : super( | ||||||
|           text: text, |           text: text, | ||||||
|           textRenderer: _box, |           textRenderer: _box, | ||||||
|  |           align: align, | ||||||
|  |           size: size, | ||||||
|           boxConfig: TextBoxConfig( |           boxConfig: TextBoxConfig( | ||||||
|             maxWidth: 400, |             maxWidth: 400, | ||||||
|             timePerChar: 0.05, |             timePerChar: timePerChar ?? 0.05, | ||||||
|             growingBox: true, |             growingBox: true, | ||||||
|             margins: const EdgeInsets.all(25), |             margins: EdgeInsets.all(margins ?? 25), | ||||||
|           ), |           ), | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void drawBackground(Canvas c) { |   void render(Canvas canvas) { | ||||||
|     final rect = Rect.fromLTWH(0, 0, width, height); |     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, |     String? text, | ||||||
|     T? textRenderer, |     T? textRenderer, | ||||||
|     TextBoxConfig? boxConfig, |     TextBoxConfig? boxConfig, | ||||||
|  |     Anchor? align, | ||||||
|     double? pixelRatio, |     double? pixelRatio, | ||||||
|     Vector2? position, |     Vector2? position, | ||||||
|  |     Vector2? size, | ||||||
|     Vector2? scale, |     Vector2? scale, | ||||||
|     double? angle, |     double? angle, | ||||||
|     Anchor? anchor, |     Anchor? anchor, | ||||||
|     Iterable<Component>? children, |     Iterable<Component>? children, | ||||||
|     int? priority, |     int? priority, | ||||||
|   })  : _boxConfig = boxConfig ?? TextBoxConfig(), |   })  : _boxConfig = boxConfig ?? TextBoxConfig(), | ||||||
|  |         _fixedSize = size != null, | ||||||
|  |         align = align ?? Anchor.topLeft, | ||||||
|         pixelRatio = pixelRatio ?? window.devicePixelRatio, |         pixelRatio = pixelRatio ?? window.devicePixelRatio, | ||||||
|         super( |         super( | ||||||
|           text: text, |           text: text, | ||||||
|           textRenderer: textRenderer, |           textRenderer: textRenderer, | ||||||
|           position: position, |           position: position, | ||||||
|           scale: scale, |           scale: scale, | ||||||
|  |           size: size, | ||||||
|           angle: angle, |           angle: angle, | ||||||
|           anchor: anchor, |           anchor: anchor, | ||||||
|           children: children, |           children: children, | ||||||
|           priority: priority, |           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 |   @override | ||||||
|   set text(String value) { |   set text(String value) { | ||||||
|     if (text != value) { |     if (text != value) { | ||||||
| @ -99,9 +125,8 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   @mustCallSuper |   @mustCallSuper | ||||||
|   Future<void> onLoad() async { |   Future<void> onLoad() { | ||||||
|     await super.onLoad(); |     return redraw(); | ||||||
|     await redraw(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @ -117,12 +142,13 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent { | |||||||
|   void updateBounds() { |   void updateBounds() { | ||||||
|     _lines.clear(); |     _lines.clear(); | ||||||
|     double? lineHeight; |     double? lineHeight; | ||||||
|  |     final maxBoxWidth = _fixedSize ? width : _boxConfig.maxWidth; | ||||||
|     text.split(' ').forEach((word) { |     text.split(' ').forEach((word) { | ||||||
|       final possibleLine = _lines.isEmpty ? word : '${_lines.last} $word'; |       final possibleLine = _lines.isEmpty ? word : '${_lines.last} $word'; | ||||||
|       lineHeight ??= textRenderer.measureTextHeight(possibleLine); |       lineHeight ??= textRenderer.measureTextHeight(possibleLine); | ||||||
|  |  | ||||||
|       final textWidth = textRenderer.measureTextWidth(possibleLine); |       final textWidth = textRenderer.measureTextWidth(possibleLine); | ||||||
|       if (textWidth <= _boxConfig.maxWidth - _boxConfig.margins.horizontal) { |       if (textWidth <= maxBoxWidth - _boxConfig.margins.horizontal) { | ||||||
|         if (_lines.isNotEmpty) { |         if (_lines.isNotEmpty) { | ||||||
|           _lines.last = possibleLine; |           _lines.last = possibleLine; | ||||||
|         } else { |         } else { | ||||||
| @ -176,7 +202,9 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Vector2 _recomputeSize() { |   Vector2 _recomputeSize() { | ||||||
|     if (_boxConfig.growingBox) { |     if (_fixedSize) { | ||||||
|  |       return size; | ||||||
|  |     } else if (_boxConfig.growingBox) { | ||||||
|       var i = 0; |       var i = 0; | ||||||
|       var totalCharCount = 0; |       var totalCharCount = 0; | ||||||
|       final _currentChar = currentChar; |       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. |   /// Override this method to provide a custom background to the text box. | ||||||
|   void drawBackground(Canvas c) {} |   void drawBackground(Canvas c) {} | ||||||
|  |  | ||||||
|   void _fullRender(Canvas c) { |   void _fullRender(Canvas canvas) { | ||||||
|     drawBackground(c); |     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 charCount = 0; | ||||||
|     var dy = _boxConfig.margins.top; |     for (var i = 0; i < nLines; i++) { | ||||||
|     for (var line = 0; line < _currentLine; line++) { |       var line = _lines[i]; | ||||||
|       charCount += _lines[line].length; |       if (i == nLines - 1) { | ||||||
|       _drawLine(c, _lines[line], dy); |         final nChars = math.min(currentChar - charCount, line.length); | ||||||
|       dy += _lineHeight; |         line = line.substring(0, nChars); | ||||||
|       } |       } | ||||||
|     final max = math.min(currentChar - charCount, _lines[_currentLine].length); |       textRenderer.render( | ||||||
|     _drawLine(c, _lines[_currentLine].substring(0, max), dy); |         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; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   void _drawLine(Canvas c, String line, double dy) { |  | ||||||
|     textRenderer.render(c, line, Vector2(_boxConfig.margins.left, dy)); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> redraw() async { |   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:canvas_test/canvas_test.dart'; | ||||||
| import 'package:flame/components.dart'; | import 'package:flame/components.dart'; | ||||||
| import 'package:flame/palette.dart'; | import 'package:flame/palette.dart'; | ||||||
| import 'package:flame_test/flame_test.dart'; | import 'package:flame_test/flame_test.dart'; | ||||||
| import 'package:test/test.dart'; | import 'package:flutter_test/flutter_test.dart'; | ||||||
|  |  | ||||||
| void main() { | void main() { | ||||||
|   group('TextBoxComponent', () { |   group('TextBoxComponent', () { | ||||||
| @ -20,7 +20,7 @@ void main() { | |||||||
|       expect(c.size.y, greaterThan(1)); |       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'); |       final c = TextBoxComponent(text: 'foo bar'); | ||||||
|  |  | ||||||
|       await game.ensureAdd(c); |       await game.ensureAdd(c); | ||||||
| @ -72,5 +72,77 @@ void main() { | |||||||
|         expect(c.cache!.debugDisposed, isFalse); |         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,8 +29,11 @@ void testGolden( | |||||||
|   PrepareGameFunction testBody, { |   PrepareGameFunction testBody, { | ||||||
|   required String goldenFile, |   required String goldenFile, | ||||||
|   FlameGame? game, |   FlameGame? game, | ||||||
|  |   bool skip = false, | ||||||
| }) { | }) { | ||||||
|   testWidgets(testName, (tester) async { |   testWidgets( | ||||||
|  |     testName, | ||||||
|  |     (tester) async { | ||||||
|       final gameInstance = game ?? FlameGame(); |       final gameInstance = game ?? FlameGame(); | ||||||
|  |  | ||||||
|       await tester.runAsync(() async { |       await tester.runAsync(() async { | ||||||
| @ -45,7 +48,9 @@ void testGolden( | |||||||
|         find.byWidgetPredicate((widget) => widget is GameWidget), |         find.byWidgetPredicate((widget) => widget is GameWidget), | ||||||
|         matchesGoldenFile(goldenFile), |         matchesGoldenFile(goldenFile), | ||||||
|       ); |       ); | ||||||
|   }); |     }, | ||||||
|  |     skip: skip, | ||||||
|  |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| typedef PrepareGameFunction = Future<void> Function(FlameGame game); | typedef PrepareGameFunction = Future<void> Function(FlameGame game); | ||||||
|  | |||||||
| @ -37,5 +37,12 @@ void main() { | |||||||
|       }, |       }, | ||||||
|       goldenFile: 'golden_test.png', |       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