Flutter Pro Design

Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Don't use SafeArea with scrollable widgets

See categories page of expen.app:

Normal ListView

It looks fine. But scroll to the end, and the button is stuck to the bottom with no breathing room, and the bottom system bar overlaps it:

Problem with ListView

First thought: "That's why SafeArea exists!" Wrap the content with it:

ListView wrapped with SafeArea

Problem looks fixed now. But no, it isn't. Sure, that fixed the button sticking to the bottom, but scroll the page now and it becomes clear: SafeArea just told the list "Hey, don't render your items after this line, OK?!" So, category items get cut off. But that doesn't look good:

ListView wrapped with SafeArea

Remove SafeArea and rethink. The device has a system bar at the bottom, and the goal is simple: breathing room only at the bottom, so content doesn't stick to the edge. The solution: add bottom padding in the size of that system bar, right?

Meet BottomPadding:

import 'package:flutter/widgets.dart';
  
class BottomPadding extends StatelessWidget {
  const BottomPadding({super.key});
  
  static double of(
    BuildContext context, {
    double minimum = 16,
  }) {
    // Get size of the bottom system bar
    final double viewPadding = MediaQuery.viewPaddingOf(context).bottom;
  
    // Use the system bar size if it's bigger than the minimum,
    // otherwise fall back to the minimum.
    final double height = viewPadding > minimum ? viewPadding : minimum;
  
    return height;
  }
  
  @override
  Widget build(BuildContext context) {
    final double height = of(context);
  
    // Return a box of that height.
    return SizedBox(height: height);
  }
}

BottomPadding does more than expected. An example:

return Scrollbar(
    child: SingleChildScrollView(
        padding: .symmetric(horizontal: 24),
        child: Column(
            children: <Widget>[
                SizedBox(height: 16),
                CategoriesList(),
                SizedBox(height: 24),
                Button(
                    label: 'new.',
                    onPressed: () {},
                ),
                const BottomPadding(),
            ],
        ),
    ),
);

Here, BottomPadding sits as the last child of the Column, and it helps in two cases: (1) it gives breathing space on devices with a bottom system bar; (2) if there isn't one, it just adds a 16px box, so content still doesn't hit the bottom edge. Perfect!

Or use BottomPadding.of(context) directly:

ListView(
    padding: .only(
        bottom: BottomPadding.of(context),
    ),
),

Same thing.

A fair question: "Why not just use padding: .all(24)? It'd add enough padding to all sides to avoid system insets." Because the bottom system bar isn't always 24px. BottomPadding calculates it dynamically. The two combine nicely:

return Scrollbar(
    child: SingleChildScrollView(
        padding: .all(24).copyWith(
            bottom: BottomPadding.of(context, minimum: 24),
        ),
    ),
);

SafeArea shrinks the viewport. BottomPadding adds breathing room.