Skip to content
Merged
1 change: 1 addition & 0 deletions .github/.cspell/dart_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ dartdoc # documentation tool for dart
dartdocs # plural of dartdoc
endtemplate # Use @endtemplate to close a @template block in dartdoc
pubspec # dependency and configuration file of every Dart project
superellipse # dart:ui RSuperellipse, used in Canvas.clipRSuperellipse
unawaited # dart:async helper to mark a Future as intentionally not awaited
69 changes: 69 additions & 0 deletions doc/flame/components/utility_components.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,75 @@ Check the example app
for details on how to use it.


## WidgetComponent

A `WidgetComponent` hosts a Flutter widget inside the Flame component tree. The widget becomes a
real part of the Flutter widget tree under the `GameWidget`, so it is laid out, painted, hit tested
and focused like any other widget: buttons respond to taps, text fields receive keyboard input, and
inherited widgets such as `Theme`, `MediaQuery` and `Directionality` are available to it. At the
same time it is rendered in the middle of the Flame render pass, so it respects the component
`priority`, the camera transform, and the position, angle, scale and anchor of the component and all
of its ancestors.

```dart
world.add(
WidgetComponent(
position: Vector2(100, 100),
size: Vector2(200, 60),
anchor: Anchor.center,
widget: ElevatedButton(
onPressed: () => print('pressed'),
child: const Text('Play'),
),
),
);
```

When `size` is given, the widget is laid out with tight constraints of that size, in the same way
as a `SizedBox` would. When it is omitted, the component adopts whatever size the widget ends up
with. In that case the widget is laid out with the `constraints` passed to the component, or, when
those are omitted too, with loose constraints bounded by the size of the game canvas expressed in
the local units of the component, so the scale of the component and of its ancestors is taken into
account but the camera zoom is not.

The hosted widget can be replaced at any time by assigning `widget`, which rebuilds the hosted
subtree in the same way as returning a new widget from a `build` method would. State inside the
widget, such as the text of a `TextField`, is kept as long as the widget types and keys line up, as
in any other Flutter rebuild.

Widgets are hit tested before the components of the game, so a tap on a button inside a
`WidgetComponent` is not also delivered to a `TapCallbacks` component below it. Material widgets
such as `ElevatedButton` and `TextField` need a `Material` ancestor, so either put a `Material`
inside the hosted widget or make sure the `GameWidget` is inside a `MaterialApp` `Scaffold`.

This differs from [overlays](../overlays.md), which are placed in a `Stack` on top of the whole
game and are not affected by the camera or by any component transforms. Use overlays for menus and
HUD elements that should stay fixed on the screen, and `WidgetComponent` for widgets that belong to
the game world, for example a speech bubble attached to a character or a form on an in-game
terminal.

There are some limitations to be aware of:

- The widget is only rendered by the `GameWidget` render pass. It is not included when the component
tree is rendered to a `Picture` or `Image` elsewhere, for example by the `Snapshot` mixin, by
`PostProcess`es or by the devtools component snapshot.
- Flame paints are not applied to the widget. Paint based effects such as `OpacityEffect` or
`ColorEffect` on the component or its ancestors do not affect the widget, only transforms and
rectangular clips (such as the camera viewport) do.
- A widget that needs its own compositing layer (for example one that contains a `RepaintBoundary`,
a scrollable list, or a platform view) splits the game's picture in two around it. Any `saveLayer`
that an ancestor component has active at that point is closed and reopened around the widget.
- A widget can only be painted once per frame. When the same `WidgetComponent` is rendered several
times in one frame, for example because its world is viewed by several cameras, only the first
render paints the widget.
- While the component is not rendered, for example because an ancestor is hidden, the widget stays
in the widget tree but is excluded from focus and semantics, and `isPainted` is false.

Check the example app
[widget_component](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/components/widget_component_example.dart)
for details on how to use it.


## ComponentsNotifier

Most of the time just accessing children and their attributes is enough to build the logic of
Expand Down
5 changes: 5 additions & 0 deletions doc/flame/overlays.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ widgets in your tree. However, if you want to easily show widgets on top of your
messages, menu screens or something of that nature, you can use the Widgets Overlay API to make
things even easier.

