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

Flutter: SingleChildScrollView, ListView und NestedScrollView

Ahmet Balaman

Zuletzt aktualisiert:

9 Min. Lesezeit

FlutterSingleChildScrollViewListViewNestedScrollViewScrollLayout
Flutter: SingleChildScrollView, ListView und NestedScrollView

SingleChildScrollView ist das einfachste Scroll-Widget in Flutter: Es macht ein einzelnes Kind, meist eine Column, scrollbar. Es sollte Ihr erster Griff sein auf Screens, auf denen eine Handvoll unterschiedlicher, bekannter Widgets untereinanderstehen und auf einem kleinen Telefon überlaufen könnten: Login-Formular, Einstellungsseite, Produktdetails. In diesem Beitrag geht es um das Widget selbst, sein Verhältnis zur Tastatur und die Frage, warum es sich mit Expanded in einer Column nicht verträgt; danach zeige ich den Unterschied zu ListView und NestedScrollView und welches Widget auf welchen Screen gehört.

Live-Demo

Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:

💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.

Was ist SingleChildScrollView?

Wie der Name sagt, hat es genau ein Kind, und wenn dieses Kind höher als der Bildschirm ist, wird die Differenz scrollbar. Passt das Kind, passiert nichts; das Widget verhält sich wie ein unsichtbarer Kasten. Deshalb ist es eine sichere Gewohnheit, jeden Screen mit festem Inhalt, der „vielleicht überläuft“, damit zu umschließen.

Grundlegende Verwendung

SingleChildScrollView(
  child: Column(
    children: [
      Container(height: 200, color: Colors.red),
      Container(height: 200, color: Colors.green),
      Container(height: 200, color: Colors.blue),
      Container(height: 200, color: Colors.yellow),
      // Weiterer Inhalt, der über den Bildschirm hinausgeht...
    ],
  ),
)

Verwenden Sie dieselbe Column ohne diese Hülle, läuft der Inhalt auf einem kleinen Bildschirm über, und unten erscheint die bekannte gelb-schwarz gestreifte Overflow-Warnung.

Horizontales Scrollen

scrollDirection: Axis.horizontal macht dasselbe für eine Row. Praktisch für einen horizontalen Kartenstreifen oder eine breite Tabelle:

SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Row(
    children: [
      Container(width: 150, color: Colors.red),
      Container(width: 150, color: Colors.green),
      Container(width: 150, color: Colors.blue),
    ],
  ),
)

Wichtige Eigenschaften

Eigenschaft Beschreibung
scrollDirection Scrollrichtung (Axis.vertical / Axis.horizontal)
padding Abstand rund um den Inhalt; gehört zum scrollbaren Bereich
reverse Beginnt das Scrollen am Ende
physics Scrollverhalten (federn, anschlagen, gar nicht scrollen)
controller Ein ScrollController, um die Position zu lesen und programmatisch zu scrollen
keyboardDismissBehavior Mit onDrag schließt sich die Tastatur, sobald der Nutzer scrollt

Scroll Physics

Das Standardverhalten folgt der Plattform: Federn auf iOS, Anschlagen mit blauem Leuchten auf Android. Über physics ändern Sie es:

  • BouncingScrollPhysics: Federn im iOS-Stil.
  • ClampingScrollPhysics: Android-Stil, stoppt am Rand.
  • NeverScrollableScrollPhysics: schaltet das Scrollen ab; beim verschachtelten Scrollen bekommt es das innere Widget.
  • AlwaysScrollableScrollPhysics: erlaubt Scrollen auch bei kurzem Inhalt; nötig für Pull-to-Refresh mit RefreshIndicator.
SingleChildScrollView(
  physics: const BouncingScrollPhysics(),
  child: Column(children: [/* ... */]),
)

Tastatur und Formular-Screens

Öffnet sich die Tastatur, verkleinert Scaffold den Body um die Tastaturhöhe (resizeToAvoidBottomInset ist standardmäßig aktiv). Kann die Column des Formulars nicht scrollen, passt sie nicht mehr in den kleineren Bereich und läuft über. SingleChildScrollView löst das auf zwei Arten: Der Inhalt wird im verkleinerten Bereich scrollbar, und das fokussierte TextField wird automatisch in den sichtbaren Bereich gescrollt, ohne dass Sie Code dafür schreiben. Fügen Sie keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag hinzu, schließt sich die Tastatur, sobald der Nutzer den Inhalt zieht:

SingleChildScrollView(
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  padding: const EdgeInsets.all(16),
  child: Column(
    children: const [
      TextField(decoration: InputDecoration(labelText: 'Name')),
      SizedBox(height: 16),
      TextField(decoration: InputDecoration(labelText: 'E-Mail')),
      // ...
    ],
  ),
)

