flame icon indicating copy to clipboard operation
flame copied to clipboard

Row and Column for Flame Components

Open rivella50 opened this issue 2 years ago • 11 comments

Problem to solve

With Row and Column classes children Components could be placed and layouted correctly without overlapping and without having to care about repeating size and position calculations. In my games i often have Components (Sprites, buttons, etc.) which have to be placed either in rows or columns, where it's always quite an effort to calculate their positions and sizes. The goal of the suggested two classes would be to make these calculations transparent for the developer.

Proposal

Flutter-like Row and Column classes with alignment options for both axes (i.e. mainAxisAlignment and crossAxisAlignment). Some ideas:

  • the new classes could extend PositionComponent themselves
  • children can possibly be all PositionComponent extending classes (since getting their size is important)
  • adding a child dynamically to a Row or Column would need to re-layout the component
  • another new class for adding gaps would make sense too (like SizedBox) - ok this can be accomplished by just adding a PositionComponent with a size defined

rivella50 avatar Sep 27 '22 18:09 rivella50

Sgtm, do you want to work on this?

spydon avatar Sep 27 '22 18:09 spydon

@spydon I don't have that much spare time at the moment, therefore rather no, sorry.

rivella50 avatar Sep 27 '22 19:09 rivella50

I've a few things on my queue, but I could try tackling this after clear some space on it

erickzanardo avatar Sep 27 '22 19:09 erickzanardo

Here are my first attempts:

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';

enum Direction { horizontal, vertical }

/// Super class for [ComponentRow] and [ComponentColumn]
abstract class LayoutComponent extends PositionComponent with HasGameRef {
  LayoutComponent(this.direction, this.mainAxisAlignment);
  final Direction direction;
  MainAxisAlignment mainAxisAlignment;
}

/// Allows laying out children in a row by defining an [MainAxisAlignment] type.
/// A relayout is performed when
///  - a new child is added
///  - an existing child changes its size
class ComponentRow extends LayoutComponent {
  ComponentRow({MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start})
      : super(Direction.horizontal, mainAxisAlignment);

  @override
  Future<void>? add(Component component) {
    assert(
      component is PositionComponent,
      "The added component has to be a child of PositionComponent",
    );
    (component as PositionComponent).size.addListener(() {
      _layoutChildren();
    });
    
    component.mounted.then((_) => _layoutChildren());
    return super.add(component);
  }

  @override
  void remove(Component component) {
    assert(
      contains(component),
      "This component is not a child of this class",
    );
    (component as PositionComponent).size.removeListener(_layoutChildren);
    
    super.remove(component);
    // hack which needs to be resolved
    Future.delayed(const Duration(milliseconds: 50), () {
      _layoutChildren();
    });
  }

  void _layoutChildren() {
    final list = children.whereType<PositionComponent>().toList();
    Vector2 currentPosition = Vector2.zero();
    double componentsWidth =
        list.fold(0, (previousValue, element) => previousValue + element.width);
    final widthAvailable = size.x != 0.0
        ? size.x - absoluteTopLeftPosition.x
        : gameRef.canvasSize.x - absoluteTopLeftPosition.x;

    if (mainAxisAlignment == MainAxisAlignment.start) {
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += child.width;
      }
    } else if (mainAxisAlignment == MainAxisAlignment.end) {
      for (var child in list.reversed) {
        currentPosition.x -= child.width;
        child.position = Vector2(currentPosition.x, currentPosition.y);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceBetween) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth) / list.length;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceEvenly) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth) / (list.length + 2);
      currentPosition.x += freeSpacePerComponent;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceAround) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth) / (list.length + 1);
      currentPosition.x += freeSpacePerComponent / 2;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.center) {
      final freeSpace = widthAvailable - componentsWidth;
      currentPosition.x += freeSpace / 2;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += child.width;
      }
    }
  }
}

It can be used like this:

final row = ComponentRow(mainAxisAlignment: MainAxisAlignment.start)
  //..size = Vector2(500, 200)
  ..position = Vector2(50,50);
add(row);
row.add(
  TextComponent(text: 'One',)
    ..textRenderer = TextPaint(
        style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600,
    ))
);
row.add(button);
row.add(PositionComponent(size: Vector2(20,0)));  // gap
row.add(TextComponent(text: 'Two',)
  ..textRenderer = TextPaint(
      style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: Colors.yellow
      ))
);

I'm sure there are lots of things i didn't think of and which need to be considered. But hopefully it's a start.

rivella50 avatar Sep 29 '22 15:09 rivella50

