İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: BottomSheet und showModalBottomSheet

Ahmet Balaman

9 Min. Lesezeit

FlutterBottomSheetshowModalBottomSheetDraggableScrollableSheetMaterial 3UI
Flutter: BottomSheet und showModalBottomSheet

Ein Bottom Sheet ist ein Panel, das vom unteren Bildschirmrand hochgleitet: die Teilen-Optionen eines Fotos, die Filter einer Liste, ein kurzes Kommentarformular. Weil es nah am Daumen liegt, ist es auf dem Handy angenehmer als ein Dialog und verdeckt den Kontext der Seite nicht vollständig. Flutter kennt zwei Arten: Ein modales Sheet dunkelt die Seite dahinter ab, blockiert die Interaktion, bis es geschlossen wird, und kann einen Wert zurückgeben; ein persistentes Sheet verhält sich wie ein Teil der Seite, und die Interaktion mit dem Inhalt darunter geht weiter. In diesem Beitrag geht es um beide Arten, um Höhen- und Tastaturprobleme, das ziehbare Sheet und die häufigsten Fehler.

Grundlagen mit showModalBottomSheet

Am häufigsten wird das modale Sheet verwendet. showModalBottomSheet liefert ein Future; schließt sich das Sheet mit Navigator.pop(context, wert), kommt dieser Wert zurück:

import 'package:flutter/material.dart';

enum PhotoAction { share, copyLink, delete }

class PhotoPage extends StatefulWidget {
  const PhotoPage({super.key});

  @override
  State<PhotoPage> createState() => _PhotoPageState();
}

class _PhotoPageState extends State<PhotoPage> {
  Future<void> _openActions() async {
    final action = await showModalBottomSheet<PhotoAction>(
      context: context,
      showDragHandle: true,
      useSafeArea: true,
      builder: (context) => Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.share_outlined),
            title: const Text('Teilen'),
            onTap: () => Navigator.pop(context, PhotoAction.share),
          ),
          ListTile(
            leading: const Icon(Icons.link),
            title: const Text('Link kopieren'),
            onTap: () => Navigator.pop(context, PhotoAction.copyLink),
          ),
          ListTile(
            leading: const Icon(Icons.delete_outline),
            title: const Text('Löschen'),
            onTap: () => Navigator.pop(context, PhotoAction.delete),
          ),
          const SizedBox(height: 8),
        ],
      ),
    );

    if (!mounted || action == null) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Ausgewählt: ${action.name}')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Foto'),
        actions: [
          IconButton(onPressed: _openActions, icon: const Icon(Icons.more_vert)),
        ],
      ),
    );
  }
}

Drei Details sind wichtig. Schließt der Nutzer das Sheet durch Herunterwischen oder Tippen auf den Hintergrund, liefert das Future null; deshalb ist die Prüfung action == null Pflicht. Die mounted-Prüfung nach dem await verhindert, dass ein veralteter context benutzt wird, falls die Seite geschlossen wurde, während das Sheet offen war. Und mainAxisSize: MainAxisSize.min an der Column sorgt dafür, dass das Sheet nur so hoch wie sein Inhalt wird; warum das nötig ist, erkläre ich im MainAxisSize-Beitrag. Das Zurückgeben von Werten funktioniert wie bei der Seitennavigation, denn auch ein modales Sheet ist eine Route; Details im Navigator-Beitrag.

In diesem Beispiel geben wir die Auswahl an die Seite zurück und verarbeiten das Ergebnis dort, statt die Arbeit im Sheet zu erledigen. Dafür gibt es einen konkreten Grund: Eine SnackBar, die bei offenem Sheet über den ScaffoldMessenger der Seite angezeigt wird, wird im Scaffold der Seite gezeichnet und liegt damit hinter dem Sheet und seiner Abdunklung. Beantwortet das Sheet nur die Frage „Was hat der Nutzer gewählt?“ und schließt sich, passieren Aufgaben wie eine Meldung anzeigen, eine Löschbestätigung einholen oder eine Anfrage senden auf der Seite, wo sie sichtbar sind. Der Code des Sheets wird außerdem einfacher und lässt sich unverändert auf anderen Screens wiederverwenden.

Wichtige Parameter

