Skip to content

Commit 54f55c0

Browse files
committed
Wrapping list views
A list with wrap on and a known item count (itemCount or bound data, no cloud query) builds all its items in a Wrap along its scroll direction: the separator spacing between items (no dividers), runSpacing between runs, aligned to the start, sized by its own fits (shrink-wrap hugs the runs). It never scrolls. A list with an unknown count ignores wrap. Items keep their own size in a run instead of being aligned across the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyH8twyrBoe4wFCTw78Cz5
1 parent b4e299a commit 54f55c0

3 files changed

Lines changed: 203 additions & 6 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:core';
2+
import 'dart:math' as math;
23

34
import 'package:cloud_firestore/cloud_firestore.dart';
45
import 'package:codelessly_api/codelessly_api.dart';
@@ -92,6 +93,7 @@ class PassiveListViewWidget extends StatelessWidget {
9293
int itemCount,
9394
IndexedWidgetBuilder itemBuilder,
9495
) {
96+
if (listWraps(node)) return wrappedList(context, itemCount, itemBuilder);
9597
final bool vertical = node.scrollDirection == AxisC.vertical;
9698
final List<Widget> items = [
9799
for (int index = 0; index < itemCount; index++) ...[
@@ -128,11 +130,51 @@ class PassiveListViewWidget extends StatelessWidget {
128130
);
129131
}
130132

133+
/// Lays out all [itemCount] items of a wrapping list (see [listWraps]) in
134+
/// a [Wrap] along the scroll direction: items flow onto a new run when
135+
/// they don't fit, with the separator's spacing between items and
136+
/// [ListViewProperties.runSpacing] between runs, all aligned to the start.
137+
///
138+
/// The list is sized by its own fits: fixed takes its design size, expanded
139+
/// fills a bounded axis, and shrink-wrap hugs (along the scroll direction,
140+
/// the widest run; across it, the runs).
141+
Widget wrappedList(
142+
BuildContext context,
143+
int itemCount,
144+
IndexedWidgetBuilder itemBuilder,
145+
) {
146+
final List<Widget> items = [
147+
for (int index = 0; index < itemCount; index++)
148+
itemBuilder(context, index),
149+
];
150+
final Widget wrap = Padding(
151+
padding: node.padding.flutterEdgeInsets,
152+
child: Wrap(
153+
direction: node.scrollDirection.flutterAxis,
154+
spacing: listWrapSpacing(node),
155+
runSpacing: math.max(0, node.properties.runSpacing),
156+
children: node.reverse ? items.reversed.toList() : items,
157+
),
158+
);
159+
return FitBox(
160+
width: node.horizontalFit == SizeFit.shrinkWrap
161+
? null
162+
: node.basicBoxLocal.width,
163+
height: node.verticalFit == SizeFit.shrinkWrap
164+
? null
165+
: node.basicBoxLocal.height,
166+
fillWidth: node.horizontalFit == SizeFit.expanded,
167+
fillHeight: node.verticalFit == SizeFit.expanded,
168+
child: node.clipsContent ? ClipRect(child: wrap) : wrap,
169+
);
170+
}
171+
131172
/// Builds one item, placed as the editor lays it out; see [ListItemBox].
132173
Widget buildItem(BaseNode item, BuildContext context) => ListItemBox(
133174
item: item,
134175
scrollDirection: node.scrollDirection,
135176
itemExtent: node.properties.itemExtent,
177+
inWrap: listWraps(node),
136178
child: manager.buildWidgetByID(item.id, context, settings: settings),
137179
);
138180

‎lib/src/transformers/utils/viewport_sizing.dart‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ class ViewportNodeBox extends StatelessWidget {
177177
/// * Scroll axis: with [itemExtent] the item fills the extent, as in the
178178
/// editor. Otherwise an expanded or flexible item, which has nothing to
179179
/// fill along an endless axis, takes its design extent.
180+
///
181+
/// In a wrapping list ([inWrap]) the item is not aligned across the list:
182+
/// it sits in its run at its own size, as [Wrap] places it.
180183
class ListItemBox extends StatelessWidget {
181184
/// The item node.
182185
final BaseNode item;
@@ -190,13 +193,17 @@ class ListItemBox extends StatelessWidget {
190193
/// The built item.
191194
final Widget child;
192195

196+
/// Whether the item is a child of a wrapping list's [Wrap].
197+
final bool inWrap;
198+
193199
/// Creates a [ListItemBox].
194200
const ListItemBox({
195201
super.key,
196202
required this.item,
197203
required this.scrollDirection,
198204
required this.itemExtent,
199205
required this.child,
206+
this.inWrap = false,
200207
});
201208

202209
@override
@@ -217,6 +224,7 @@ class ListItemBox extends StatelessWidget {
217224
child: result,
218225
);
219226
}
227+
if (inWrap) return result;
220228
return Align(
221229
alignment: vertical ? Alignment.topCenter : Alignment.centerLeft,
222230
child: result,
@@ -300,20 +308,41 @@ class DesignCellGridDelegate extends SliverGridDelegate {
300308
oldDelegate.mainAxisExtent != mainAxisExtent;
301309
}
302310

303-
/// Whether list [node] builds all its items up front, as a plain [Flex]
304-
/// rather than a viewport: it doesn't scroll, hugs its items along its axis
305-
/// and has a known item count (bound data or a set count). Such a list builds
306-
/// every item either way, so it needs no viewport.
311+
/// Whether list [node] has a known item count: bound data or a set count,
312+
/// and no cloud query (whose length is only known once it loads, page by
313+
/// page).
314+
bool listHasKnownItemCount(ListViewNode node) =>
315+
!node.useCloudDatabase &&
316+
(node.variables['data'] != null || node.properties.itemCount != null);
317+
318+
/// Whether list [node] lays its items out in a [Wrap]: it wraps
319+
/// ([ListViewProperties.wrap]) and has a known item count. Such a list builds
320+
/// all its items and never scrolls, whatever its scroll settings. A list
321+
/// with an unknown count ignores [ListViewProperties.wrap] and scrolls.
322+
bool listWraps(ListViewNode node) =>
323+
node.properties.wrap && listHasKnownItemCount(node);
324+
325+
/// Whether list [node] builds all its items up front, as a plain [Flex] or a
326+
/// [Wrap] rather than a viewport: it wraps (see [listWraps]), or it doesn't
327+
/// scroll, hugs its items along its axis and has a known item count (bound
328+
/// data or a set count). Such a list builds every item either way, so it
329+
/// needs no viewport.
307330
bool listBuildsAllItems(ListViewNode node) {
331+
if (listWraps(node)) return true;
308332
final SizeFit mainFit = node.scrollDirection == AxisC.vertical
309333
? node.verticalFit
310334
: node.horizontalFit;
311335
return !node.isScrollable &&
312336
mainFit == SizeFit.shrinkWrap &&
313-
!node.useCloudDatabase &&
314-
(node.variables['data'] != null || node.properties.itemCount != null);
337+
listHasKnownItemCount(node);
315338
}
316339

340+
/// The gap between consecutive items of a wrapping list: the separator's
341+
/// spacing when it has one. A wrapping list draws no dividers.
342+
double listWrapSpacing(ListViewNode node) => node.properties.hasSeparator
343+
? math.max(0, node.properties.separatorSpacing)
344+
: 0;
345+
317346
/// Whether [node] or any node below it builds a widget that cannot report
318347
/// intrinsic sizes: a list, grid or page view (a viewport behind a
319348
/// [LayoutBuilder]), a scrolling row, column or frame (a [LayoutBuilder]

‎test/list_view_wrap_test.dart‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
6+
import 'viewport_layout_test.dart' as vp;
7+
8+
/// A horizontal list of [itemCount] 100x50 items that wraps, 14 apart with
9+
/// 6 between runs, and hugs its height.
10+
ListViewNode wrappingList({
11+
double width = 250,
12+
int? itemCount = 4,
13+
SizeFit horizontalFit = SizeFit.fixed,
14+
bool hasSeparator = true,
15+
}) {
16+
final ListViewNode list = vp.list(
17+
width: width,
18+
height: 50,
19+
itemCount: itemCount,
20+
horizontalFit: horizontalFit,
21+
verticalFit: SizeFit.shrinkWrap,
22+
scrollDirection: AxisC.horizontal,
23+
);
24+
list.properties
25+
..wrap = true
26+
..hasSeparator = hasSeparator
27+
..separatorSpacing = 14
28+
..runSpacing = 6;
29+
return list;
30+
}
31+
32+
List<Rect> itemRects(WidgetTester tester) => [
33+
for (final Element element in vp.rectOf('item').evaluate())
34+
tester.getRect(find.byWidget(element.widget).first),
35+
];
36+
37+
void main() {
38+
test('a wrapping list with a known count builds all its items', () {
39+
final ListViewNode list = wrappingList();
40+
expect(list.isScrollable, isTrue);
41+
expect(listWraps(list), isTrue);
42+
expect(listBuildsAllItems(list), isTrue);
43+
expect(listWrapSpacing(list), 14);
44+
45+
list.properties.itemCount = null;
46+
expect(listWraps(list), isFalse, reason: 'endless list');
47+
expect(listBuildsAllItems(list), isFalse);
48+
49+
list
50+
..properties.itemCount = 3
51+
..useCloudDatabase = true;
52+
expect(listWraps(list), isFalse, reason: 'cloud query');
53+
});
54+
55+
testWidgets('a wrapping list keeps its items on one run when they fit', (
56+
tester,
57+
) async {
58+
await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList(
59+
width: 500,
60+
), [vp.rect('item', 'list')]);
61+
expect(find.byType(Wrap), findsOneWidget);
62+
expect(find.byType(ViewportNodeBox), findsNothing);
63+
final List<Rect> items = itemRects(tester);
64+
expect(items, hasLength(4));
65+
for (int i = 1; i < 4; i++) {
66+
expect(items[i].top, items[0].top);
67+
expect(items[i].left - items[i - 1].right, 14);
68+
}
69+
expect(tester.getSize(find.byType(Wrap)).height, 50);
70+
});
71+
72+
testWidgets('a wrapping list moves items that do not fit to a new run', (
73+
tester,
74+
) async {
75+
await vp.pump(tester, const Size(800, 800), vp.Host.loose, wrappingList(), [
76+
vp.rect('item', 'list'),
77+
]);
78+
final List<Rect> items = itemRects(tester);
79+
// 100 + 14 + 100 = 214 fits in 250, a third item does not.
80+
expect(items[1].top, items[0].top);
81+
expect(items[2].left, items[0].left);
82+
expect(items[2].top - items[0].bottom, 6);
83+
expect(items[3].top, items[2].top);
84+
expect(tester.getSize(find.byType(Wrap)), const Size(250, 106));
85+
});
86+
87+
testWidgets('an expanded wrapping list wraps at the width it gets', (
88+
tester,
89+
) async {
90+
await vp.pump(
91+
tester,
92+
const Size(250, 800),
93+
vp.Host.tight,
94+
wrappingList(width: 800, horizontalFit: SizeFit.expanded),
95+
[vp.rect('item', 'list')],
96+
);
97+
final List<Rect> items = itemRects(tester);
98+
expect(items[2].top - items[0].bottom, 6);
99+
});
100+
101+
testWidgets('without a separator the items touch', (tester) async {
102+
await vp.pump(
103+
tester,
104+
const Size(800, 800),
105+
vp.Host.loose,
106+
wrappingList(width: 500, hasSeparator: false),
107+
[vp.rect('item', 'list')],
108+
);
109+
final List<Rect> items = itemRects(tester);
110+
expect(items[1].left, items[0].right);
111+
});
112+
113+
testWidgets('a wrapping list with no count scrolls as before', (
114+
tester,
115+
) async {
116+
await vp.pump(
117+
tester,
118+
const Size(800, 800),
119+
vp.Host.loose,
120+
wrappingList(itemCount: null)..verticalFit = SizeFit.fixed,
121+
[vp.rect('item', 'list')],
122+
);
123+
expect(find.byType(Wrap), findsNothing);
124+
expect(find.byType(ViewportNodeBox), findsOneWidget);
125+
});
126+
}

0 commit comments

Comments
 (0)