Modal BottomSheet in Flutter: solved exercise

showModalBottomSheet is the standard way to display temporary panels from the bottom of the screen: filters, contextual actions, confirmations, option selectors. It feels more natural on mobile than a centered AlertDialog when the action is related to a list or content below.

Problem statement

Implement three BottomSheet variants:

  1. Basic BottomSheet: an action list (share, edit, delete).
  2. BottomSheet with a form: a filter panel with switches and apply/cancel buttons.
  3. DraggableScrollableSheet: a draggable panel with a long scrollable list.

Dependencies

Flutter SDK only.

Solution: Actions BottomSheet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// lib/sheets/actions_sheet.dart
import 'package:flutter/material.dart';

Future<String?> showActionsSheet(BuildContext context) {
  return showModalBottomSheet<String>(
    context: context,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
    ),
    builder: (_) => SafeArea(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          // Visual handle
          Container(
            margin: const EdgeInsets.symmetric(vertical: 8),
            width: 40,
            height: 4,
            decoration: BoxDecoration(
              color: Colors.grey.shade300,
              borderRadius: BorderRadius.circular(2),
            ),
          ),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text('Options', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
          ),
          const Divider(height: 1),
          _ActionTile(
            icon: Icons.share,
            label: 'Share',
            onTap: () => Navigator.pop(context, 'share'),
          ),
          _ActionTile(
            icon: Icons.edit,
            label: 'Edit',
            onTap: () => Navigator.pop(context, 'edit'),
          ),
          _ActionTile(
            icon: Icons.delete,
            label: 'Delete',
            color: Colors.red,
            onTap: () => Navigator.pop(context, 'delete'),
          ),
          const SizedBox(height: 8),
        ],
      ),
    ),
  );
}

class _ActionTile extends StatelessWidget {
  final IconData icon;
  final String label;
  final VoidCallback onTap;
  final Color? color;

  const _ActionTile({
    required this.icon,
    required this.label,
    required this.onTap,
    this.color,
  });

  @override
  Widget build(BuildContext context) {
    final effectiveColor = color ?? Theme.of(context).textTheme.bodyLarge?.color;
    return ListTile(
      leading: Icon(icon, color: effectiveColor),
      title: Text(label, style: TextStyle(color: effectiveColor)),
      onTap: onTap,
    );
  }
}

Solution: Filters BottomSheet with form

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// lib/sheets/filters_sheet.dart
import 'package:flutter/material.dart';

class FilterOptions {
  final bool onlyFavorites;
  final bool onlyPublished;
  final String sortBy;

  const FilterOptions({
    this.onlyFavorites = false,
    this.onlyPublished = true,
    this.sortBy = 'date',
  });
}

Future<FilterOptions?> showFiltersSheet(
  BuildContext context, {
  FilterOptions initial = const FilterOptions(),
}) {
  return showModalBottomSheet<FilterOptions>(
    context: context,
    isScrollControlled: true, // allows custom height
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (_) => _FiltersSheet(initial: initial),
  );
}

class _FiltersSheet extends StatefulWidget {
  final FilterOptions initial;
  const _FiltersSheet({required this.initial});

  @override
  State<_FiltersSheet> createState() => _FiltersSheetState();
}

class _FiltersSheetState extends State<_FiltersSheet> {
  late bool _onlyFavorites;
  late bool _onlyPublished;
  late String _sortBy;