Details zu Validierung und TextFormField finden Sie im Beitrag zum Form-Widget.

Wann verwenden – und wann nicht?

Wählen Sie das Paar Column + SingleChildScrollView, wenn die Widgets auf dem Screen verschieden sind und ihre Anzahl von vornherein feststeht: Login-Formular, Profilbearbeitung, Einstellungen, Produktdetails, Onboarding-Seite. Auf diesen Screens wird der Inhalt ohnehin auf einmal gebaut, und das Scrollen ist nur eine Absicherung gegen kleine Bildschirme und die Tastatur.

Auswahl des Scroll-Widgets: Column wenn der Inhalt passt, SingleChildScrollView bei bekannter Anzahl, ListView.builder für lange Listen, GridView für Raster

In diesen Fällen passt ein anderes Widget besser:

  • Zeigen Sie gleichartige Zeilen in einer Anzahl, die Sie nicht kontrollieren (Nachrichten, Produkte, Suchergebnisse), verwenden Sie ListView.builder. Eine Column in einer SingleChildScrollView baut alle Zeilen auf einmal; bei 20 Zeilen merkt es niemand, bei 2.000 friert der Screen ein.
  • Bilden die Elemente ein Raster, brauchen Sie GridView; sollen mehrere scrollbare Abschnitte auf einem Screen zusammenkommen (Header, horizontale Liste, vertikale Liste), brauchen Sie CustomScrollView und Slivers. Liegen Tab-Listen unter einem zusammenklappenden Header, brauchen Sie NestedScrollView; die Unterschiede stehen im nächsten Abschnitt.
  • Passt der Inhalt garantiert auf jeden Bildschirm (etwa ein Bestätigungs-Screen mit drei Buttons), gibt es nichts zu scrollen; eine einfache Column reicht.

Der Unterschied zu ListView und NestedScrollView

Alle drei scrollen, bauen ihren Inhalt aber unterschiedlich auf und lösen unterschiedliche Probleme:

Widget Wie es den Inhalt aufbaut Wo es passt
SingleChildScrollView Das gesamte einzelne Kind auf einmal Eine bekannte Menge unterschiedlicher Widgets: Formular, Einstellungen, Detailseite
ListView.builder Nur die sichtbaren Zeilen, beim Scrollen nach und nach Gleichartige Zeilen, lang oder in unbekannter Anzahl
NestedScrollView Koordiniert einen Kopfbereich mit einem scrollbaren Körper darunter Tabs unter einem zusammenklappenden Header, jeder Tab mit eigener Liste

ListView: baut die sichtbaren Zeilen

ListView.builder erzeugt Zeilen, sobald sie ins Bild kommen, und gibt die frei, die es verlassen. Selbst bei tausend Zeilen wird zu jedem Zeitpunkt nur gebaut, was auf den Bildschirm passt, plus ein kleiner Puffer. Schreiben Sie dieselbe Liste als Column in einer SingleChildScrollView, entstehen alle tausend Zeilen schon im ersten Frame.

ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(title: Text('Zeile $index')),
)

Details wie separatorBuilder, Scroll-Steuerung und endlose Listen stehen im Beitrag zur Verwendung von ListView, die Darstellung derselben Daten als Raster im Beitrag zu GridView.

NestedScrollView: ein Header mit Tab-Listen

Der typische Einsatz von NestedScrollView laut offizieller Dokumentation: im Kopfbereich eine zusammenklappbare SliverAppBar mit TabBar, im Körper eine TabBarView, in der jeder Tab seine eigene Liste trägt. Eine einzelne CustomScrollView kann das nicht, weil jeder Tab eine eigene Scrollposition hat. NestedScrollView verbindet die beiden Scroller: Scrollt der Nutzer nach oben, klappt zuerst der Header zusammen, dann bewegt sich die innere Liste.

