Repository: flame-engine/flame

Issue:
Get rid of LayoutComponent.shrinkWrap
### What could be improved

I think we can actually get rid of shrinkWrap entirely. I've been using RowComponent and ColumnComponent a lot, and I realized that I set this to true more than I set this to false.

What I'm thinking is, by default the behavior should be shrinkwrapping UNLESS an explicit size is set.

### Why should this be improved

To simplify the API

### Risks

The API will change, so this will be a breaking change. But also this is in the experimental side of flame anyway, so people using it should have had their expectations set anyway.

### More information

_No response_

### Other

- [x] Are you interested in working on a PR for this?


Discussion: Sounds like a good idea!

Fix patch:
diff --git a/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart b/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart
index 5dc1222dbb0..020bba211d1 100644
--- a/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart
+++ b/examples/lib/stories/bridge_libraries/flame_jenny/jenny_command_lifecycle_example.dart
@@ -38,7 +38,6 @@ commands work.
     add(dialogueControllerComponent);
     add(
       ColumnComponent(
-        shrinkWrap: true,
         children: [
           onCommandLabel,
           onCommandFinishLabel,
diff --git a/examples/lib/stories/experimental/layout_component_example.dart b/examples/lib/stories/experimental/layout_component_example.dart
index 94ffe590ccc..40c028903ac 100644
--- a/examples/lib/stories/experimental/layout_component_example.dart
+++ b/examples/lib/stories/experimental/layout_component_example.dart
@@ -29,7 +29,6 @@ layouts to reflect the chosen values.
     );
 
     final mainAxisControls = RowComponent(
-      shrinkWrap: true,
       gap: 16,
       children: [
         TextComponent(text: 'MainAxisAlignment:'),
@@ -45,7 +44,6 @@ layouts to reflect the chosen values.
       ],
     );
     final crossAxisControls = RowComponent(
-      shrinkWrap: true,
       gap: 16,
       children: [
         TextComponent(text: 'CrossAxisAlignment:'),
@@ -60,24 +58,7 @@ layouts to reflect the chosen values.
         }),
       ],
     );
