Skip to content

Commit e6a4f61

Browse files
committed
Let dialogs follow the screen and add loose flexible sizing
- A dialog whose responsive canvas holds a responsive layout (a variance node with breakpoints) fills the screen, so its variant follows the screen width: a full-screen sheet on mobile, a centered card on desktop. Plain fixed-size dialog canvases keep their design size. - In a dialog, taps where nothing paints or reacts (a canvas without a background, an empty frame) reach the barrier, so a dismissible full-screen dialog closes on them. - A squeezable shrink-wrapped node in a Row/Column is wrapped in a loose Flexible: it hugs its content and shrinks to the room left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4
1 parent 5f312bb commit e6a4f61

7 files changed

Lines changed: 342 additions & 9 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77
`LineChartGeometry` holds the pure parts (dates, domain, interpolation,
88
Catmull-Rom control points, readout text and placement).
99
- `FormulaEvaluator.formatDate` exposes the `formatDate` formula function.
10+
- A dialog whose canvas is responsive and holds a responsive layout (a
11+
`VarianceNode` with breakpoints) fills the screen instead of keeping its
12+
design size, so its variant follows the screen width and each variant
13+
places itself (a full-screen sheet on mobile, a centered card on desktop).
14+
Dialogs with plain fixed-size canvases are unchanged.
15+
- In a dialog (`TapThroughScope`), taps on parts of the layout where nothing
16+
paints or reacts (a canvas without a background, an empty frame) reach the
17+
barrier, so a dismissible full-screen dialog closes on them.
18+
- A `squeezable` node that wraps its content on a Row/Column's main axis is
19+
laid out as `Flexible(fit: FlexFit.loose)`: it hugs its content and
20+
shrinks to the room left when the Row/Column runs out of space.
1021