Parameter Standard Wofür?
isScrollControlled false Bei true kann das Sheet bis zur vollen Bildschirmhöhe wachsen
useSafeArea false Hält das Sheet von Statusleiste, Notch und ähnlichen Bereichen oben fern
showDragHandle aus dem Theme, sonst false Zeigt den kleinen Ziehgriff von Material 3 oben an
isDismissible true Ob ein Tippen auf den Hintergrund es schließt
enableDrag true Ob es sich durch Herunterwischen schließen lässt
backgroundColor, shape aus dem Theme Hintergrundfarbe und Eckenform
constraints in Material 3 höchstens 640 Pixel breit Größenbegrenzung des Sheets
useRootNavigator false Öffnet das Sheet bei verschachtelten Navigatoren im obersten Navigator

Die meisten Parameter zum Aussehen (showDragHandle, backgroundColor, shape, constraints) lassen sich auch auf Theme-Ebene über BottomSheetThemeData setzen; dazu weiter unten mehr.

Höhe: isScrollControlled

Standardmäßig wächst ein modales Sheet höchstens auf 9/16 der Bildschirmhöhe, egal wie lang sein Inhalt ist. Für eine kurze Optionsliste reicht das. Ist der Inhalt länger, wird der untere Teil abgeschnitten oder läuft über. Damit das Sheet bis zur vollen Bildschirmhöhe wachsen kann, brauchen Sie isScrollControlled: true. Dann ist auch useSafeArea: true eine gute Gewohnheit; sonst kann ein hohes Sheet bis unter die Statusleiste reichen.

isScrollControlled: true macht das Sheet nicht von selbst bildschirmfüllend; es wächst nur so weit, wie der Inhalt Platz braucht. Legen Sie langen Inhalt in ein SingleChildScrollView oder eine Liste, wird der Teil, der nicht auf den Bildschirm passt, scrollbar.

Formulare und Tastatur: viewInsets

Enthält das Sheet ein Textfeld, verschwindet das Feld beim Öffnen der Tastatur dahinter. Die Lösung besteht aus zwei Teilen: Mit isScrollControlled: true darf das Sheet nach oben wachsen, und unter dem Inhalt wird Platz in Höhe der Tastatur ergänzt:

Future<String?> showCommentSheet(BuildContext context) {
  return showModalBottomSheet<String>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    showDragHandle: true,
    builder: (context) => Padding(
      padding: EdgeInsets.only(
        left: 16,
        right: 16,
        bottom: MediaQuery.viewInsetsOf(context).bottom + 16,
      ),
      child: TextField(
        autofocus: true,
        textInputAction: TextInputAction.send,
        decoration: const InputDecoration(labelText: 'Ihr Kommentar'),
        onSubmitted: (value) => Navigator.pop(context, value.trim()),
      ),
    ),
  );
}

MediaQuery.viewInsetsOf(context).bottom ist die Höhe der Tastatur, die gerade den unteren Bildschirmteil verdeckt; bei geschlossener Tastatur ist sie 0. Details zum Textfeld selbst (Fokus, Tastaturtyp, onSubmitted) stehen im TextField-Beitrag.

DraggableScrollableSheet: ein ziehbares Sheet

Soll ein Sheet mit langer Liste zuerst halb geöffnet sein und wachsen, wenn der Nutzer es hochzieht, verwenden Sie DraggableScrollableSheet:

Future<void> showCountryPicker(BuildContext context, List<String> countries) {
  return showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    builder: (context) => DraggableScrollableSheet(
      expand: false,
      initialChildSize: 0.5,
      minChildSize: 0.3,
      maxChildSize: 0.95,
      snap: true,
      snapSizes: const [0.5],
      builder: (context, scrollController) => ListView.builder(
        controller: scrollController,
        itemCount: countries.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(countries[index]),
          onTap: () => Navigator.pop(context),
        ),
      ),
    ),
  );
}

Die Größen sind Anteile an der Gesamthöhe, die dem Sheet zur Verfügung steht: 0.5 bedeutet die Hälfte dieses Bereichs. snap: true und snapSizes sorgen dafür, dass das Sheet beim Loslassen auf bestimmten Höhen einrastet. Zwei Regeln: Übergeben Sie den scrollController aus builder unbedingt an die Liste, sonst scrollt die Liste, aber das Sheet wächst nicht; und verwenden Sie in einem modalen Sheet expand: false, sonst versucht das Sheet, den gesamten verfügbaren Platz zu füllen. Zu ListView.builder selbst lesen Sie den ListView-Beitrag.

Persistente Bottom Sheets

Ein persistentes Sheet dunkelt den Hintergrund nicht ab und blockiert die Interaktion mit der Seite nicht; denken Sie an die „Wird gerade gespielt“-Leiste einer Musik-App. Es wird mit Scaffold.of(context).showBottomSheet geöffnet, das einen Controller zum Schließen zurückgibt:

class PlayerPage extends StatefulWidget {
  const PlayerPage({super.key});

  @override
  State<PlayerPage> createState() => _PlayerPageState();
}

class _PlayerPageState extends State<PlayerPage> {
  PersistentBottomSheetController? _sheet;

  void _toggleSheet(BuildContext scaffoldContext) {
    if (_sheet != null) {
      _sheet!.close();
      return;
    }
    _sheet = Scaffold.of(scaffoldContext).showBottomSheet(
      (context) => const ListTile(
        leading: Icon(Icons.music_note),
        title: Text('Wird gerade gespielt'),
        subtitle: Text('Titelname'),
      ),
      showDragHandle: true,
    );
    _sheet!.closed.then((_) {
      if (mounted) setState(() => _sheet = null);
    });
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Playlist')),
      body: Builder(
        builder: (scaffoldContext) => Center(
          child: FilledButton(
            onPressed: () => _toggleSheet(scaffoldContext),
            child: Text(_sheet == null ? 'Player anzeigen' : 'Ausblenden'),
          ),
        ),
      ),
    );
  }
}

Der Builder steht hier mit Absicht: Scaffold.of sucht von einem context aus nach oben nach einem Scaffold, der eigene context der build-Methode liegt aber oberhalb des Scaffold. Das Future closed wird auch abgeschlossen, wenn der Nutzer das Sheet wegwischt; deshalb setzen wir den Zustand dort zurück. Soll das Sheet immer sichtbar sein, ist ein Widget im Parameter bottomSheet des Scaffold einfacher; solange dieser Parameter gesetzt ist, kann showBottomSheet allerdings nicht aufgerufen werden.

Ein persistentes Sheet eignet sich für Informationen, die der Nutzer im Blick behält, während er mit der Seite weiterarbeitet: die Zusammenfassung eines gewählten Orts auf einer Karte, ein Musikplayer, eine Warenkorbübersicht. Brauchen Sie eine Entscheidung vom Nutzer, ist ein modales Sheet die bessere Wahl, denn ein persistentes Sheet gibt kein Ergebnis zurück; dass es geschlossen wurde, erfahren Sie nur über closed.

Aussehen, Animation und Theme

Statt bei jedem Aufruf showDragHandle: true zu schreiben, können Sie das ins Theme verlagern: ThemeData(bottomSheetTheme: const BottomSheetThemeData(showDragHandle: true)). Hintergrundfarbe, Eckenform und Breitenbegrenzung setzen Sie über dieselbe Klasse. Die grundsätzliche Idee der Komponenten-Themes erkläre ich im Beitrag zu Theme und ThemeData.

Der Ziehgriff ist nicht nur ein visueller Hinweis. Für Screenreader-Nutzer wird er als antippbares Element mit der Beschriftung „Schließen“ angesagt und schließt das Sheet; für Nutzer, die die Wischgeste nicht ausführen können, ist das eine wichtige Hilfe. Aus demselben Grund ist ein sichtbarer Schließen- oder Abbrechen-Button im Sheet eine gute Gewohnheit.

Wollen Sie die Geschwindigkeit beim Öffnen und Schließen ändern, gibt es den Parameter sheetAnimationStyle:

showModalBottomSheet<void>(
  context: context,
  sheetAnimationStyle: const AnimationStyle(
    duration: Duration(milliseconds: 400),
    reverseDuration: Duration(milliseconds: 250),
  ),
  builder: (context) => const SizedBox(height: 200),
);

Um die Animation ganz abzuschalten, übergeben Sie AnimationStyle.noAnimation. Die Standarddauern folgen den Material-Richtlinien; ändern Sie sie daher nur, wenn es einen echten Grund gibt.

Mini-Szenario: ein Filter-Panel

Stellen Sie sich eine Produktliste vor: Ein Tipp auf das Filtersymbol oben rechts öffnet ein Panel mit Sortierung, Kategorien und der Option „Nur auf Lager“; „Anwenden“ gibt die Auswahl an die Seite zurück, und wird das Panel weggewischt, ändert sich nichts. Ein Badge am Symbol zeigt, wie viele Filter aktiv sind:

enum SortOrder { newest, priceLow, priceHigh }

class ProductFilter {
  const ProductFilter({
    this.sort = SortOrder.newest,
    this.categories = const {},
    this.onlyInStock = false,
  });

  final SortOrder sort;
  final Set<String> categories;
  final bool onlyInStock;