Overlays are placed on top of the whole game and are not affected by the camera or by component
transforms. If you instead want a widget to be part of the game world, rendered in between other
components and following their position, angle and scale, use a
[`WidgetComponent`](components/utility_components.md#widgetcomponent).

`Game.overlays` enables any Flutter widget to be shown on top of a game instance. This makes it very
easy to create things like a pause menu or an inventory screen for example.

Expand Down
9 changes: 9 additions & 0 deletions examples/lib/stories/components/components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:examples/stories/components/priority_example.dart';
import 'package:examples/stories/components/skip_text_box_component_example.dart';
import 'package:examples/stories/components/spawn_component_example.dart';
import 'package:examples/stories/components/time_scale_example.dart';
import 'package:examples/stories/components/widget_component_example.dart';
import 'package:flame/game.dart';

void addComponentsStories(Dashbook dashbook) {
Expand Down Expand Up @@ -116,5 +117,13 @@ void addComponentsStories(Dashbook dashbook) {
(_) => GameWidget(game: SkipTextBoxComponentExample()),
codeLink: baseLink('components/skip_text_box_component_example.dart'),
info: SkipTextBoxComponentExample.description,
)
..add(
'Widget Component',
(_) => const GameWidget.managed(
gameFactory: WidgetComponentExample.new,
),
codeLink: baseLink('components/widget_component_example.dart'),
info: WidgetComponentExample.description,
);
}
178 changes: 178 additions & 0 deletions examples/lib/stories/components/widget_component_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import 'dart:math';

import 'package:examples/commons/ember.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

class WidgetComponentExample extends FlameGame {
static const String description = '''
In this example we showcase the `WidgetComponent`, which hosts a Flutter
widget inside the Flame component tree. The widgets are real parts of the
Flutter widget tree, so the button and the text field respond to taps and
keyboard input, while they are rendered with the position, angle, scale and
priority of their component, in between other Flame components.
Press the button to spawn Embers around the card, randomly behind or in
front of it, and type in the text field to change the label of the
rotating card.
''';

final Random _random = Random();
final ValueNotifier<String> _label = ValueNotifier('Flame');

Vector2 get _cardCenter => size / 2 + Vector2(0, 80);

@override
Future<void> onLoad() async {
final button = WidgetComponent(
position: Vector2(size.x / 2, 80),
anchor: Anchor.center,
widget: Material(
color: Colors.transparent,
child: ElevatedButton.icon(
onPressed: _spawnEmber,
icon: const Icon(Icons.add),
label: const Text('Spawn an Ember'),
),
),
);

final textField = WidgetComponent(
position: Vector2(size.x / 2, 160),
size: Vector2(280, 56),
anchor: Anchor.center,
widget: Material(
color: Colors.transparent,
child: TextField(
onChanged: (value) => _label.value = value,
style: const TextStyle(color: Colors.black87),
decoration: const InputDecoration(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white,
hintText: 'Card label',
hintStyle: TextStyle(color: Colors.black54),
),
),
),
);

final card = WidgetComponent(
position: _cardCenter,
size: Vector2(220, 120),
anchor: Anchor.center,
priority: 1,
widget: _LabelCard(label: _label),
);
card.add(
RotateEffect.by(
2 * pi,
EffectController(duration: 8, infinite: true),
),
);
card.add(
ScaleEffect.to(
Vector2.all(1.3),
EffectController(
duration: 2,
reverseDuration: 2,
infinite: true,
),
),
);

addAll([
_BackgroundEmber(position: _cardCenter),
button,
textField,
card,
_ForegroundEmber(position: _cardCenter),
]);
}

void _spawnEmber() {
final angle = _random.nextDouble() * 2 * pi;
final distance = 30 + _random.nextDouble() * 70;
final inFront = _random.nextBool();
final ember = Ember(
position: _cardCenter + Vector2(cos(angle), sin(angle)) * distance,
size: Vector2.all(40),
priority: inFront ? 2 : 0,
);
ember.add(
MoveEffect.by(
Vector2(0, -30),
EffectController(
duration: 1,
reverseDuration: 1,
infinite: true,
),
),
);
add(ember);
}
}

/// An Ember rendered behind the card, to show that widgets are rendered in
/// between other components according to their priority.
class _BackgroundEmber extends Ember {
_BackgroundEmber({required super.position})
: super(size: Vector2.all(160), priority: 0);
}

/// An Ember rendered in front of the card, orbiting around it.
class _ForegroundEmber extends Ember {
_ForegroundEmber({required super.position})
: super(size: Vector2.all(40), priority: 2);

@override
Future<void> onLoad() async {
await super.onLoad();
add(
MoveAlongPathEffect(
Path()..addOval(Rect.fromCircle(center: Offset.zero, radius: 140)),
EffectController(duration: 6, infinite: true),
),
);
}
}

class _LabelCard extends StatelessWidget {
const _LabelCard({required this.label});

final ValueNotifier<String> label;

@override
Widget build(BuildContext context) {
return Card(
color: Colors.orange.shade100,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.widgets, size: 32, color: Colors.black87),
const SizedBox(height: 8),
ValueListenableBuilder<String>(
valueListenable: label,
builder: (context, value, child) {
return Text(
value.isEmpty ? 'Flame' : value,
style: const TextStyle(
color: Colors.black87,
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
);
}
}
1 change: 1 addition & 0 deletions packages/flame/lib/components.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export 'src/components/text_box_component.dart';
export 'src/components/text_component.dart';
export 'src/components/text_element_component.dart';
export 'src/components/timer_component.dart';
export 'src/components/widget_component.dart';
export 'src/extensions/vector2.dart';
export 'src/geometry/circle_component.dart';
export 'src/geometry/polygon_component.dart';
Expand Down
Loading
Loading