fix(app): a sheet's outlines and scrollbar stay clear of its edges

Found on the laptop: the outlines at a sheet's left (a text field, the
round buttons) were shaved by half a pixel, and the scrollbar lay over
the fields at the right. The sheet's room at its sides was around its
scroll view, so the body's outlines lay on the scroll view's edge: a
scroll view cuts what it holds there, an outline's outer, antialiased
half pixel included, which shows wherever the edge falls between two
pixels, as with a dialog centred in a window of odd width; and its
scrollbar runs along that edge. The room now lies inside the scroll
view, and the title and the footer keep their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 11:46:21 +02:00
commit c9faaf99c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 58 additions and 9 deletions

View file

@ -140,24 +140,40 @@ class const SheetScaffold({
final presentation = _Presentation.at(context); final presentation = _Presentation.at(context);
final isDialog = presentation.isDialog; final isDialog = presentation.isDialog;
final heightFactor = this.heightFactor; final heightFactor = this.heightFactor;
// The sides' room goes inside the scroll view, not around it: a scroll
// view cuts what it holds at its edges, outlines' outer half pixel
// included, and its scrollbar runs along its edge.
final side = isDialog ? 24.0 : 20.0;
Widget inset(Widget child) => Padding(
padding: EdgeInsets.symmetric(horizontal: side),
child: child,
);
final column = Column( final column = Column(
mainAxisSize: isDialog || heightFactor == null mainAxisSize: isDialog || heightFactor == null
? MainAxisSize.min ? MainAxisSize.min
: MainAxisSize.max, : MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_Header(title: title, action: action, onClose: onClose), inset(_Header(title: title, action: action, onClose: onClose)),
// Loose, so a short body keeps the sheet short; tight at a fixed // Loose, so a short body keeps the sheet short; tight at a fixed
// height, so the footer sits at the bottom. // height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null) if (!isDialog && heightFactor != null)
Expanded( Expanded(
child: _ScrollingBody(body: body, fill: true, footer: footer), child: _ScrollingBody(
body: body,
side: side,
fill: true,
footer: footer,
),
) )
else else
Flexible( Flexible(
child: _ScrollingBody(body: body, footer: footer), child: _ScrollingBody(body: body, side: side, footer: footer),
), ),
if (footer case final footer?) ...[const SizedBox(height: 12), footer], if (footer case final footer?) ...[
const SizedBox(height: 12),
inset(footer),
],
], ],
); );
final content = hasSnackBars final content = hasSnackBars
@ -179,16 +195,16 @@ class const SheetScaffold({
maxHeight: math.min(dialogMaxHeight, media.size.height - 64), maxHeight: math.min(dialogMaxHeight, media.size.height - 64),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), padding: const EdgeInsets.symmetric(vertical: 20),
child: content, child: content,
), ),
); );
} }
// A drag handle above gives the top its space. // A drag handle above gives the top its space.
final padding = EdgeInsets.fromLTRB( final padding = EdgeInsets.fromLTRB(
20, 0,
presentation.hasDragHandle ? 0 : 16, presentation.hasDragHandle ? 0 : 16,
20, 0,
16, 16,
); );
// What's left once the keyboard and the system's bottom inset are // What's left once the keyboard and the system's bottom inset are
@ -261,6 +277,9 @@ class const _Header({
class const _ScrollingBody({ class const _ScrollingBody({
required final Widget body, required final Widget body,
required final Widget? footer, required final Widget? footer,
/// The room at its sides, inside the scroll view.
required final double side,
final bool fill = false, final bool fill = false,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -300,8 +319,14 @@ class _ScrollingBodyState() extends State<_ScrollingBody> {
_ => false, _ => false,
}; };
// The lines keep their room while hidden, so the body doesn't jump. // The lines keep their room while hidden, so the body doesn't jump.
Widget line({required bool shown, required Key key}) => Widget line({required bool shown, required Key key}) => shown
shown ? Divider(key: key, height: 1) : const SizedBox(height: 1); ? Divider(
key: key,
height: 1,
indent: widget.side,
endIndent: widget.side,
)
: const SizedBox(height: 1);
final scrolling = NotificationListener<ScrollMetricsNotification>( final scrolling = NotificationListener<ScrollMetricsNotification>(
// Also when the body's size changes, as a section opens. // Also when the body's size changes, as a section opens.
onNotification: (notification) { onNotification: (notification) {
@ -324,6 +349,7 @@ class _ScrollingBodyState() extends State<_ScrollingBody> {
).copyWith(scrollbars: false), ).copyWith(scrollbars: false),
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _scroll, controller: _scroll,
padding: EdgeInsets.symmetric(horizontal: widget.side),
child: widget.body, child: widget.body,
), ),
), ),

View file

@ -167,4 +167,27 @@ void main() {
expect(find.byKey(SheetScaffold.moreBelowKey), findsNothing); expect(find.byKey(SheetScaffold.moreBelowKey), findsNothing);
expect(find.byKey(SheetScaffold.moreAboveKey), findsNothing); expect(find.byKey(SheetScaffold.moreAboveKey), findsNothing);
}); });
testWidgets("the body keeps clear of the scroll view's edges, where it is "
'cut and the scrollbar runs', (tester) async {
for (final width in [400.0, 1000.0]) {
await open(
tester,
width: width,
body: const TextField(
decoration: InputDecoration(border: OutlineInputBorder()),
),
);
final scrolling = tester.getRect(find.byType(SingleChildScrollView));
final field = tester.getRect(find.byType(TextField));
expect(field.left - scrolling.left, greaterThanOrEqualTo(16));
expect(scrolling.right - field.right, greaterThanOrEqualTo(16));
// The title and the button stay in line with the body.
expect(tester.getTopLeft(find.text('New studyset')).dx, field.left);
expect(tester.getTopRight(find.byType(FilledButton)).dx, field.right);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
}
});
} }