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:
Pasha Stetsenko
2022-06-03 14:21:40 -07:00
committed by GitHub
parent 3f28789881
commit c64aedaeb3
7 changed files with 204 additions and 65 deletions

View File

@ -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);
}
}
```

View File

@ -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);
}
}

View File

@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -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 runnst 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 Montagues.',
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);
}
}

View File

@ -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);

View File

@ -37,5 +37,12 @@ void main() {
},
goldenFile: 'golden_test.png',
);
testGolden(
'skipped test',
(game) async {},
goldenFile: 'golden_test.png',
skip: true,
);
});
}