class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  static const _tabs = ['Beiträge', 'Likes'];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                title: const Text('Profil'),
                pinned: true,
                expandedHeight: 200,
                forceElevated: innerBoxIsScrolled,
                bottom: TabBar(
                  tabs: [for (final tab in _tabs) Tab(text: tab)],
                ),
              ),
            ),
          ],
          body: TabBarView(
            children: [
              for (final tab in _tabs)
                Builder(
                  builder: (context) => CustomScrollView(
                    key: PageStorageKey<String>(tab),
                    slivers: [
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverList.builder(
                        itemCount: 30,
                        itemBuilder: (context, index) =>
                            ListTile(title: Text('$tab $index')),
                      ),
                    ],
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

Drei Details machen diesen Code korrekt. SliverOverlapAbsorber und der SliverOverlapInjector in jedem Tab teilen der inneren Liste mit, wie viel Platz die fixierte App Bar belegt; laut Dokumentation kann die innere Liste ohne sie unter der App Bar landen, selbst wenn sie noch gar nicht gescrollt wurde. Der Builder sorgt dafür, dass sliverOverlapAbsorberHandleFor mit einem context unterhalb der NestedScrollView aufgerufen wird. PageStorageKey sorgt dafür, dass jede Liste ihre Scrollposition beim Tab-Wechsel behält. Soll der Header zurückkommen, sobald der Nutzer nach oben zu scrollen beginnt, reicht floating: true allein nicht; die NestedScrollView braucht zusätzlich floatHeaderSlivers: true.

Hat der Screen keine Tabs und nur eine Liste, brauchen Sie keine NestedScrollView: Header und Liste als Slivers in derselben CustomScrollView zu schreiben, ist einfacher. Der Aufbau im Beitrag SliverAppBar mit Kategorie-Header und Suchfeld macht genau das für einen Header mit Suchfeld und Kategoriezeile.

Häufige Fehler

1. „Bottom overflowed by N pixels“, sobald die Tastatur aufgeht

Symptom: Der Formular-Screen sieht gut aus, bis Sie ein Feld antippen; dann öffnet sich die Tastatur, unten erscheinen gelb-schwarze Streifen, und die Konsole zeigt:

A RenderFlex overflowed by 142 pixels on the bottom.

Der Body ist um die Tastaturhöhe geschrumpft, die Column konnte es nicht. Die Lösung ist, die Column in eine SingleChildScrollView zu setzen, mehr nicht. Mit resizeToAvoidBottomInset: false lässt sich der Fehler zwar stumm schalten, aber dann verdeckt die Tastatur die unteren Felder, und der Nutzer erreicht das letzte nicht mehr.

2. Expanded oder Spacer in einer SingleChildScrollView

Symptom: Der Screen bleibt leer, und die Konsole zeigt:

RenderFlex children have non-zero flex but incoming height constraints are unbounded.

Expanded verlangt von der Column „den restlichen Platz“, während SingleChildScrollView ihrem Kind unbegrenzte Höhe gibt. Von Unendlich gibt es keinen „Rest“. War das Expanded nur da, um Platz zu füllen, ist es meist überflüssig; entfernen Sie es und lassen Sie dem Widget seine natürliche Höhe. Wollen Sie „Button unten fixiert, aber scrollen, wenn der Inhalt länger wird“, verwenden Sie dieses Muster, das der Column über ConstrainedBox eine Mindesthöhe und über IntrinsicHeight eine messbare Höhe gibt:

LayoutBuilder(
  builder: (context, constraints) {
    return SingleChildScrollView(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: constraints.maxHeight),
        child: IntrinsicHeight(
          child: Column(
            children: [
              const Text('Oberer Inhalt'),
              const Spacer(), // Funktioniert jetzt
              FilledButton(onPressed: () {}, child: const Text('Weiter')),
            ],
          ),
        ),
      ),
    );
  },
)

ConstrainedBox sagt „sei mindestens so hoch wie der Bildschirm“, und IntrinsicHeight gibt der Column eine Höhe, die Flutter messen kann; der Spacer füllt die Lücke, solange der Inhalt kurz ist, und sobald er wächst, übernimmt das Scrollen. Dasselbe Muster erklärt, warum mainAxisAlignment: MainAxisAlignment.center in einer SingleChildScrollView nichts bewirkt: Die Column schrumpft auf ihren Inhalt und hat keinen Platz zum Zentrieren; mit der Mindesthöhe funktioniert es wieder. IntrinsicHeight kostet einen zusätzlichen Messdurchlauf, verwenden Sie es also auf kurzen Screens wie Formularen, nicht in langen Listen.

3. Eine ListView oder GridView hineinsetzen

Symptom: Vertical viewport was given unbounded height. Auch die innere Liste bekommt unbegrenzte Höhe und kann sich nicht selbst bemessen. Bei einer kurzen Liste übergeben Sie shrinkWrap: true und physics: const NeverScrollableScrollPhysics(); die Liste misst ihre eigene Höhe und überlässt das Scrollen dem äußeren Widget. Bei einer langen Liste zerstört das das Lazy Loading; verzichten Sie dann auf die äußere SingleChildScrollView und fügen Sie alles als Slivers in einer CustomScrollView zusammen.

4. Expanded in einer Row beim horizontalen Scrollen

Derselbe Fehler, nur seitwärts: Expanded in einer Row innerhalb von SingleChildScrollView(scrollDirection: Axis.horizontal) erzeugt „incoming width constraints are unbounded“. Geben Sie jedem Kind eines horizontalen Streifens eine explizite Breite wie SizedBox(width: ...); wer weiß, wie Expanded funktioniert, erkennt diesen Fehler auf einen Blick.

Mini-Szenario: Ein Login-Screen

Stellen Sie sich einen klassischen Login-Screen vor: oben ein Logo, in der Mitte die Felder für E-Mail und Passwort, ganz unten der Button „Anmelden“. Auf einem hohen Bildschirm soll der Button unten fixiert bleiben; öffnet sich die Tastatur, muss der Inhalt scrollen und der Button über der Tastatur erscheinen. Genau hier gehört das IntrinsicHeight-Muster von oben hin.

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

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Hier die Anmeldeanfrage senden
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Anmelden')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          return SingleChildScrollView(
            keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
            padding: const EdgeInsets.all(24),
            child: ConstrainedBox(
              // Das Padding nimmt oben und unten je 24 Pixel
              constraints: BoxConstraints(minHeight: constraints.maxHeight - 48),
              child: IntrinsicHeight(
                child: Form(
                  key: _formKey,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      const FlutterLogo(size: 96),
                      const SizedBox(height: 32),
                      TextFormField(
                        controller: _emailController,
                        keyboardType: TextInputType.emailAddress,
                        decoration: const InputDecoration(labelText: 'E-Mail'),
                        validator: (value) => (value == null || !value.contains('@'))
                            ? 'Gültige E-Mail-Adresse eingeben'
                            : null,
                      ),
                      const SizedBox(height: 16),
                      TextFormField(
                        controller: _passwordController,
                        obscureText: true,
                        decoration: const InputDecoration(labelText: 'Passwort'),
                        validator: (value) => (value == null || value.length < 6)
                            ? 'Mindestens 6 Zeichen'
                            : null,
                      ),
                      const Spacer(),
                      FilledButton(
                        onPressed: _submit,
                        child: const Text('Anmelden'),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

Hier passieren drei Dinge. LayoutBuilder meldet die aktuelle Höhe des Body; öffnet sich die Tastatur, schrumpft dieser Wert, die Mindesthöhe schrumpft mit, und der Inhalt wird scrollbar. Spacer schiebt den Button nach unten, solange die Tastatur geschlossen ist. Das fokussierte Feld bleibt dank SingleChildScrollView sichtbar. Zieht der Nutzer den Inhalt während der Eingabe, schließt sich die Tastatur. Für all das haben wir kein einziges setState geschrieben; die ganze Arbeit steckt im richtig aufgebauten Layout.

Häufig gestellte Fragen

Was ist der Unterschied zwischen SingleChildScrollView und ListView?

SingleChildScrollView scrollt ein einzelnes Kind und baut es vollständig auf einmal; ListView baut Zeilen erst, wenn sie ins Bild kommen. Das erste ist richtig für Screens aus einer bekannten Anzahl unterschiedlicher Widgets, das zweite für viele gleichartige Zeilen.

Warum bekomme ich beim Öffnen der Tastatur einen „bottom overflowed“-Fehler?

Scaffold verkleinert den Body, sobald die Tastatur aufgeht, und eine nicht scrollbare Column passt nicht mehr in den neuen Platz. Die Column in eine SingleChildScrollView zu setzen reicht; das fokussierte TextField wird automatisch in den sichtbaren Bereich gescrollt.

Warum funktioniert Expanded in SingleChildScrollView nicht?

Der Scrollbereich gibt seinem Kind unbegrenzte Höhe, also ist der „restliche Platz“, den Expanded füllen würde, nicht definiert. Entfernen Sie das Expanded, oder geben Sie der Column mit dem Muster LayoutBuilder + ConstrainedBox(minHeight) + IntrinsicHeight eine messbare Höhe.

Wie zentriere ich eine Column vertikal in einer SingleChildScrollView?

Weil die Column auf ihren Inhalt schrumpft, bewirkt mainAxisAlignment: center allein nichts. Geben Sie ihr über eine ConstrainedBox die Bildschirmhöhe als minHeight; die Column wird dann mindestens so hoch wie der Bildschirm, und das Zentrieren funktioniert.

Wann brauche ich NestedScrollView?

Wenn unter einem zusammenklappbaren Header mehrere Tabs liegen und jeder Tab eine eigene scrollbare Liste hat. Für eine einzelne Liste mit zusammenklappendem Header darüber reicht CustomScrollView; NestedScrollView ist dafür da, zwei getrennte Scroller zu koordinieren.

Kommentare