Now supports a constant gap between the children which can be changed dynamically.

/// Super class for [ComponentRow] and [ComponentColumn]
abstract class LayoutComponent extends PositionComponent with HasGameRef {
  LayoutComponent(this.direction, this.mainAxisAlignment, this._gap);
  final Direction direction;
  final MainAxisAlignment mainAxisAlignment;

  /// gap between components
  double _gap;

  set gap(double gap) {
    _gap = gap;
    layoutChildren();
  }

  double get gap => _gap;

  @override
  Future<void>? add(Component component) {
    assert(
    component is PositionComponent,
    "The added component has to be a child of PositionComponent",
    );
    (component as PositionComponent).size.addListener(() {
      layoutChildren();
    });

    component.mounted.then((_) => layoutChildren());
    return super.add(component);
  }

  @override
  void remove(Component component) {
    assert(
    contains(component),
    "This component is not a child of this class",
    );
    (component as PositionComponent).size.removeListener(layoutChildren);

    super.remove(component);
    // hack which needs to be resolved
    // https://github.com/flame-engine/flame/issues/1956
    Future.delayed(const Duration(milliseconds: 50), () {
      layoutChildren();
    });
  }

  @protected
  void layoutChildren();
}
/// Allows laying out children in a row by defining a [MainAxisAlignment] type.
/// A relayout is performed when
///  - a new child is added
///  - an existing child changes its size
///  - the [gap] parameter is changed
class ComponentRow extends LayoutComponent {
  ComponentRow({
    MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
    double gap = 0.0,
  }) : super(Direction.horizontal, mainAxisAlignment, gap);

  @override
  void layoutChildren() {
    final list = children.whereType<PositionComponent>().toList();
    Vector2 currentPosition = Vector2.zero();
    double componentsWidth =
        list.fold(0, (previousValue, element) => previousValue + element.width);
    double gapWidth = gap * (list.length - 1);
    final widthAvailable = size.x != 0.0
        ? size.x - absoluteTopLeftPosition.x
        : gameRef.canvasSize.x - absoluteTopLeftPosition.x;

    if (mainAxisAlignment == MainAxisAlignment.start) {
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (child.width + gap);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.end) {
      for (var child in list.reversed) {
        currentPosition.x -= (child.width + gap);
        child.position = Vector2(currentPosition.x, currentPosition.y);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceBetween) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth - gapWidth) / list.length;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width + gap);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceEvenly) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth - gapWidth) / (list.length + 2);
      currentPosition.x += freeSpacePerComponent;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width + gap);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.spaceAround) {
      final freeSpacePerComponent =
          (widthAvailable - componentsWidth - gapWidth) / (list.length + 1);
      currentPosition.x += freeSpacePerComponent / 2;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (freeSpacePerComponent + child.width + gap);
      }
    } else if (mainAxisAlignment == MainAxisAlignment.center) {
      final freeSpace = widthAvailable - componentsWidth - gapWidth;
      currentPosition.x += freeSpace / 2;
      for (var child in list) {
        child.position = Vector2(currentPosition.x, currentPosition.y);
        currentPosition.x += (child.width + gap);
      }
    }
  }
}
enum Direction { horizontal, vertical }

To be used like this:

row = ComponentRow(mainAxisAlignment: MainAxisAlignment.center, gap: 20.0)
  //..size = Vector2(500, 200)
  ..position = Vector2(50,50);
add(row);
row.add(
  TextComponent(text: 'One',)
    ..textRenderer = TextPaint(
        style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600,
    ))
);
row.add(button);
//row.add(PositionComponent(size: Vector2(20,0)));
row.add(TextComponent(text: 'Two',)
  ..textRenderer = TextPaint(
      style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: Colors.yellow
      ))
);

Future.delayed(const Duration(milliseconds: 2200), () {
  row.gap = 50;
});

rivella50 avatar Sep 30 '22 06:09 rivella50

Can we rename the Component to be RowComponent instead of ComponentRow?

That way it follows the current naming convention.

alestiago avatar Oct 01 '22 14:10 alestiago

@alestiago Done

rivella50 avatar Oct 01 '22 14:10 rivella50

Is this feature available?

mrbeardad avatar Jul 15 '23 04:07 mrbeardad

@mrbeardad Not really. At the moment there's only AlignComponent available, where additional layout components shall be added later.

rivella50 avatar Jul 15 '23 05:07 rivella50

Waiting for use now....

HaiboLee avatar Aug 01 '23 03:08 HaiboLee

interesting... +1

NashIlli avatar Jan 05 '24 16:01 NashIlli