feat: Added AlignComponent layout component (#2350)

This PR adds first layout component: AlignComponent, an equivalent of Align widget in Flutter.

AlignComponent sizes itself to its parent, and then keeps its child aligned to the specified anchor within its own bounding box.

Also adding onParentResize() lifecycle method, which is similar to onGameResize, but fires whenever the parent of the current component changes its size for any reason. (FlameGame is assumed to have the size canvasSize, and will invoke onParentResize whenever the canvas size changes).

Additional layout components are planned to be added in future PRs.
This commit is contained in:
Pasha Stetsenko
2023-03-02 13:51:02 -08:00
committed by GitHub
parent 9d18248092
commit 4f5e56f05f
18 changed files with 403 additions and 6 deletions

View File

@ -39,6 +39,9 @@ Every `Component` has a few methods that you can optionally implement, which are
The `onGameResize` method is called whenever the screen is resized, and also when this component The `onGameResize` method is called whenever the screen is resized, and also when this component
gets added into the component tree, before the `onMount`. gets added into the component tree, before the `onMount`.
The `onParentResize` method is similar: it is also called when the component is mounted into the
component tree, and also whenever the parent of the current component changes its size.
The `onRemove` method can be overridden to run code before the component is removed from the game, The `onRemove` method can be overridden to run code before the component is removed from the game,
it is only run once even if the component is removed both by using the parents remove method and it is only run once even if the component is removed both by using the parents remove method and
the `Component` remove method. the `Component` remove method.

View File

@ -12,6 +12,7 @@
- [Camera Component](camera_component.md) - [Camera Component](camera_component.md)
- [Inputs](inputs/inputs.md) - [Inputs](inputs/inputs.md)
- [Rendering](rendering/rendering.md) - [Rendering](rendering/rendering.md)
- [Layout](layout/layout.md)
- [Overlays](overlays.md) - [Overlays](overlays.md)
- [Other](other/other.md) - [Other](other/other.md)
@ -30,5 +31,6 @@ Camera & Viewport <camera_and_viewport.md>
Camera Component <camera_component.md> Camera Component <camera_component.md>
Inputs <inputs/inputs.md> Inputs <inputs/inputs.md>
Rendering <rendering/rendering.md> Rendering <rendering/rendering.md>
Layout <layout/layout.md>
Other <other/other.md> Other <other/other.md>
``` ```

View File

@ -0,0 +1,10 @@
# AlignComponent
```{dartdoc}
:package: flame
:symbol: AlignComponent
:file: src/layout/align_component.dart
[Align]: https://api.flutter.dev/flutter/widgets/Align-class.html
[Alignment]: https://api.flutter.dev/flutter/painting/Alignment-class.html
```

View File

@ -0,0 +1,7 @@
# Layout
```{toctree}
:hidden:
AlignComponent <align_component.md>
```

View File

@ -11,6 +11,7 @@ import 'package:examples/stories/effects/effects.dart';
import 'package:examples/stories/experimental/experimental.dart'; import 'package:examples/stories/experimental/experimental.dart';
import 'package:examples/stories/games/games.dart'; import 'package:examples/stories/games/games.dart';
import 'package:examples/stories/input/input.dart'; import 'package:examples/stories/input/input.dart';
import 'package:examples/stories/layout/layout.dart';
import 'package:examples/stories/parallax/parallax.dart'; import 'package:examples/stories/parallax/parallax.dart';
import 'package:examples/stories/rendering/rendering.dart'; import 'package:examples/stories/rendering/rendering.dart';
import 'package:examples/stories/sprites/sprites.dart'; import 'package:examples/stories/sprites/sprites.dart';
@ -39,6 +40,7 @@ void main() {
addEffectsStories(dashbook); addEffectsStories(dashbook);
addExperimentalStories(dashbook); addExperimentalStories(dashbook);
addInputStories(dashbook); addInputStories(dashbook);
addLayoutStories(dashbook);
addParallaxStories(dashbook); addParallaxStories(dashbook);
addRenderingStories(dashbook); addRenderingStories(dashbook);
addTiledStories(dashbook); addTiledStories(dashbook);

View File

@ -37,7 +37,7 @@ class GameChangeTimer extends TimerComponent
void onTick() { void onTick() {
final child = gameRef.draggablesGame.square; final child = gameRef.draggablesGame.square;
final newParent = child.parent == gameRef.draggablesGame final newParent = child.parent == gameRef.draggablesGame
? gameRef.composedGame.parentSquare ? gameRef.composedGame.parentSquare as Component
: gameRef.draggablesGame; : gameRef.draggablesGame;
child.changeParent(newParent); child.changeParent(newParent);
} }

View File

@ -0,0 +1,100 @@
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/game.dart';
import 'package:flame/layout.dart';
class AlignComponentExample extends FlameGame {
static const String description = '''
In this example the AlignComponent is used to arrange the circles
so that there is one in the middle and 8 more surrounding it in
the shape of a diamond.
The arrangement will remain intact if you change the window size.
''';
@override
void onLoad() {
addAll([
AlignComponent(
child: CircleComponent(
radius: 40,
children: [
SizeEffect.by(
Vector2.all(25),
EffectController(
infinite: true,
duration: 0.75,
reverseDuration: 0.5,
),
),
AlignComponent(
alignment: Anchor.topCenter,
child: CircleComponent(
radius: 10,
anchor: Anchor.bottomCenter,
),
keepChildAnchor: true,
),
AlignComponent(
alignment: Anchor.bottomCenter,
child: CircleComponent(
radius: 10,
anchor: Anchor.topCenter,
),
keepChildAnchor: true,
),
AlignComponent(
alignment: Anchor.centerLeft,
child: CircleComponent(
radius: 10,
anchor: Anchor.centerRight,
),
keepChildAnchor: true,
),
AlignComponent(
alignment: Anchor.centerRight,
child: CircleComponent(
radius: 10,
anchor: Anchor.centerLeft,
),
keepChildAnchor: true,
),
],
),
alignment: Anchor.center,
),
AlignComponent(
child: CircleComponent(radius: 30),
alignment: Anchor.topCenter,
),
AlignComponent(
child: CircleComponent(radius: 30),
alignment: Anchor.bottomCenter,
),
AlignComponent(
child: CircleComponent(radius: 30),
alignment: Anchor.centerLeft,
),
AlignComponent(
child: CircleComponent(radius: 30),
alignment: Anchor.centerRight,
),
AlignComponent(
child: CircleComponent(radius: 10),
alignment: const Anchor(0.25, 0.25),
),
AlignComponent(
child: CircleComponent(radius: 10),
alignment: const Anchor(0.25, 0.75),
),
AlignComponent(
child: CircleComponent(radius: 10),
alignment: const Anchor(0.75, 0.25),
),
AlignComponent(
child: CircleComponent(radius: 10),
alignment: const Anchor(0.75, 0.75),
),
]);
}
}

View File

@ -0,0 +1,13 @@
import 'package:dashbook/dashbook.dart';
import 'package:examples/commons/commons.dart';
import 'package:examples/stories/layout/align_component.dart';
import 'package:flame/game.dart';
void addLayoutStories(Dashbook dashbook) {
dashbook.storiesOf('Layout').add(
'AlignComponent',
(_) => GameWidget(game: AlignComponentExample()),
codeLink: baseLink('layout/align_component.dart'),
info: AlignComponentExample.description,
);
}

View File

@ -0,0 +1 @@
export 'src/layout/align_component.dart' show AlignComponent;

View File

@ -66,6 +66,9 @@ abstract class Viewport extends Component
camera.viewfinder.onViewportResize(); camera.viewfinder.onViewportResize();
} }
onViewportResize(); onViewportResize();
if (hasChildren) {
children.forEach((child) => child.onParentResize(_size));
}
} }
/// Reference to the parent camera. /// Reference to the parent camera.

