BottomSheet modal en Flutter: ejercicio resuelto

BottomSheet modal en Flutter: ejercicio resuelto

showModalBottomSheet es la forma estándar de mostrar paneles temporales desde la parte inferior: filtros, acciones contextuales, confirmaciones, selección de opciones. Es más natural en móvil que un AlertDialog centrado cuando la acción está relacionada con una lista o contenido debajo.

Enunciado

Implementa tres variantes de BottomSheet:

  1. BottomSheet básico: lista de acciones (compartir, editar, eliminar).
  2. BottomSheet con formulario: panel de filtros con switches y aplicar/cancelar.
  3. DraggableScrollableSheet: panel arrastrable con lista larga de ítems.

Dependencias

Solo Flutter SDK.

Solución: BottomSheet de acciones

 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: [
          // Handle visual
          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('Opciones', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
          ),
          const Divider(height: 1),
          _ActionTile(
            icon: Icons.share,
            label: 'Compartir',
            onTap: () => Navigator.pop(context, 'compartir'),
          ),
          _ActionTile(
            icon: Icons.edit,
            label: 'Editar',
            onTap: () => Navigator.pop(context, 'editar'),
          ),
          _ActionTile(
            icon: Icons.delete,
            label: 'Eliminar',
            color: Colors.red,
            onTap: () => Navigator.pop(context, 'eliminar'),
          ),
          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,
    );
  }
}

Solución: BottomSheet de filtros con formulario

  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, // permite altura custom
    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(
      // evita que el teclado tape el formulario
      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('Filtros', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
          ),
          SwitchListTile(
            title: const Text('Solo favoritos'),
            value: _onlyFavorites,
            onChanged: (v) => setState(() => _onlyFavorites = v),
          ),
          SwitchListTile(
            title: const Text('Solo publicados'),
            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: 'Ordenar por',
                border: OutlineInputBorder(),
              ),
              items: const [
                DropdownMenuItem(value: 'date', child: Text('Fecha')),
                DropdownMenuItem(value: 'title', child: Text('Título')),
                DropdownMenuItem(value: 'views', child: Text('Visualizaciones')),
              ],
              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('Cancelar'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: FilledButton(
                    onPressed: () => Navigator.pop(
                      context,
                      FilterOptions(
                        onlyFavorites: _onlyFavorites,
                        onlyPublished: _onlyPublished,
                        sortBy: _sortBy,
                      ),
                    ),
                    child: const Text('Aplicar'),
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 24),
        ],
      ),
    );
  }
}

Solución: 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% de la pantalla al abrir
      minChildSize: 0.25,      // mínimo 25%
      maxChildSize: 0.95,      // máximo 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('Todos los ítems', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
            ),
            Expanded(
              child: ListView.builder(
                controller: scrollController, // necesario para integrar con el drag
                itemCount: 40,
                itemBuilder: (_, i) => ListTile(
                  leading: const Icon(Icons.article_outlined),
                  title: Text('Ítem ${i + 1}'),
                  subtitle: Text('Descripción del ítem ${i + 1}'),
                ),
              ),
            ),
          ],
        ),
      ),
    ),
  );
}

Pantalla principal

 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('Última acción: $_lastAction'),
            const SizedBox(height: 8),
            Text('Filtros: favoritos=${_filters.onlyFavorites}, orden=${_filters.sortBy}'),
            const SizedBox(height: 32),
            ElevatedButton(
              onPressed: () async {
                final action = await showActionsSheet(context);
                if (action != null) setState(() => _lastAction = action);
              },
              child: const Text('Sheet de acciones'),
            ),
            const SizedBox(height: 12),
            ElevatedButton(
              onPressed: () async {
                final result = await showFiltersSheet(context, initial: _filters);
                if (result != null) setState(() => _filters = result);
              },
              child: const Text('Sheet de filtros'),
            ),
            const SizedBox(height: 12),
            ElevatedButton(
              onPressed: () => showDraggableSheet(context),
              child: const Text('DraggableScrollableSheet'),
            ),
          ],
        ),
      ),
    );
  }
}

Errores frecuentes

  • isScrollControlled: false con formulario: el sheet tiene altura fija y el teclado lo tapa. Usa siempre isScrollControlled: true cuando haya campos de texto.
  • DraggableScrollableSheet sin pasar scrollController al ListView: el drag y el scroll entran en conflicto. Pasa siempre el controller del builder.
  • Cerrar el sheet con Navigator.pop(context) pasando un valor: el tipo del valor debe coincidir con el genérico showModalBottomSheet<T>, si no el resultado es null.

Aplicación práctica

Filtros de búsqueda, menús de acciones contextuales, confirmación de eliminación, selección de imagen/archivo, pago en carrito — cualquier flujo secundario que no merece una ruta completa.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Cómo evito que el sheet se cierre al pulsar fuera?

Pasa isDismissible: false a showModalBottomSheet. Útil para confirmaciones críticas o formularios con datos no guardados.

¿Puedo abrir un BottomSheet desde otro BottomSheet?

Técnicamente sí, pero la UX es confusa. En su lugar usa un Navigator.push dentro del sheet para ir a una pantalla completa si necesitas más profundidad.

¿Qué diferencia hay entre BottomSheet modal y persistente?

El modal bloquea la interacción con el resto de la pantalla (showModalBottomSheet). El persistente convive con la pantalla debajo (Scaffold.showBottomSheet). Para la mayoría de casos, el modal es la elección correcta.