mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-03 04:18:25 +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