1122
## 8.1.0
1223

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,15 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer<CanvasNode> {
195195
body: body,
196196
);
197197
} else {
198+
final Color? background = retrieveBackgroundColor(context, node);
199+
// Under a TapThroughScope, a canvas without a background takes no taps
200+
// on its empty parts, so they reach its dialog's barrier.
201+
final bool transparent =
202+
(background == null || background.a == 0) &&
203+
TapThroughScope.of(context);
198204
scaffold = Material(
199-
color: retrieveBackgroundColor(context, node),
205+
type: transparent ? MaterialType.transparency : MaterialType.canvas,
206+
color: transparent ? null : background,
200207
child: body,
201208
);
202209
}
@@ -377,6 +384,9 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer<CanvasNode> {
377384
settings: settings,
378385
builder: (context) => CanvasViewport(
379386
node: node,
387+
followsScreen:
388+
node.scaleMode == ScaleMode.responsive &&
389+
hasResponsiveLayout(node, getNode),
380390
child: switch (node.scaleMode) {
381391
ScaleMode.responsive => _wrapInScaffoldForResponsive(
382392
context: context,
@@ -393,6 +403,20 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer<CanvasNode> {
393403
);
394404
}
395405

406+
/// Whether [node] or a node under it is a responsive layout (a
407+
/// [VarianceNode] whose variants map to breakpoints), in any variant.
408+
static bool hasResponsiveLayout(BaseNode node, GetNode getNode) {
409+
if (node is VarianceNode) {
410+
if (node.isResponsive) return true;
411+
return node.allVariantsChildren.any(
412+
(id) => hasResponsiveLayout(getNode(id), getNode),
413+
);
414+
}
415+
return node.childrenOrEmpty.any(
416+
(id) => hasResponsiveLayout(getNode(id), getNode),
417+
);
418+
}
419+
396420
static Color? retrieveBackgroundColor(BuildContext context, CanvasNode node) {
397421
final fills = <PaintModel>[
398422
for (final fill in node.fills)
@@ -439,6 +463,13 @@ enum CanvasSizing {
439463
/// The canvas keeps the size it was designed at, within the space it is
440464
/// given. Used where the host only centers the layout and lets it choose
441465
/// its size, such as a dialog.
466+
///
467+
/// A responsive canvas with a responsive layout in it (see
468+
/// [PassiveCanvasTransformer.hasResponsiveLayout]) fills the space
469+
/// instead, as with [fill]: its variant follows the screen, and each
470+
/// variant places itself in it (a full-screen sheet on a phone, a
471+
/// centered card on a desktop). Give such a canvas no background so the
472+
/// barrier shows around what its layout draws.
442473
design,
443474
}
444475

@@ -462,6 +493,22 @@ class CanvasSizingScope extends InheritedWidget {
462493
sizing != oldWidget.sizing;
463494
}
464495

496+
/// Makes the empty parts of the layout below let taps through to what is
497+
/// behind it: the parts where neither a node's fills, strokes or content
498+
/// paint nor a reactive node is (a transparent canvas, an empty frame). Set
499+
/// by `CodelesslyDialogWidget`, so a tap beside a full-screen dialog's
500+
/// layout reaches the barrier and closes a dismissible dialog.
501+
class TapThroughScope extends InheritedWidget {
502+
const TapThroughScope({super.key, required super.child});
503+
504+
/// Whether a [TapThroughScope] is above [context].
505+
static bool of(BuildContext context) =>
506+
context.getInheritedWidgetOfExactType<TapThroughScope>() != null;
507+
508+
@override
509+
bool updateShouldNotify(TapThroughScope oldWidget) => false;
510+
}
511+
465512
/// The width the canvas above is laid out at, which picks the variant a
466513
/// responsive [VarianceNode] shows (see [VarianceNode.variantForWidth]), the
467514
/// same width the editor's design canvas has.
@@ -495,9 +542,19 @@ class CanvasWidthScope extends InheritedWidget {
495542
/// on an unbounded axis it gets its design size (see [CanvasSizing.fill]).
496543
class CanvasViewport extends StatelessWidget {
497544
final CanvasNode node;
545+
546+
/// Whether the canvas fills the space under [CanvasSizing.design] too: a
547+
/// responsive canvas that holds a responsive layout.
548+
final bool followsScreen;
549+
498550
final Widget child;
499551

500-
const CanvasViewport({super.key, required this.node, required this.child});
552+
const CanvasViewport({
553+
super.key,
554+
required this.node,
555+
this.followsScreen = false,
556+
required this.child,
557+
});
501558

502559
/// The canvas box for [constraints] under [sizing], per axis. A null
503560
/// height leaves the height to the canvas content (see
@@ -540,7 +597,9 @@ class CanvasViewport extends StatelessWidget {
540597

541598
@override
542599
Widget build(BuildContext context) {
543-
final CanvasSizing sizing = CanvasSizingScope.of(context);
600+
final CanvasSizing sizing = followsScreen
601+
? CanvasSizing.fill
602+
: CanvasSizingScope.of(context);
544603
return LayoutBuilder(
545604
builder: (context, constraints) {
546605
final box = resolve(node, constraints, sizing: sizing);

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,33 @@ class PassiveRectangleWidget extends StatelessWidget {
158158
size: node.basicBoxLocal.size,
159159
child: Container(
160160
clipBehavior: getClipBehavior(node),
161-
decoration: BoxDecoration(
162-
boxShadow: retrieveBoxShadow(node, scopedValues),
163-
borderRadius: getBorderRadius(node),
164-
),
161+
// This box only clips and casts shadows; the node's fills, strokes
162+
// and children take taps where they paint. Under a TapThroughScope,
163+
// its empty parts let taps through.
164+
decoration: TapThroughScope.of(context)
165+
? _TapThroughDecoration(
166+
boxShadow: retrieveBoxShadow(node, scopedValues),
167+
borderRadius: getBorderRadius(node),
168+
)
169+
: BoxDecoration(
170+
boxShadow: retrieveBoxShadow(node, scopedValues),
171+
borderRadius: getBorderRadius(node),
172+
),
165173
child: child,
166174
),
167175
);
168176
}
169177
}
170178

179+
/// A [BoxDecoration] that takes no taps itself.
180+
class _TapThroughDecoration extends BoxDecoration {
181+
const _TapThroughDecoration({super.boxShadow, super.borderRadius});
182+
183+
@override
184+
bool hitTest(Size size, Offset position, {TextDirection? textDirection}) =>
185+
false;
186+
}
187+
171188
List<Widget> wrapWithInkWell(
172189
BuildContext context,
173190
BaseNode node,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer<RowColumnNode> {
315315
}
316316
}
317317

318+
// A squeezable node that wraps its content hugs it while it fits and
319+
// shrinks to the room the Row/Column has left when it doesn't.
320+
if (mainAxisFit == SizeFit.shrinkWrap && node.squeezable) {
321+
childWidget = Flexible(flex: flex, child: childWidget);
322+
}
323+
318324
return childWidget;
319325
}
320326

‎lib/src/ui/codelessly_dialog_widget.dart‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ class CodelesslyDialogWidget extends StatelessWidget {
1616
@override
1717
Widget build(BuildContext context) {
1818
// A dialog is centered and sized by its layout, so its canvas keeps its
19-
// design size instead of filling the screen.
19+
// design size instead of filling the screen, unless it holds a
20+
// responsive layout (see CanvasSizing.design). Taps on its empty parts
21+
// reach the barrier.
2022
return CanvasSizingScope(
2123
sizing: CanvasSizing.design,
22-
child: Builder(builder: _buildDialog),
24+
child: TapThroughScope(child: Builder(builder: _buildDialog)),
2325
);
2426
}
2527

‎test/flex_layout_test.dart‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,4 +640,117 @@ void main() {
640640
expect(sizes['c']!.width, 100);
641641
});
642642
});
643+
644+
group('a squeezable shrinkWrap child (loose Flexible)', () {
645+
TextNode label(String id, String characters, {bool squeezable = false}) =>
646+
TextNode(
647+
id: id,
648+
name: id,
649+
basicBoxLocal: NodeBox(0, 0, 120, 10),
650+
horizontalFit: SizeFit.shrinkWrap,
651+
verticalFit: SizeFit.shrinkWrap,
652+
characters: characters,
653+
maxLines: 1,
654+
overflow: TextOverflowC.ellipsis,
655+
textMixedProps: [
656+
StartEndProp(start: 0, end: characters.length, fontSize: 10),
657+
],
658+
)..squeezable = squeezable;
659+
660+
RenderParagraph paragraphOf(WidgetTester tester, String characters) =>
661+
tester.renderObject(
662+
find.byWidgetPredicate(
663+
(widget) =>
664+
widget is RichText && widget.text.toPlainText() == characters,
665+
),
666+
);
667+
668+
// A name and its count in a 200 wide row; the test font is 10 px a
669+
// character.
670+
Future<void> pumpRow(WidgetTester tester, String characters, Size screen) {
671+
final name = label('name', characters, squeezable: true);
672+
final count = label('count', 'x3');
673+
final row = flex(
674+
'row',
675+
kRootNode,
676+
[name, count],
677+
type: RowColumnType.row,
678+
width: 200,
679+
height: 10,
680+
);
681+
return pump(tester, row, [name, count], screen: screen, host: Host.loose);
682+
}
683+
684+
for (final screen in screens) {
685+
testWidgets('hugs a short name and ellipsizes a long one at '
686+
'${screen.width.toInt()}', (tester) async {
687+
await pumpRow(tester, 'Ann', screen);
688+
final RenderParagraph short = paragraphOf(tester, 'Ann');
689+
expect(short.size.width, 30);
690+
expect(short.didExceedMaxLines, isFalse);
691+
expect(
692+
tester.getTopLeft(find.text('x3')).dx,
693+
tester.getTopRight(find.text('Ann')).dx,
694+
);
695+
696+
const String long = 'Bartholomew Montgomeryfitzgerald';
697+
await pumpRow(tester, long, screen);
698+
final RenderParagraph name = paragraphOf(tester, long);
699+
// The name gives up what the count needs, and ellipsizes.
700+
expect(name.size.width, 180);
701+
expect(name.didExceedMaxLines, isTrue);
702+
expect(paragraphOf(tester, 'x3').size.width, 20);
703+
expect(
704+
tester.getTopRight(find.text('x3')).dx,
705+
tester.getTopRight(rectOf('row')).dx,
706+
);
707+
});
708+
}
709+
710+
// A dialog body: a header and a card that is as tall as its content, up
711+
// to the height the column has left.
712+
testWidgets('a card hugs its content up to the room left', (tester) async {
713+
for (final int items in [1, 6]) {
714+
final header = rect('header', '', width: 300, height: 50);
715+
final children = [
716+
for (int i = 0; i < items; i++)
717+
rect('item$i', '', width: 300, height: 100),
718+
];
719+
final card = flex(
720+
'card',
721+
'',
722+
children,
723+
type: RowColumnType.column,
724+
width: 300,
725+
height: 100,
726+
verticalFit: SizeFit.shrinkWrap,
727+
isScrollable: true,
728+
)..squeezable = true;
729+
final column = flex(
730+
'column',
731+
kRootNode,
732+
[header, card],
733+
type: RowColumnType.column,
734+
width: 300,
735+
height: 400,
736+
);
737+
for (final screen in screens) {
738+
final sizes = await pump(
739+
tester,
740+
column,
741+
[header, card, ...children],
742+
screen: screen,
743+
host: Host.loose,
744+
);
745+
expect(sizes['column'], const Size(300, 400));
746+
expect(
747+
sizes['card'],
748+
Size(300, items == 1 ? 100 : 350),
749+
reason: '$items items at $screen',
750+
);
751+
expect(sizes['item0'], const Size(300, 100));
752+
}
753+
}
754+
});
755+
});
643756
}

0 commit comments

Comments
 (0)