Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/flame/rendering/text_rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ class MyGame extends FlameGame {
}
```

`TextComponent` has the `HasPaint` mixin, so effects such as `OpacityEffect` and `ColorEffect`
work on it. The component's `paint` is applied on top of the `TextRenderer`: the opacity of the
paint scales the opacity of the text and of the shadows defined in the style, and a color filter
set on the paint is applied to the glyphs. The renderer you set is never modified, `textRenderer`
always returns it. `TextBoxComponent` applies the `paint` to its whole box instead, including the
background.

You can find all the options under [TextComponent's
API](https://pub.dev/documentation/flame/latest/components/TextComponent-class.html).

Expand Down
7 changes: 7 additions & 0 deletions examples/lib/stories/rendering/text_example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ class TextExample extends FlameGame {
),
TextComponent(
text: 'I fade in and fade out',
textRenderer: TextPaint(
style: const TextStyle(
color: Color(0xFF2E9940),
fontSize: 24,
shadows: [Shadow(color: Color(0x99FFFFFF), blurRadius: 4)],
),
),
anchor: Anchor.topRight,
position: Vector2(size.x - 50, 20),
children: [
Expand Down
17 changes: 13 additions & 4 deletions packages/flame/lib/src/components/text_box_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame/palette.dart';
import 'package:flame/text.dart';
import 'package:flutter/widgets.dart' hide Image;
import 'package:meta/meta.dart';
Expand Down Expand Up @@ -63,8 +62,6 @@ class TextBoxConfig {
}

class TextBoxComponent<T extends TextRenderer> extends TextComponent {
static final Paint _imagePaint = BasicPalette.white.paint()
..filterQuality = FilterQuality.medium;
TextBoxConfig _boxConfig;

TextBoxConfig get boxConfig => _boxConfig;
Expand Down Expand Up @@ -361,17 +358,29 @@ class TextBoxComponent<T extends TextRenderer> extends TextComponent {
}
}

/// Draws the cached image of the text box with the component's [paint], so
/// that the opacity and the color filter of the paint apply to the whole
/// box, including the background, without re-rendering the text.
@override
void render(Canvas canvas) {
if (cache == null) {
return;
}
canvas.save();
canvas.scale(1 / pixelRatio);
canvas.drawImage(cache!, Offset.zero, _imagePaint);
canvas.drawImage(
cache!,
Offset.zero,
paint..filterQuality = FilterQuality.medium,
);
canvas.restore();
}

/// The paint is applied when the cached image is drawn in [render], so the
/// text does not need to be formatted again when the paint changes.
@override
void onChanged() {}

Future<Image> _fullRenderAsImage(Vector2 size) {
final recorder = PictureRecorder();
final scaledSize = size * pixelRatio;
Expand Down
24 changes: 22 additions & 2 deletions packages/flame/lib/src/components/text_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class TextComponent<T extends TextRenderer> extends PositionComponent
super.key,
}) : _text = text ?? '',
_textRenderer = textRenderer ?? TextRendererFactory.createDefault<T>() {
_paintedTextRenderer = _textRenderer;
updateBounds();
}

Expand All @@ -32,17 +33,35 @@ class TextComponent<T extends TextRenderer> extends PositionComponent
}
}

/// The renderer that was set on the component.
///
/// The text is drawn with [paintedTextRenderer], which is derived from this
/// renderer whenever the [paint] changes, so this renderer is never modified
/// by opacity or color changes.
T get textRenderer => _textRenderer;
T _textRenderer;
set textRenderer(T textRenderer) {
_textRenderer = textRenderer;
_paintedTextRenderer = _hasPaintChanged
? textRenderer.copyWithPaint(paint) as T
: textRenderer;
updateBounds();
}

/// [textRenderer] with the [paint] of the component applied, which is what
/// the text is drawn with.
///
/// It is derived from [textRenderer] again on every paint change, so the
/// paint never compounds across changes.
@internal
T get paintedTextRenderer => _paintedTextRenderer;
late T _paintedTextRenderer;
bool _hasPaintChanged = false;

late InlineTextElement _textElement;

void _updateElement() {
_textElement = _textRenderer.format(_text);
_textElement = _paintedTextRenderer.format(_text);
_textElement.translate(0, _textElement.metrics.ascent);
}

Expand All @@ -60,7 +79,8 @@ class TextComponent<T extends TextRenderer> extends PositionComponent

@override
void onChanged() {
_textRenderer = _textRenderer.copyWithPaint(paint) as T;
_hasPaintChanged = true;
_paintedTextRenderer = _textRenderer.copyWithPaint(paint) as T;
_updateElement();
}
}
16 changes: 15 additions & 1 deletion packages/flame/lib/src/text/renderers/sprite_font_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,25 @@ class SpriteFontRenderer extends TextRenderer {
);
}

/// Returns a copy of this [SpriteFontRenderer] where [paint] is applied on
/// top of the renderer's own [SpriteFontRenderer.paint].
///
/// The opacity of [paint] is multiplied into the glyph opacity, and the
/// color filter of [paint] replaces the one set through the `color`
/// argument of [SpriteFontRenderer.fromFont] when present.
@override
TextRenderer copyWithPaint(Paint paint) {
return SpriteFontRenderer.fromPaint(
font,
paint: paint,
paint: Paint.from(this.paint)
..color = this.paint.color.withValues(
alpha: this.paint.color.a * paint.color.a,
)
..colorFilter = paint.colorFilter ?? this.paint.colorFilter
..maskFilter = paint.maskFilter ?? this.paint.maskFilter
..shader = paint.shader ?? this.paint.shader,
scale: scale,
letterSpacing: letterSpacing,
);
}
}
47 changes: 46 additions & 1 deletion packages/flame/lib/src/text/renderers/text_paint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,62 @@ class TextPaint extends TextRenderer {
return _textPainterCache.getValue(text)!;
}

/// Returns a copy of this [TextPaint] where [paint] is applied on top of
/// the [style].
///
/// The opacity of [paint] scales the opacity of the text color, the
/// [TextStyle.shadows], the decoration color and the background, so opacity
/// changes made to the paint of a component affect the whole text without
/// discarding the colors that the [style] defines. The color filter, mask
/// filter and shader of [paint] are applied to the glyphs when set.
@override
TextRenderer copyWithPaint(Paint paint) {
final opacity = paint.color.a;
return copyWith(
(style) {
return style.copyWith(
foreground: paint,
foreground: _foregroundWithPaint(style, paint),
shadows: style.shadows
?.map((shadow) => _shadowWithOpacity(shadow, opacity))
.toList(),
decorationColor: _withOpacity(style.decorationColor, opacity),
backgroundColor: _withOpacity(style.backgroundColor, opacity),
background: _paintWithOpacity(style.background, opacity),
);
},
);
}

static Paint _foregroundWithPaint(TextStyle style, Paint paint) {
final foreground = style.foreground;
final color = foreground?.color ?? style.color ?? defaultTextStyle.color!;
return (foreground == null ? Paint() : Paint.from(foreground))
..color = color.withValues(alpha: color.a * paint.color.a)
..colorFilter = paint.colorFilter ?? foreground?.colorFilter
..maskFilter = paint.maskFilter ?? foreground?.maskFilter
..shader = paint.shader ?? foreground?.shader;
}

static Shadow _shadowWithOpacity(Shadow shadow, double opacity) {
return Shadow(
color: shadow.color.withValues(alpha: shadow.color.a * opacity),
offset: shadow.offset,
blurRadius: shadow.blurRadius,
);
}

static Color? _withOpacity(Color? color, double opacity) {
return color?.withValues(alpha: color.a * opacity);
}

static Paint? _paintWithOpacity(Paint? paint, double opacity) {
if (paint == null) {
return null;
}
return Paint.from(paint)
..color = paint.color.withValues(alpha: paint.color.a * opacity);
}

TextPaint copyWith(
TextStyle Function(TextStyle) transform, {
TextDirection? textDirection,
Expand Down
9 changes: 9 additions & 0 deletions packages/flame/lib/src/text/renderers/text_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ abstract class TextRenderer {
return format(text).metrics;
}

/// Returns a copy of this renderer with [paint] applied on top of its own
/// styling.
///
/// This is used by `TextComponent` whenever its paint changes, for example
/// by an `OpacityEffect` or a `ColorEffect`. The returned renderer should
/// keep the colors and other properties of this renderer, and scale its
/// opacity by the opacity of [paint]. Implementations are always given the
/// renderer that was originally set on the component, never a previously
/// returned copy, so the opacity does not compound.
TextRenderer copyWithPaint(Paint paint);

void render(
Expand Down
20 changes: 20 additions & 0 deletions packages/flame/test/components/text_box_component_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,26 @@ void main() {
);
});

testWithFlameGame('draws the cached image with the component paint', (
game,
) async {
final c = TextBoxComponent(text: 'foo bar');
await game.ensureAdd(c);

c.setOpacity(0.5);

final canvas = MockCanvas();
game.render(canvas);
expect(
canvas,
MockCanvas(mode: AssertionMode.containsAnyOrder)..drawImage(
null,
Offset.zero,
Paint()..color = const Color(0xFFFFFFFF).withValues(alpha: 0.5),
),
);
});

testWithFlameGame(
'internal image is disposed when component is removed',
(game) async {
Expand Down
Loading
Loading