View File

@ -7,6 +7,7 @@ import 'package:flame/src/components/core/component_tree_root.dart';
import 'package:flame/src/components/core/position_type.dart'; import 'package:flame/src/components/core/position_type.dart';
import 'package:flame/src/components/mixins/coordinate_transform.dart'; import 'package:flame/src/components/mixins/coordinate_transform.dart';
import 'package:flame/src/components/mixins/has_game_ref.dart'; import 'package:flame/src/components/mixins/has_game_ref.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flame/src/game/flame_game.dart'; import 'package:flame/src/game/flame_game.dart';
import 'package:flame/src/game/game.dart'; import 'package:flame/src/game/game.dart';
import 'package:flame/src/gestures/events.dart'; import 'package:flame/src/gestures/events.dart';
@ -474,6 +475,14 @@ class Component {
/// [onMount] call before. /// [onMount] call before.
void onRemove() {} void onRemove() {}
/// Called whenever the parent of this component changes size; and also once
/// before [onMount].
///
/// The component may change its own size or perform layout in response to
/// this call. If the component changes size, then it should call
/// [onParentResize] for all its children.
void onParentResize(Vector2 maxSize) {}
/// This method is called periodically by the game engine to request that your /// This method is called periodically by the game engine to request that your
/// component updates itself. /// component updates itself.
/// ///
@ -830,6 +839,9 @@ class Component {
assert(isLoaded && !isLoading); assert(isLoaded && !isLoading);
_setMountingBit(); _setMountingBit();
onGameResize(_parent!.findGame()!.canvasSize); onGameResize(_parent!.findGame()!.canvasSize);
if (_parent is ReadonlySizeProvider) {
onParentResize((_parent! as ReadonlySizeProvider).size);
}
if (isRemoved) { if (isRemoved) {
_clearRemovedBit(); _clearRemovedBit();
} else if (isRemoving) { } else if (isRemoving) {

View File

@ -68,6 +68,7 @@ class PositionComponent extends Component
AngleProvider, AngleProvider,
PositionProvider, PositionProvider,
ScaleProvider, ScaleProvider,
SizeProvider,
CoordinateTransform { CoordinateTransform {
PositionComponent({ PositionComponent({
Vector2? position, Vector2? position,
@ -184,8 +185,16 @@ class PositionComponent extends Component
/// This property can be reassigned at runtime, although this is not /// This property can be reassigned at runtime, although this is not
/// recommended. Instead, in order to make the [PositionComponent] larger /// recommended. Instead, in order to make the [PositionComponent] larger
/// or smaller, change its [scale]. /// or smaller, change its [scale].
@override
NotifyingVector2 get size => _size; NotifyingVector2 get size => _size;
set size(Vector2 size) => _size.setFrom(size);
@override
set size(Vector2 size) {
_size.setFrom(size);
if (hasChildren) {
children.forEach((child) => child.onParentResize(_size));
}
}
/// The width of the component in local coordinates. Note that the object /// The width of the component in local coordinates. Note that the object
/// may visually appear larger or smaller due to application of [scale]. /// may visually appear larger or smaller due to application of [scale].

View File

@ -46,9 +46,14 @@ abstract class AnchorProvider {
set anchor(Anchor value); set anchor(Anchor value);
} }
/// Interface for a component that can be affected by size effects. /// Interface for a class that has [size] property which can be read but not
abstract class SizeProvider { /// modified.
abstract class ReadonlySizeProvider {
Vector2 get size; Vector2 get size;
}
/// Interface for a component that can be affected by size effects.
abstract class SizeProvider extends ReadonlySizeProvider {
set size(Vector2 value); set size(Vector2 value);
} }

View File

@ -2,6 +2,7 @@ import 'dart:ui';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame/src/components/core/component_tree_root.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flame/src/game/camera/camera.dart'; import 'package:flame/src/game/camera/camera.dart';
import 'package:flame/src/game/camera/camera_wrapper.dart'; import 'package:flame/src/game/camera/camera_wrapper.dart';
import 'package:flame/src/game/game.dart'; import 'package:flame/src/game/game.dart';
@ -15,7 +16,9 @@ import 'package:meta/meta.dart';
/// ///
/// This is the recommended base class to use for most games made with Flame. /// This is the recommended base class to use for most games made with Flame.
/// It is based on the Flame Component System (also known as FCS). /// It is based on the Flame Component System (also known as FCS).
class FlameGame extends ComponentTreeRoot with Game { class FlameGame extends ComponentTreeRoot
with Game
implements ReadonlySizeProvider {
FlameGame({ FlameGame({
super.children, super.children,
Camera? camera, Camera? camera,
@ -111,6 +114,7 @@ class FlameGame extends ComponentTreeRoot with Game {
// there is no way to explicitly call the [Component]'s implementation, // there is no way to explicitly call the [Component]'s implementation,
// we propagate the event to [FlameGame]'s children manually. // we propagate the event to [FlameGame]'s children manually.
handleResize(canvasSize); handleResize(canvasSize);
children.forEach((child) => child.onParentResize(canvasSize));
} }
/// Ensure that all pending tree operations finish. /// Ensure that all pending tree operations finish.

View File

@ -0,0 +1,124 @@
import 'package:flame/src/anchor.dart';
import 'package:flame/src/components/position_component.dart';
import 'package:flame/src/effects/provider_interfaces.dart';
import 'package:flutter/widgets.dart';
import 'package:vector_math/vector_math_64.dart';
/// **AlignComponent** is a layout component that positions its child within
/// itself using relative placement. It is similar to Flutter's [Align] widget.
///
/// The component requires a single [child], which will be the target of this
/// component's alignment. Of course, other children can be added to this
/// component too, but only the initial [child] will be aligned.
///
/// The [alignment] parameter describes where the child should be placed within
/// the current component. For example, if the [alignment] is `Anchor.center`,
/// then the child will be centered.
///
/// Normally, this component's size will match the size of its parent. However,
/// if you provide properties [widthFactor] or [heightFactor], then the size of
/// this component in that direction will be equal to the size of the child
/// times the corresponding factor. For example, if you set [heightFactor] to
/// 1 then the width of this component will be equal to the width of the parent,
/// but the height will match the height of the child.
///
/// ```dart
/// AlignComponent(
/// child: TextComponent('hello'),
/// alignment: Anchor.centerLeft,
/// );
/// ```
///
/// By default, the child's anchor is set equal to the [alignment] value. This
/// achieves traditional alignment behavior: for example, the center of the
/// child will be placed at the center of the current component, or bottom
/// right corner of the child can be placed in the bottom right corner of the
/// component. However, it is also possible to achieve more extravagant
/// placement by giving the child a different anchor and setting
/// [keepChildAnchor] to true. For example, if you set `alignment` to
/// `topCenter`, and child's anchor to `bottomCenter`, then the child will
/// effectively be placed above the current component:
/// ```dart
/// PlayerSprite().add(
/// AlignComponent(
/// child: HealthBar()..anchor = Anchor.bottomCenter,
/// alignment: Anchor.topCenter,
/// keepChildAnchor: true,
/// ),
/// );
/// ```
class AlignComponent extends PositionComponent {
/// Creates a component that keeps its [child] positioned according to the
/// [alignment] within this component's bounding box.
///
/// More precisely, the child will be placed at [alignment] relative position
/// within the current component's bounding box. The child's anchor will also
/// be set to the [alignment], unless [keepChildAnchor] parameter is true.
AlignComponent({
required this.child,
required Anchor alignment,
this.widthFactor,
this.heightFactor,
this.keepChildAnchor = false,
}) {
this.alignment = alignment;
add(child);
}
late Anchor _alignment;
/// The component that will be positioned by this component. The [child] will
/// be automatically mounted to the current component.
final PositionComponent child;
/// How the [child] will be positioned within the current component.
///
/// Note: unlike Flutter's [Alignment], the top-left corner of the component
/// has relative coordinates `(0, 0)`, while the bottom-right corner has
/// coordinates `(1, 1)`.
Anchor get alignment => _alignment;
set alignment(Anchor value) {
_alignment = value;
if (!keepChildAnchor) {
child.anchor = value;
}
child.position = Vector2(size.x * alignment.x, size.y * alignment.y);
}
/// If `null`, then the component's width will be equal to the width of the
/// parent. Otherwise, the width will be equal to the child's width multiplied
/// by this factor.
final double? widthFactor;
/// If `null`, then the component's height will be equal to the height of the
/// parent. Otherwise, the height will be equal to the child's height
/// multiplied by this factor.
final double? heightFactor;
/// If `false` (default), then the child's `anchor` will be kept equal to the
/// [alignment] value. If `true`, then the [child] will be allowed to have its
/// own `anchor` value independent from the parent.
final bool keepChildAnchor;
@override
set size(Vector2 value) {
throw UnsupportedError('The size of AlignComponent cannot be set directly');
}
@override
void onMount() {
assert(
parent is ReadonlySizeProvider,
"An AlignComponent's parent must have a size",
);
}
@override
void onParentResize(Vector2 maxSize) {
super.size = Vector2(
widthFactor == null ? maxSize.x : child.size.x * widthFactor!,
heightFactor == null ? maxSize.y : child.size.y * heightFactor!,
);
child.position = Vector2(size.x * alignment.x, size.y * alignment.y);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,102 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/layout.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AlignComponent', () {
testWithFlameGame('Valid parent', (game) async {
expectLater(
() async {
final parent = Component();
game.add(parent);
parent.add(
AlignComponent(
child: PositionComponent(),
alignment: Anchor.center,
),
);
await game.ready();
},
failsAssert("An AlignComponent's parent must have a size"),
);
});
testGolden(
'Align placement: golden',
(game) async {
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 5.0
..color = const Color(0xaaffff00);
game.add(
AlignComponent(
alignment: Anchor.center,
child: CircleComponent(radius: 20),
),
);
for (final alignment in [
Anchor.topLeft,
Anchor.topRight,
Anchor.bottomLeft,
Anchor.bottomRight,
]) {
game.add(
AlignComponent(
child: CircleComponent(
radius: 60,
paint: stroke,
anchor: Anchor.center,
),
alignment: alignment,
keepChildAnchor: true,
),
);
}
},
goldenFile: '../_goldens/align_component_1.png',
size: Vector2(150, 100),
);
testWithFlameGame(
"Child's alignment remains valid when game resizes",
(game) async {
final component = CircleComponent(radius: 20);
game.add(
AlignComponent(child: component, alignment: Anchor.center),
);
await game.ready();
expect(component.anchor, Anchor.center);
expect(component.position, Vector2(400, 300));
expect(component.size, Vector2.all(40));
game.onGameResize(Vector2(1000, 2000));
expect(component.position, Vector2(500, 1000));
expect(component.size, Vector2.all(40));
},
);
testWithFlameGame(
'Changing alignment value',
(game) async {
final component = CircleComponent(radius: 20);
final alignComponent = AlignComponent(
child: component,
alignment: Anchor.center,
);
game.add(alignComponent);
await game.ready();
expect(component.anchor, Anchor.center);
expect(component.position, Vector2(400, 300));
alignComponent.alignment = Anchor.bottomLeft;
expect(component.position, Vector2(0, 600));
expect(component.anchor, Anchor.bottomLeft);
},
);
});
}

View File

@ -290,4 +290,4 @@ packages:
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
sdks: sdks:
dart: ">=2.18.0 <4.0.0" dart: ">=2.18.0 <3.0.0"