-    final shrinkWrapControls = RowComponent(
-      shrinkWrap: true,
-      gap: 16,
-      children: [
-        TextComponent(text: 'ShrinkWrap:'),
-        ...[false, true].map((shrinkWrap) {
-          return ButtonComponent(
-            button: TextComponent(text: shrinkWrap.toString()),
-            onPressed: () {
-              rowDemo.shrinkWrap = shrinkWrap;
-              columnDemo.shrinkWrap = shrinkWrap;
-            },
-          );
-        }),
-      ],
-    );
     final gapControls = RowComponent(
-      shrinkWrap: true,
       gap: 16,
       children: [
         TextComponent(text: 'Gap:'),
@@ -93,11 +74,10 @@ layouts to reflect the chosen values.
       ],
     );
     final sizeControls = RowComponent(
-      shrinkWrap: true,
       gap: 16,
       children: [
         TextComponent(text: 'Size:'),
-        ...[defaultSize, Vector2(1080, 600)].map((layoutSize) {
+        ...[defaultSize, Vector2(1080, 600), null].map((layoutSize) {
           return ButtonComponent(
             button: TextComponent(text: layoutSize.toString()),
             onPressed: () {
@@ -111,7 +91,6 @@ layouts to reflect the chosen values.
     final rootColumnComponent = ColumnComponent(
       position: Vector2(48, 48),
       gap: 24,
-      shrinkWrap: true,
       children: [
         TextComponent(
           text:
@@ -126,7 +105,6 @@ layouts to reflect the chosen values.
         ),
         mainAxisControls,
         crossAxisControls,
-        shrinkWrapControls,
         gapControls,
         sizeControls,
         rowDemo,
diff --git a/packages/flame/lib/src/experimental/column_component.dart b/packages/flame/lib/src/experimental/column_component.dart
index 64103eeae84..a5a0a467257 100644
--- a/packages/flame/lib/src/experimental/column_component.dart
+++ b/packages/flame/lib/src/experimental/column_component.dart
@@ -10,9 +10,11 @@ import 'package:flutter/rendering.dart';
 /// [mainAxisAlignment], while their alignment along the horizontal axis is
 /// controlled by [crossAxisAlignment].
 ///
-/// If [shrinkWrap] is set to true, the size of the column will shrink its size
-/// fit its children. Otherwise, the size of the column will be determined by
-/// the [size] parameter or the size of its parent.
+/// If [size] is non-null, behaves as normal explicit sizing.
+/// If [size] is null, sets the size to the minimum size that containing all
+/// the children. This is similar to setting the [size] to [inherentSize], but
+/// the distinct in that sizing will respond to changes in children, other
+/// properties, etc...
 ///
 /// Example usage:
 /// ```dart
@@ -32,7 +34,6 @@ class ColumnComponent extends LayoutComponent {
     super.mainAxisAlignment = MainAxisAlignment.start,
     super.crossAxisAlignment = CrossAxisAlignment.start,
     super.gap = 0.0,
-    super.shrinkWrap = false,
     super.size,
     super.position,
     super.children,
diff --git a/packages/flame/lib/src/experimental/layout_component.dart b/packages/flame/lib/src/experimental/layout_component.dart
index 58a253be941..937def638a9 100644
--- a/packages/flame/lib/src/experimental/layout_component.dart
+++ b/packages/flame/lib/src/experimental/layout_component.dart
@@ -33,18 +33,17 @@ enum Direction {
 ///  - the [size] parameter is changed
 ///  - the [mainAxisAlignment] parameter is changed
 ///  - the [crossAxisAlignment] parameter is changed
-///  - the [shrinkWrap] parameter is changed
 ///
 /// Property interactions and gotchas
 ///  - [gap] is ignored when the [mainAxisAlignment] is set to one of:
 ///    - [MainAxisAlignment.spaceAround]
 ///    - [MainAxisAlignment.spaceBetween]
 ///    - [MainAxisAlignment.spaceEvenly]
-///  - Setting the [shrinkWrap] property to true is a reversal of constraints.
+///  - [size] can be set to null to activate shrink-wrap mode.
 ///    Instead of top-down sizing and layout, LayoutComponent derives its size
-///    from its children. In this context, certain layout options become
-///    meaningless. The following are the behaviors you should note:
-///    - Ignore [size].
+///    from its children via [inherentSize]. In this mode, certain layout
+///    options become meaningless. The following are the behaviors you should
+///    note:
 ///    - [mainAxisAlignment] acts like [MainAxisAlignment.start], regardless
 ///      of its original value.
 ///    - [crossAxisAlignment] acts like [CrossAxisAlignment.start], only if
@@ -64,16 +63,18 @@ abstract class LayoutComponent extends PositionComponent {
     required CrossAxisAlignment crossAxisAlignment,
     required MainAxisAlignment mainAxisAlignment,
     required double gap,
-    required bool shrinkWrap,
     required super.position,
-    required super.size,
+    required Vector2? size,
     required super.children,
     super.priority,
   })  : _crossAxisAlignment = crossAxisAlignment,
         _mainAxisAlignment = mainAxisAlignment,
-        _gap = gap,
-        _shrinkWrap = shrinkWrap {
-    setupSizeListeners();
+        _gap = gap {
+    // use the size setter rather than invoke [layoutChildren] because the
+    // latter needs the intent to shrinkwrap pre-set by [_shrinkWrapMode].
+    // At the time of construction, [_shrinkWrapMode] only has its default
+    // value.
+    this.size = size;
   }
 
   factory LayoutComponent.fromDirection(
@@ -81,7 +82,6 @@ abstract class LayoutComponent extends PositionComponent {
     CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.start,
     MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
     double gap = 0.0,
-    bool shrinkWrap = false,
     Vector2? position,
     Vector2? size,
     Iterable<Component> children = const [],
@@ -92,7 +92,6 @@ abstract class LayoutComponent extends PositionComponent {
           crossAxisAlignment: crossAxisAlignment,
           mainAxisAlignment: mainAxisAlignment,
           gap: gap,
-          shrinkWrap: shrinkWrap,
           position: position,
           size: size,
           children: children,
@@ -102,7 +101,6 @@ abstract class LayoutComponent extends PositionComponent {
           crossAxisAlignment: crossAxisAlignment,
           mainAxisAlignment: mainAxisAlignment,
           gap: gap,
-          shrinkWrap: shrinkWrap,
           position: position,
           size: size,
           children: children,
@@ -110,26 +108,64 @@ abstract class LayoutComponent extends PositionComponent {
     }
   }
 
-  final Direction direction;
-
-  bool _shrinkWrap;
-
-  bool get shrinkWrap => _shrinkWrap;
-
-  set shrinkWrap(bool newShrinkWrap) {
-    _shrinkWrap = newShrinkWrap;
-    setupSizeListeners();
-    layoutChildren();
+  /// This size setter is nullable unlike its superclass, to allow this
+  /// [LayoutComponent] to shrink-wrap its children. In other words, it sets
+  /// the size to [inherentSize]. This setter also records the intent to
+  /// shrink-wrap via the [_shrinkWrapMode] property, so that [layoutChildren]
+  /// knows whether or not to invoke this setter.
+  ///
+  /// Internally, this [size] setter should only ever be invoked upon
+  /// construction, and inside [layoutChildren] to make it easier to track and
+  /// reason about.
+  ///
+  /// Externally, this [size] setter is designed as an API, so a library user
+  /// should feel free to use this.
+  @override
+  set size(Vector2? newSize) {
+    final shrinkWrapMode = newSize == null;
+    if (_shrinkWrapMode != shrinkWrapMode) {
+      // We only invoke this when [_shrinkWrapMode]'s value is changing.
+      // This is so we can avoid accumulation of listeners on the children.
+      _setupSizeListeners(_shrinkWrapMode);
+    }
+    _shrinkWrapMode = shrinkWrapMode;
+    // we use [super.size] to benefit from the superclass's notifier mechanisms.
+    if (newSize == null) {
+      super.size = inherentSize;
+    } else {
+      super.size = newSize;
+    }
+    // We might be tempted to use [layoutChildren], but recall that we already
+    // have listeners attached to size via [setupSizeListeners].
+    _layoutMainAxis();
+    _layoutCrossAxis();
   }
 
-  void setupSizeListeners() {
-    if (_shrinkWrap) {
-      size.removeListener(layoutChildren);
+  final Direction direction;
+
+  bool _shrinkWrapMode = false;
+
+  /// Attaches or removes size listeners from [positionChildren], depending on
+  /// the mode of operation. [_shrinkWrapMode] is a property accessible to this
+  /// function, so technically we can access the property directly from within
+  /// the function, but because this function is very sensitive to the value of
+  /// this property, as a safety measure we are making it explicitly a function
+  /// of [shrinkWrapMode].
+  ///
+  /// Previously, this method also attached or removed a listener on the
+  /// component [size] itself, but now that [size] is being overloaded to
+  /// signal intent to shrink wrap, the layout methods are invoked directly
+  /// from the [size] setter itself.
+  void _setupSizeListeners(bool shrinkWrapMode) {
+    if (shrinkWrapMode) {
+      // In shrink wrap mode, since sizing is bottom-up, the children have the
+      // listener and trigger layout.
       for (final child in positionChildren) {
         child.size.addListener(layoutChildren);
       }
     } else {
-      size.addListener(layoutChildren);
+      // In explicit sizing mode, since sizing is top-down, remove the listeners
+      // from the children.
       for (final child in positionChildren) {
         child.size.removeListener(layoutChildren);
       }
@@ -139,7 +175,7 @@ abstract class LayoutComponent extends PositionComponent {
   CrossAxisAlignment _crossAxisAlignment;
 
   CrossAxisAlignment get crossAxisAlignment {
-    if (shrinkWrap && _crossAxisAlignment == CrossAxisAlignment.stretch) {
+    if (_shrinkWrapMode && _crossAxisAlignment == CrossAxisAlignment.stretch) {
       return CrossAxisAlignment.start;
     }
     return _crossAxisAlignment;
@@ -153,7 +189,7 @@ abstract class LayoutComponent extends PositionComponent {
   MainAxisAlignment _mainAxisAlignment;
 
   MainAxisAlignment get mainAxisAlignment {
-    if (shrinkWrap) {
+    if (_shrinkWrapMode) {
       return MainAxisAlignment.start;
     }
     return _mainAxisAlignment;
@@ -179,7 +215,7 @@ abstract class LayoutComponent extends PositionComponent {
       return;
     }
     // setupSizeListeners(), but for a single child
-    if (type == ChildrenChangeType.added && shrinkWrap) {
+    if (type == ChildrenChangeType.added && _shrinkWrapMode) {
       child.size.addListener(layoutChildren);
     } else {
       child.size.removeListener(layoutChildren);
@@ -187,9 +223,11 @@ abstract class LayoutComponent extends PositionComponent {
     layoutChildren();
   }
 
+  /// Sets the size of this [LayoutComponent], then lays out the children
+  /// along both main and cross axes.
   void layoutChildren() {
-    if (shrinkWrap) {
-      size.setFrom(inherentSize);
+    if (_shrinkWrapMode) {
+      size = null;
     }
     _layoutMainAxis();
     _layoutCrossAxis();
@@ -340,7 +378,8 @@ abstract class LayoutComponent extends PositionComponent {
   /// [inherentSize]. This is because all [crossAxisAlignment] transformations
   /// fall within the largestCrossAxisLength, while [mainAxisAlignment] is
   /// entirely ignored in all cases where [inherentSize] is needed.
-  /// This means that [inherentSize] should be used *before* [layoutChildren].
+  /// This means that [inherentSize] should be used either *before* or at the
+  /// start of [layoutChildren].
   Vector2 get inherentSize {
     // Used at multiple points, cache to avoid recalculating each invocation
     final positionChildren = this.positionChildren;
diff --git a/packages/flame/lib/src/experimental/row_component.dart b/packages/flame/lib/src/experimental/row_component.dart
index 8fe4447aa1f..e97f9fa3397 100644
--- a/packages/flame/lib/src/experimental/row_component.dart
+++ b/packages/flame/lib/src/experimental/row_component.dart
@@ -10,9 +10,11 @@ import 'package:flutter/rendering.dart';
 /// [mainAxisAlignment], while their alignment along the vertical axis is
 /// controlled by [crossAxisAlignment].
 ///
-/// If [shrinkWrap] is set to true, the size of the row will shrink to fit its
-/// children. Otherwise, the size of the row will be determined by the [size]
-/// parameter or the size of its parent.
+/// If [size] is non-null, behaves as normal explicit sizing.
+/// If [size] is null, sets the size to the minimum size that containing all
+/// the children. This is similar to setting the [size] to [inherentSize], but
+/// the distinct in that sizing will respond to changes in children, other
+/// properties, etc...
 ///
 /// Example usage:
 /// ```dart
@@ -32,7 +34,6 @@ class RowComponent extends LayoutComponent {
     super.mainAxisAlignment = MainAxisAlignment.start,
     super.crossAxisAlignment = CrossAxisAlignment.start,
     super.gap = 0.0,
-    super.shrinkWrap = false,
     super.size,
     super.position,
     super.children,


Test patch:
diff --git a/packages/flame/test/experimental/layout_component_test.dart b/packages/flame/test/experimental/layout_component_test.dart
index 59ee1e85039..e600eff2254 100644
--- a/packages/flame/test/experimental/layout_component_test.dart
+++ b/packages/flame/test/experimental/layout_component_test.dart
@@ -262,43 +262,42 @@ void main() {
       });
     });
 
-    group('shrinkWrap', () {
+    group('size', () {
       runLayoutComponentTestRegistry({
-        'shrinkWrap=true ignores size': (game, direction) async {
+        'size=null sets size to inherentSize': (game, direction) async {
           final circle = CircleComponent(radius: 20);
           final rectangle = RectangleComponent(size: Vector2(100, 50));
           final text = TextComponent(text: 'testing');
           final layoutComponentSize = Vector2.all(500);
           final layoutComponent = LayoutComponent.fromDirection(
             direction,
-            shrinkWrap: true,
             children: [circle, rectangle, text],
             size: layoutComponentSize,
           );
           await game.ensureAdd(layoutComponent);
+          expect(layoutComponent.size, layoutComponentSize);
+          layoutComponent.size = null;
           expect(layoutComponent.size, layoutComponent.inherentSize);
         },
-        'shrinkWrap=true ignores mainAxisAlignment': (game, direction) async {
+        'size=null ignores mainAxisAlignment': (game, direction) async {
           final circle = CircleComponent(radius: 20);
           final rectangle = RectangleComponent(size: Vector2(100, 50));
           final text = TextComponent(text: 'testing');
           final layoutComponent = LayoutComponent.fromDirection(
             direction,
-            shrinkWrap: true,
             children: [circle, rectangle, text],
             mainAxisAlignment: MainAxisAlignment.spaceBetween,
           );
           await game.ensureAdd(layoutComponent);
           expect(layoutComponent.mainAxisAlignment, MainAxisAlignment.start);
         },
-        'shrinkWrap=true respects supported crossAxisAlignments':
+        'size=null respects supported crossAxisAlignments':
             (game, direction) async {
           final circle = CircleComponent(radius: 20);
           final rectangle = RectangleComponent(size: Vector2(100, 50));
           final text = TextComponent(text: 'testing');
           final layoutComponent = LayoutComponent.fromDirection(
             direction,
-            shrinkWrap: true,
             children: [circle, rectangle, text],
             crossAxisAlignment: CrossAxisAlignment.center,
           );
@@ -309,14 +308,13 @@ void main() {
           layoutComponent.crossAxisAlignment = CrossAxisAlignment.start;
           expect(layoutComponent.crossAxisAlignment, CrossAxisAlignment.start);
         },
-        "shrinkWrap=true doesn't respect CrossAxisAlignment.stretch":
+        "size=null doesn't respect CrossAxisAlignment.stretch":
             (game, direction) async {
           final circle = CircleComponent(radius: 20);
           final rectangle = RectangleComponent(size: Vector2(100, 50));
           final text = TextComponent(text: 'testing');
           final layoutComponent = LayoutComponent.fromDirection(
             direction,
-            shrinkWrap: true,
             children: [circle, rectangle, text],
             crossAxisAlignment: CrossAxisAlignment.stretch,
           );
@@ -335,7 +333,6 @@ void main() {
             // final rectangle2 = RectangleComponent(size: Vector2(200, 70));
             final layoutComponent = LayoutComponent.fromDirection(
               direction,
-              shrinkWrap: true,
               crossAxisAlignment: CrossAxisAlignment.stretch,
             );
             await game.ensureAdd(layoutComponent);