Skip to content

Commit 8209237

Browse files
committed
Wrap a row or column's children onto new runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147gqWiq3xoEdAGwWSpq9JK
1 parent afef14b commit 8209237

4 files changed

Lines changed: 298 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
## Unreleased
22

3+
### Layout
4+
5+
- A row or column with `wrap` builds a `Wrap`: its alignment places
6+
children within and across runs, `spacing` and `runSpacing` set the gaps,
7+
and expanded children take their own size. A child hidden at runtime
8+
leaves no gap.
9+
310
### Signing
411

512
- Signature nodes take a signature by finger, pen or mouse and write its

‎lib/src/transformers/node_transformers/passive_row_column_transformer.dart‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import 'dart:math' as math;
12

23
import 'package:codelessly_api/codelessly_api.dart';
34
import 'package:flutter/material.dart';
5+
import 'package:flutter/rendering.dart' show OverflowBoxFit;
46

57
import '../../../codelessly_sdk.dart';
68

@@ -9,6 +11,11 @@ typedef WidgetInserter = Widget Function(Widget child);
911
class PassiveRowColumnTransformer extends NodeWidgetTransformer<RowColumnNode> {
1012
PassiveRowColumnTransformer(super.getNode, super.manager);
1113

14+
/// Whether [node] is a row/column that wraps its children onto new runs
15+
/// (a [Wrap]) instead of laying them out on one line (a [Flex]).
16+
static bool wrapsChildren(BaseNode node) =>
17+
node is RowColumnNode && node.wrap;
18+
1219
static Widget buildRowColumnWidget(
1320
BaseNode rowColumnNode, {
1421
required List<Widget> childrenWidgets,
@@ -57,6 +64,35 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer<RowColumnNode> {
5764
),
5865
);
5966

67+
// A wrapping row/column is a [Wrap]: the children are broken into runs
68+
// that fit its main axis. It hugs its runs on an axis it shrink-wraps and
69+
// takes the outer box's size on the others.
70+
if (wrapsChildren(rowColumnNode)) {
71+
final RowColumnNode wrapNode = rowColumnNode as RowColumnNode;
72+
Widget wrap = Wrap(
73+
key: ValueKey(rowColumnNode.id),
74+
direction: isRow ? Axis.horizontal : Axis.vertical,
75+
alignment: wrapNode.mainAxisAlignment.flutterWrapAlignment,
76+
runAlignment: wrapNode.mainAxisAlignment.flutterWrapAlignment,
77+
crossAxisAlignment:
78+
wrapNode.crossAxisAlignment.flutterWrapCrossAlignment,
79+
spacing: math.max(0, wrapNode.spacing),
80+
runSpacing: math.max(0, wrapNode.runSpacing),
81+
children: childrenWidgets,
82+
);
83+
if (withScroll && wrapNode.isScrollable) {
84+
wrap = wrapWithPaddedScrollable(
85+
padding: rowColumnNode.resolvedPadding().flutterEdgeInsets,
86+
node: wrapNode,
87+
fillViewport:
88+
rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start,
89+
flexContent: false,
90+
child: wrap,
91+
);
92+
}
93+
return _sizeOuterBox(wrap, outerBox);
94+
}
95+
6096
final SizeFit mainFit = isRow
6197
? rowColumnNode.horizontalFit
6298
: rowColumnNode.verticalFit;
@@ -121,6 +157,11 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer<RowColumnNode> {
121157
);
122158
}
123159

160+
return _sizeOuterBox(res, outerBox);
161+
}
162+
163+
/// Sizes a Row/Column's content [res] with its own box, [outerBox].
164+
static Widget _sizeOuterBox(Widget res, FitBox outerBox) {
124165
if (outerBox.width == null &&
125166
outerBox.height == null &&
126167
!outerBox.fillWidth &&
@@ -209,6 +250,31 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer<RowColumnNode> {
209250
? node.verticalFit
210251
: node.horizontalFit);
211252

253+
// A child of a [Wrap] can't flex: there is no free space to share in a
254+
// run. An expanded child takes its own (solved) size on the main axis,
255+
// as the editor lays it out: its box gets an unbounded main axis, where
256+
// a fill falls back to its design size. The cross axis of a run is
257+
// already unbounded. A child's own alignment is not applied: an [Align]
258+
// would fill the run's main axis.
259+
if (wrapsChildren(parentRowColumn)) {
260+
if (flexibleSpaceBackground != null) {
261+
childWidget = flexibleSpaceBackground(childWidget);
262+
}
263+
if (mainAxisFit == SizeFit.expanded) {
264+
final bool isRow = mainAxis == AxisC.horizontal;
265+
childWidget = OverflowBox(
266+
alignment: AlignmentDirectional.topStart,
267+
fit: OverflowBoxFit.deferToChild,
268+
minWidth: isRow ? 0 : null,
269+
maxWidth: isRow ? double.infinity : null,
270+
minHeight: isRow ? null : 0,
271+
maxHeight: isRow ? null : double.infinity,
272+
child: childWidget,
273+
);
274+
}
275+
return childWidget;
276+
}
277+
212278
// A parent that stretches its children across gives each one the full
213279
// cross axis, like the editor's solver, except a child that hugs on that
214280
// axis: the solver keeps its own size there and places it at the start.
@@ -395,8 +461,42 @@ class PassiveRowColumnWidget extends StatelessWidget {
395461
);
396462
}
397463

464+
/// A child of a wrapping row/column that a condition or variable hides at
465+
/// runtime is left out of the [Wrap], so it takes no spacing either. The
466+
/// Wrap rebuilds when what decides a child's visibility changes; each
467+
/// child is keyed so the others keep their state.
468+
Widget _buildWrap(BuildContext context) {
469+
final NodeTransformerManager manager = this.manager;
470+
if (manager is! PassiveNodeTransformerManager) {
471+
return _buildContent(context, children);
472+
}
473+
Widget build(BuildContext context) => _buildContent(context, [
474+
for (final BaseNode child in children)
475+
if (manager.runtimeVisibility(context, child) != false) child,
476+
]);
477+
478+
final List<Listenable> listenables = [
479+
for (final BaseNode child in children)
480+
...manager.listenablesFor(context, child),
481+
];
482+
if (listenables.isEmpty) return build(context);
483+
return ManagedListenableBuilder(
484+
key: ValueKey('${node.id}-wrap'),
485+
listenables: listenables,
486+
builder: build,
487+
);
488+
}
489+
398490
@override
399491
Widget build(BuildContext context) {
492+
if (PassiveRowColumnTransformer.wrapsChildren(node)) {
493+
return _buildWrap(context);
494+
}
495+
return _buildContent(context, children);
496+
}
497+
498+
Widget _buildContent(BuildContext context, List<BaseNode> children) {
499+
final bool wraps = PassiveRowColumnTransformer.wrapsChildren(node);
400500
final List<Widget> widgetChildren = children.map((child) {
401501
final Widget builtWidget = manager.buildWidgetByID(
402502
child.id,
@@ -411,6 +511,9 @@ class PassiveRowColumnWidget extends StatelessWidget {
411511
builtWidget,
412512
child.flex,
413513
);
514+
if (wraps) {
515+
return KeyedSubtree(key: ValueKey(child.id), child: sizedWidget);
516+
}
414517
return _collapseWhenHidden(context, child, sizedWidget);
415518
}).toList();
416519

‎lib/src/utils/extensions.dart‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,32 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC {
493493
}
494494
}
495495

496+
extension CWrapMainAxisAlignmentHelper on MainAxisAlignmentC {
497+
/// The [flutter.WrapAlignment] of a wrapping row/column: used both for the
498+
/// children within a run and for the runs within the cross axis.
499+
flutter.WrapAlignment get flutterWrapAlignment => switch (this) {
500+
MainAxisAlignmentC.start => flutter.WrapAlignment.start,
501+
MainAxisAlignmentC.end => flutter.WrapAlignment.end,
502+
MainAxisAlignmentC.center => flutter.WrapAlignment.center,
503+
MainAxisAlignmentC.spaceBetween => flutter.WrapAlignment.spaceBetween,
504+
MainAxisAlignmentC.spaceAround => flutter.WrapAlignment.spaceAround,
505+
MainAxisAlignmentC.spaceEvenly => flutter.WrapAlignment.spaceEvenly,
506+
};
507+
}
508+
509+
extension CWrapCrossAxisAlignmentHelper on CrossAxisAlignmentC {
510+
/// The [flutter.WrapCrossAlignment] of a wrapping row/column. A run is as
511+
/// thick as its thickest child, so there is nothing to stretch to; stretch
512+
/// and baseline fall back to start.
513+
flutter.WrapCrossAlignment get flutterWrapCrossAlignment => switch (this) {
514+
CrossAxisAlignmentC.center => flutter.WrapCrossAlignment.center,
515+
CrossAxisAlignmentC.end => flutter.WrapCrossAlignment.end,
516+
CrossAxisAlignmentC.start ||
517+
CrossAxisAlignmentC.stretch ||
518+
CrossAxisAlignmentC.baseline => flutter.WrapCrossAlignment.start,
519+
};
520+
}
521+
496522
extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC {
497523
flutter.CrossAxisAlignment get flutterAxis {
498524
switch (this) {

‎test/wrap_row_column_test.dart‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import 'package:codelessly_api/codelessly_api.dart';
2+
import 'package:codelessly_sdk/codelessly_sdk.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:provider/provider.dart';
6+
7+
RectangleNode box(String id, {double width = 60, double height = 30}) =>
8+
RectangleNode(id: id, name: id, basicBoxLocal: NodeBox(0, 0, width, height))
9+
..parentID = 'row';
10+
11+
Finder findBox(String id) =>
12+
find.byKey(ValueKey('Rectangle Transformer of $id [$id]'));
13+
14+
/// Pumps [row] holding [boxes] at the top left of the screen.
15+
Future<void> pumpRow(
16+
WidgetTester tester,
17+
RowColumnNode row,
18+
List<BaseNode> boxes,
19+
) async {
20+
row
21+
..parentID = kRootNode
22+
..children = [for (final b in boxes) b.id];
23+
final registry = NodeRegistry()
24+
..setNodes({
25+
for (final node in [row, ...boxes]) node.id: node,
26+
});
27+
final manager = PassiveNodeTransformerManager(
28+
registry.getNodeByID,
29+
(context, bounds, pageID, layoutID, canvasID) => const SizedBox(),
30+
);
31+
await tester.pumpWidget(
32+
ChangeNotifierProvider<CodelesslyContext>.value(
33+
value: CodelesslyContext.empty(),
34+
child: MaterialApp(
35+
home: Material(
36+
child: Align(
37+
alignment: Alignment.topLeft,
38+
child: Builder(
39+
builder: (context) => manager.buildWidgetFromNode(
40+
row,
41+
context,
42+
settings: const WidgetBuildSettings(debugLabel: 'test'),
43+
),
44+
),
45+
),
46+
),
47+
),
48+
),
49+
);
50+
expect(tester.takeException(), isNull);
51+
}
52+
53+
RowColumnNode wrapRow({
54+
double width = 200,
55+
double height = 30,
56+
MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start,
57+
CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start,
58+
SizeFit verticalFit = SizeFit.shrinkWrap,
59+
}) => RowColumnNode(
60+
id: 'row',
61+
name: 'row',
62+
basicBoxLocal: NodeBox(0, 0, width, height),
63+
children: [],
64+
rowColumnType: RowColumnType.row,
65+
mainAxisAlignment: mainAxisAlignment,
66+
crossAxisAlignment: crossAxisAlignment,
67+
verticalFit: verticalFit,
68+
wrap: true,
69+
spacing: 10,
70+
runSpacing: 8,
71+
);
72+
73+
void main() {
74+
testWidgets('a wrapping row breaks its children into runs', (tester) async {
75+
final row = wrapRow();
76+
final boxes = [for (int i = 0; i < 6; i++) box('b$i')];
77+
await pumpRow(tester, row, boxes);
78+
79+
expect(find.byType(Wrap), findsOneWidget);
80+
// 3 * 60 + 2 * 10 = 200: three boxes fit each run.
81+
const expected = [
82+
Offset(0, 0),
83+
Offset(70, 0),
84+
Offset(140, 0),
85+
Offset(0, 38),
86+
Offset(70, 38),
87+
Offset(140, 38),
88+
];
89+
for (int i = 0; i < boxes.length; i++) {
90+
expect(
91+
tester.getTopLeft(findBox('b$i')),
92+
expected[i],
93+
reason: 'b$i in run ${i ~/ 3}',
94+
);
95+
expect(tester.getSize(findBox('b$i')), const Size(60, 30));
96+
}
97+
// The row hugs both runs vertically and keeps its width.
98+
expect(tester.getSize(find.byType(Wrap)), const Size(200, 68));
99+
});
100+
101+
testWidgets('main axis alignment aligns the children in each run and the '
102+
'runs, cross axis alignment aligns children in their run', (
103+
tester,
104+
) async {
105+
final row = wrapRow(
106+
height: 100,
107+
verticalFit: SizeFit.fixed,
108+
mainAxisAlignment: MainAxisAlignmentC.center,
109+
crossAxisAlignment: CrossAxisAlignmentC.end,
110+
);
111+
await pumpRow(tester, row, [
112+
box('a', width: 80, height: 20),
113+
box('b'),
114+
box('c'),
115+
]);
116+
117+
// Runs take 30 + 8 + 30 = 68 of 100 and start at 16. Run 1 takes 150 of
118+
// 200 and starts at 25; a is 20 tall in a 30 tall run.
119+
expect(tester.getTopLeft(findBox('a')), const Offset(25, 26));
120+
expect(tester.getTopLeft(findBox('b')), const Offset(115, 16));
121+
expect(tester.getTopLeft(findBox('c')), const Offset(70, 54));
122+
});
123+
124+
testWidgets('expanded children are not flexed and take their own size', (
125+
tester,
126+
) async {
127+
final row = wrapRow();
128+
await pumpRow(tester, row, [
129+
box('a'),
130+
box('wide', width: 100)..horizontalFit = SizeFit.expanded,
131+
box('c'),
132+
]);
133+
expect(find.byType(Flexible), findsNothing);
134+
expect(tester.getSize(findBox('wide')), const Size(100, 30));
135+
expect(tester.getTopLeft(findBox('wide')), const Offset(70, 0));
136+
expect(tester.getTopLeft(findBox('c')), const Offset(0, 38));
137+
});
138+
139+
testWidgets('a child hidden at runtime takes no room or spacing', (
140+
tester,
141+
) async {
142+
final row = wrapRow();
143+
await pumpRow(tester, row, [
144+
box('a'),
145+
box('hidden')..variables['visible'] = r'${= false}',
146+
box('b'),
147+
box('c'),
148+
]);
149+
expect(findBox('hidden'), findsNothing);
150+
expect(tester.getTopLeft(findBox('a')), Offset.zero);
151+
expect(tester.getTopLeft(findBox('b')), const Offset(70, 0));
152+
expect(tester.getTopLeft(findBox('c')), const Offset(140, 0));
153+
expect(tester.getSize(find.byType(Wrap)), const Size(200, 30));
154+
});
155+
156+
testWidgets('without wrap the row is a single line', (tester) async {
157+
final row = wrapRow()..wrap = false;
158+
await pumpRow(tester, row, [box('a'), box('b')]);
159+
expect(find.byType(Wrap), findsNothing);
160+
expect(tester.getTopLeft(findBox('b')), const Offset(60, 0));
161+
});
162+
}

0 commit comments

Comments
 (0)