  ProductFilter copyWith({
    SortOrder? sort,
    Set<String>? categories,
    bool? onlyInStock,
  }) {
    return ProductFilter(
      sort: sort ?? this.sort,
      categories: categories ?? this.categories,
      onlyInStock: onlyInStock ?? this.onlyInStock,
    );
  }
}

class FilterSheet extends StatefulWidget {
  const FilterSheet({super.key, required this.initial});

  final ProductFilter initial;

  @override
  State<FilterSheet> createState() => _FilterSheetState();
}

class _FilterSheetState extends State<FilterSheet> {
  static const _allCategories = ['Bücher', 'Elektronik', 'Kleidung', 'Spielzeug'];
  static const _sortLabels = {
    SortOrder.newest: 'Neueste',
    SortOrder.priceLow: 'Preis aufsteigend',
    SortOrder.priceHigh: 'Preis absteigend',
  };

  late ProductFilter _filter = widget.initial;

  void _toggleCategory(String category, bool selected) {
    final next = {..._filter.categories};
    selected ? next.add(category) : next.remove(category);
    setState(() => _filter = _filter.copyWith(categories: next));
  }

  @override
  Widget build(BuildContext context) {
    final text = Theme.of(context).textTheme;

    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('Sortierung', style: text.titleMedium),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              for (final entry in _sortLabels.entries)
                ChoiceChip(
                  label: Text(entry.value),
                  selected: _filter.sort == entry.key,
                  onSelected: (_) =>
                      setState(() => _filter = _filter.copyWith(sort: entry.key)),
                ),
            ],
          ),
          const SizedBox(height: 16),
          Text('Kategorien', style: text.titleMedium),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              for (final category in _allCategories)
                FilterChip(
                  label: Text(category),
                  selected: _filter.categories.contains(category),
                  onSelected: (selected) => _toggleCategory(category, selected),
                ),
            ],
          ),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('Nur auf Lager'),
            value: _filter.onlyInStock,
            onChanged: (value) =>
                setState(() => _filter = _filter.copyWith(onlyInStock: value)),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => setState(() => _filter = const ProductFilter()),
                  child: const Text('Zurücksetzen'),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: FilledButton(
                  onPressed: () => Navigator.pop(context, _filter),
                  child: const Text('Anwenden'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class ProductsPage extends StatefulWidget {
  const ProductsPage({super.key});

  @override
  State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  ProductFilter _filter = const ProductFilter();

  Future<void> _openFilters() async {
    final result = await showModalBottomSheet<ProductFilter>(
      context: context,
      isScrollControlled: true,
      useSafeArea: true,
      showDragHandle: true,
      builder: (context) => FilterSheet(initial: _filter),
    );
    if (result == null) return; // Sheet geschlossen, Filter unverändert
    setState(() => _filter = result);
  }

  @override
  Widget build(BuildContext context) {
    final count = _filter.categories.length + (_filter.onlyInStock ? 1 : 0);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Produkte'),
        actions: [
          IconButton(
            onPressed: _openFilters,
            tooltip: 'Filtern',
            icon: Badge(
              isLabelVisible: count > 0,
              label: Text('$count'),
              child: const Icon(Icons.tune),
            ),
          ),
        ],
      ),
      body: Center(
        child: Text(
          'Sortierung: ${_filter.sort.name}\n'
          'Kategorien: ${_filter.categories.isEmpty ? 'alle' : _filter.categories.join(', ')}\n'
          'Nur auf Lager: ${_filter.onlyInStock ? 'ja' : 'nein'}',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

Die wichtigste Entscheidung in diesem Szenario: Das Panel ist ein eigenes StatefulWidget. Weil das Sheet als separate Route geöffnet wird, baut das setState der Seite es nicht neu; sofortige Änderungen wie die Chip-Auswahl liegen im eigenen State des Sheets. Das Panel startet mit einer Kopie des Seitenfilters, und Änderungen kommen erst beim Tippen auf „Anwenden“ zurück; überlegt es sich der Nutzer anders und wischt das Panel weg, ist result null, und der Filter der Seite bleibt, wie er war. Weil die Filterwerte als unveränderliche Objekte mit copyWith übergeben werden, kann „Zurücksetzen“ in einer Zeile zu den Standardwerten zurückkehren. Die Zahl am Filtersymbol zeigt das eingebaute Badge-Widget von Material 3.

Wann ein Bottom Sheet, wann etwas anderes?

Ein Bottom Sheet ist ideal für Aktionslisten mit wenigen Optionen, Filter und kurze Formulare. Für eine Bestätigung, die der Nutzer lesen und entscheiden muss („Wirklich löschen?“), passt ein AlertDialog besser, weil er die Aufmerksamkeit in der Bildschirmmitte bündelt. Für eine kurze Auswahlliste an einem Button ist ein PopupMenuButton leichter. Lange Formulare mit mehreren Schritten lassen sich auf einer eigenen Seite bequemer ausfüllen, als wenn man sie in ein Sheet zwängt.

Häufige Fehler

1. Langer Inhalt wird abgeschnitten

Symptom: Das Sheet endet auf halber Höhe, die Buttons unten sind nicht sichtbar oder eine Overflow-Warnung erscheint. Die Standard-Höhengrenze liegt bei 9/16 des Bildschirms. Setzen Sie isScrollControlled: true und machen Sie den Inhalt scrollbar.

2. Die Tastatur verdeckt das Textfeld

Ein Tipp auf ein Feld im Sheet öffnet eine Tastatur, die es verdeckt. Ergänzen Sie zusammen mit isScrollControlled: true unter dem Inhalt Platz in Höhe von MediaQuery.viewInsetsOf(context).bottom.

3. DraggableScrollableSheet wächst nicht

Die Liste scrollt, aber das Sheet wächst nicht nach oben: Der scrollController aus builder wurde nicht an die Liste übergeben. Füllt das Sheet schon beim Öffnen den ganzen Bildschirm, fehlt expand: false.

4. Änderungen im Sheet werden nicht angezeigt

Den Inhalt des Sheets aus dem State der Seite aufzubauen und auf der Seite setState aufzurufen, aktualisiert das Sheet nicht, weil es eine eigene Route ist. Verschieben Sie den Inhalt in ein eigenes StatefulWidget oder nutzen Sie für kleine Zustände StatefulBuilder. StatefulBuilder gibt seiner builder-Funktion ein eigenes setState; für kleine Zustände wie einen einzelnen Schalter oder Zähler funktioniert das ohne eigene Klasse, mit wachsendem Inhalt ist ein separates Widget aber deutlich lesbarer.

5. Der Scaffold.of-Fehler

Erhalten Sie beim Öffnen eines persistenten Sheets diesen Fehler:

Scaffold.of() called with a context that does not contain a Scaffold.

verwenden Sie den context der build-Methode, die den Scaffold erzeugt. Legen Sie den Button in einen Builder oder lagern Sie ihn in ein eigenes Widget aus.

Häufig gestellte Fragen

Wie verhindere ich, dass der Nutzer das Sheet versehentlich schließt?

isDismissible: false verhindert das Schließen per Tippen auf den Hintergrund, enableDrag: false das Schließen per Herunterwischen. Für die Zurück-Taste oder Zurück-Geste unter Android umschließen Sie den Inhalt des Sheets mit PopScope(canPop: false, ...); bei einem Formular, das vor ungespeicherten Änderungen warnt, werden alle drei zusammen eingesetzt. Lassen Sie dem Nutzer immer einen sichtbaren „Abbrechen“-Button.

Warum wirkt das Sheet auf einem Tablet schmal und zentriert?

In Material 3 liegt die Standard-Breitenbegrenzung eines modalen Sheets bei 640 Pixeln; auf breiten Bildschirmen wird das Sheet in dieser Breite zentriert. Soll es die volle Breite einnehmen, übergeben Sie constraints: const BoxConstraints(maxWidth: double.infinity) oder setzen dieselbe Einstellung app-weit über BottomSheetThemeData.

Kann man die Seite darunter antippen, während das Sheet offen ist?

Bei einem modalen Sheet nicht; die Abdunklung dahinter fängt Berührungen ab und schließt das Sheet standardmäßig. Soll die Interaktion mit der Seite darunter weitergehen, verwenden Sie ein persistentes Sheet (showBottomSheet oder Scaffold.bottomSheet).

Warum bleibt die untere Navigationsleiste sichtbar, wenn sich das Sheet öffnet?

Nutzt Ihre App für jeden Tab einen eigenen Navigator (verschachtelte Navigation), öffnet showModalBottomSheet standardmäßig im nächstgelegenen Navigator, also innerhalb des Tabs; die untere Navigationsleiste bleibt außerhalb der Abdunklung. Damit das Sheet über allem erscheint, übergeben Sie useRootNavigator: true. Dann muss auch der Navigator.pop-Aufruf, der das Sheet schließt, den eigenen context des Sheets verwenden; solange Sie den an builder übergebenen context nutzen, ist alles in Ordnung.

Kommentare