  @override
  void initState() {
    super.initState();
    _onlyFavorites = widget.initial.onlyFavorites;
    _onlyPublished = widget.initial.onlyPublished;
    _sortBy = widget.initial.sortBy;
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      // prevent keyboard from covering the form
      padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const SizedBox(height: 12),
          Center(
            child: Container(
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: Colors.grey.shade300,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
          ),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text('Filters', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
          ),
          SwitchListTile(
            title: const Text('Favorites only'),
            value: _onlyFavorites,
            onChanged: (v) => setState(() => _onlyFavorites = v),
          ),
          SwitchListTile(
            title: const Text('Published only'),
            value: _onlyPublished,
            onChanged: (v) => setState(() => _onlyPublished = v),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: DropdownButtonFormField<String>(
              value: _sortBy,
              decoration: const InputDecoration(
                labelText: 'Sort by',
                border: OutlineInputBorder(),
              ),
              items: const [
                DropdownMenuItem(value: 'date', child: Text('Date')),
                DropdownMenuItem(value: 'title', child: Text('Title')),
                DropdownMenuItem(value: 'views', child: Text('Views')),
              ],
              onChanged: (v) => setState(() => _sortBy = v!),
            ),
          ),
          const SizedBox(height: 16),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                Expanded(
                  child: OutlinedButton(
                    onPressed: () => Navigator.pop(context),
                    child: const Text('Cancel'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: FilledButton(
                    onPressed: () => Navigator.pop(
                      context,
                      FilterOptions(
                        onlyFavorites: _onlyFavorites,
                        onlyPublished: _onlyPublished,
                        sortBy: _sortBy,
                      ),
                    ),
                    child: const Text('Apply'),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 24),
        ],
      ),
    );
  }
}

Solution: DraggableScrollableSheet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// lib/sheets/draggable_sheet.dart
import 'package:flutter/material.dart';

void showDraggableSheet(BuildContext context) {
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    backgroundColor: Colors.transparent,
    builder: (_) => DraggableScrollableSheet(
      initialChildSize: 0.5,   // 50% of screen height on open
      minChildSize: 0.25,      // minimum 25%
      maxChildSize: 0.95,      // maximum 95%
      expand: false,
      builder: (_, scrollController) => Container(
        decoration: const BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
        ),
        child: Column(
          children: [
            Container(
              margin: const EdgeInsets.symmetric(vertical: 8),
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                color: Colors.grey.shade300,
                borderRadius: BorderRadius.circular(2),
              ),
            ),
            const Padding(
              padding: EdgeInsets.all(12),
              child: Text('All items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
            ),
            Expanded(
              child: ListView.builder(
                controller: scrollController, // required to integrate with drag
                itemCount: 40,
                itemBuilder: (_, i) => ListTile(
                  leading: const Icon(Icons.article_outlined),
                  title: Text('Item ${i + 1}'),
                  subtitle: Text('Description of item ${i + 1}'),
                ),
              ),
            ),
          ],
        ),
      ),
    ),
  );
}

Main screen

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// lib/main.dart
import 'package:flutter/material.dart';
// import 'sheets/actions_sheet.dart';
// import 'sheets/filters_sheet.dart';
// import 'sheets/draggable_sheet.dart';

void main() => runApp(const MaterialApp(home: BottomSheetDemo()));

class BottomSheetDemo extends StatefulWidget {
  const BottomSheetDemo({super.key});
  @override
  State<BottomSheetDemo> createState() => _BottomSheetDemoState();
}

class _BottomSheetDemoState extends State<BottomSheetDemo> {
  String _lastAction = '—';
  FilterOptions _filters = const FilterOptions();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BottomSheet Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Last action: $_lastAction'),
            const SizedBox(height: 8),
            Text('Filters: favorites=${_filters.onlyFavorites}, sort=${_filters.sortBy}'),
            const SizedBox(height: 32),
            ElevatedButton(
              onPressed: () async {
                final action = await showActionsSheet(context);
                if (action != null) setState(() => _lastAction = action);
              },
              child: const Text('Actions sheet'),
            ),
            const SizedBox(height: 12),
            ElevatedButton(
              onPressed: () async {
                final result = await showFiltersSheet(context, initial: _filters);
                if (result != null) setState(() => _filters = result);
              },
              child: const Text('Filters sheet'),
            ),
            const SizedBox(height: 12),
            ElevatedButton(
              onPressed: () => showDraggableSheet(context),
              child: const Text('DraggableScrollableSheet'),
            ),
          ],
        ),
      ),
    );
  }
}

Common mistakes

  • isScrollControlled: false with a text field: the sheet has a fixed height and the keyboard covers it. Always use isScrollControlled: true when the sheet contains text inputs.
  • DraggableScrollableSheet without passing scrollController to the ListView: drag and scroll conflict with each other. Always pass the builder’s controller.
  • Navigator.pop(context, value) with a type mismatch: the value type must match the generic of showModalBottomSheet<T>; otherwise the result is null.

Practical use

Search filters, contextual action menus, delete confirmations, image/file pickers, checkout flows — any secondary workflow that does not deserve a full route.

Guided practice and next step

FAQ

How do I prevent the sheet from closing when tapping outside?

Pass isDismissible: false to showModalBottomSheet. Useful for critical confirmations or forms with unsaved data.

Can I open a BottomSheet from another BottomSheet?

Technically yes, but the UX is confusing. Instead, use Navigator.push inside the sheet to go to a full screen if you need deeper navigation.

What is the difference between a modal and a persistent BottomSheet?

The modal blocks interaction with the rest of the screen (showModalBottomSheet). The persistent one coexists with the screen below (Scaffold.showBottomSheet). For most use cases, the modal is the right choice.