mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-01 09:39:12 +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);
|
||||||
|
}
|
||||||
|
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 {
|
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,23 +29,28 @@ void testGolden(
|
|||||||
PrepareGameFunction testBody, {
|
PrepareGameFunction testBody, {
|
||||||
required String goldenFile,
|
required String goldenFile,
|
||||||
FlameGame? game,
|
FlameGame? game,
|
||||||
|
bool skip = false,
|
||||||
}) {
|
}) {
|
||||||
testWidgets(testName, (tester) async {
|
testWidgets(
|
||||||
final gameInstance = game ?? FlameGame();
|
testName,
|
||||||
|
(tester) async {
|
||||||
|
final gameInstance = game ?? FlameGame();
|
||||||
|
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
await tester.pumpWidget(GameWidget(game: gameInstance));
|
await tester.pumpWidget(GameWidget(game: gameInstance));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await testBody(gameInstance);
|
await testBody(gameInstance);
|
||||||
await gameInstance.ready();
|
await gameInstance.ready();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
});
|
});
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
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