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

Flutter: ListView-Widget verwenden

Ahmet Balaman

Zuletzt aktualisiert:

6 Min. Lesezeit

FlutterListViewWidgetScrollPerformanceLayout
Flutter: ListView-Widget verwenden

ListView reiht Elemente hintereinander auf und macht alles scrollbar, was nicht auf den Bildschirm passt. Sie begegnen ihm auf jedem Screen, auf dem gleichartige Zeilen untereinander stehen: Nachrichtenliste, Einstellungsmenü, Produkt-Feed. Eine ListView zu schreiben ist der einfache Teil. Lernen sollten Sie, welchen Konstruktor Sie wann wählen, denn die falsche Wahl fällt bei zehn Zeilen nicht auf und wird bei tausend Zeilen zum Ruckeln.

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.

Grundlegende Verwendung

Die einfachste Form nimmt eine fertige Widget-Liste entgegen:

ListView(
  children: const [
    Text('Eintrag 1'),
    Text('Eintrag 2'),
    Text('Eintrag 3'),
  ],
)

Bei diesem Konstruktor werden alle Widget-Objekte aus children sofort erzeugt. Für ein festes Menü mit fünf oder zehn Zeilen ist das in Ordnung; für Hunderte Zeilen ist ListView.builder das richtige Werkzeug (siehe unten).

Wichtige Eigenschaften

Eigenschaft Beschreibung
scrollDirection Scrollrichtung (Axis.vertical / Axis.horizontal)
padding Abstand rund um den Listeninhalt
reverse Beginnt die Liste am Ende (praktisch für Chat-Screens)
physics Scrollverhalten
shrinkWrap Zwingt die Liste, nur so hoch wie ihr Inhalt zu sein (teuer)
itemExtent Feste Höhe für jedes Element
prototypeItem Misst die Höhe an einem Beispiel-Widget
controller Ein ScrollController, um die Scrollposition zu lesen und zu ändern

Klassische Zeilen mit ListTile

Für das Zeilendesign reicht meist ListTile; Icon, Titel, Untertitel und der Pfeil rechts sind schon fertig:

ListView(
  children: [
    ListTile(
      leading: const Icon(Icons.person),
      title: const Text('Benutzername'),
      subtitle: const Text('Untertitel'),
      trailing: const Icon(Icons.chevron_right),
      onTap: () {},
    ),
    const ListTile(
      leading: Icon(Icons.email),
      title: Text('E-Mail'),
      subtitle: Text('[email protected]'),
      trailing: Icon(Icons.chevron_right),
    ),
  ],
)

Lazy Loading mit ListView.builder

ListView.builder erzeugt die Elemente nicht im Voraus. Es ruft itemBuilder nur für die Zeilen auf, die sichtbar sind, plus den kleinen Cache-Bereich direkt außerhalb des Viewports. Beim Scrollen werden neue Zeilen gebaut und verlassende Zeilen freigegeben. Ob die Liste 100 oder 100.000 Einträge hat: Gleichzeitig existieren ungefähr so viele Zeilen, wie auf den Bildschirm passen.

In ListView.builder werden nur die Zeilen im sichtbaren Ausschnitt gebaut; für die Zeilen darüber und darunter läuft itemBuilder nicht

ListView.builder(
  itemCount: 100,
  itemBuilder: (context, index) {
    return ListTile(
      leading: CircleAvatar(child: Text('$index')),
      title: Text('Eintrag $index'),
      subtitle: const Text('Beschreibung dieses Eintrags'),
    );
  },
)

Wenn ListTile nicht reicht, bauen Sie die Zeile beliebig selbst. Text in einer Row mit Expanded zu umschließen ist eine gute Gewohnheit, damit lange Titel nicht überlaufen:

final List<Map<String, dynamic>> items = [
  {'title': 'Flutter', 'icon': Icons.flutter_dash, 'color': Colors.blue},
  {'title': 'Dart', 'icon': Icons.code, 'color': Colors.teal},
  {'title': 'Firebase', 'icon': Icons.cloud, 'color': Colors.orange},
];

ListView.builder(
  itemCount: items.length,
  padding: const EdgeInsets.all(16),
  itemBuilder: (context, index) {
    final item = items[index];
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Icon(item['icon'], size: 40, color: item['color']),
            const SizedBox(width: 16),
            Expanded(
              child: Text(item['title'], style: const TextStyle(fontSize: 18)),
            ),
            ElevatedButton(
              onPressed: () {},
              child: const Text('Details'),
            ),
          ],
        ),
      ),
    );
  },
)

Trennlinien mit ListView.separated

Um eine Linie oder einen Abstand zwischen die Zeilen zu setzen, nehmen Sie ListView.separated, statt an jede Zeile einen Divider anzuhängen. separatorBuilder wird nur zwischen den Elementen aufgerufen; nach dem letzten wird nichts gezeichnet, und das Problem „überflüssige Linie unter der letzten Zeile“ tritt gar nicht erst auf. Auch dieser Konstruktor arbeitet lazy wie builder.

ListView.separated(
  itemCount: 10,
  separatorBuilder: (context, index) => const Divider(height: 1),
  itemBuilder: (context, index) {
    return ListTile(title: Text('Eintrag $index'));
  },
)

Horizontale ListView

scrollDirection: Axis.horizontal legt die Liste auf die Seite. Ein Haken: Eine horizontale Liste kann ihre eigene Höhe nicht bestimmen, Sie müssen ihr eine begrenzte Höhe geben.

SizedBox(
  height: 120, // Eine horizontale Liste braucht eine begrenzte Höhe
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    itemCount: 10,
    itemBuilder: (context, index) {
      return Container(
        width: 100,
        margin: const EdgeInsets.all(8),
        color: Colors.primaries[index % Colors.primaries.length],
        child: Center(child: Text('${index + 1}')),
      );
    },
  ),
)

itemExtent und prototypeItem

Wenn alle Zeilen gleich hoch sind, machen Sie das Scrollen billiger, indem Sie es der ListView mitteilen. Flutter muss dann nicht mehr jede Zeile einzeln messen, und bei großen Sprüngen, etwa beim Ziehen der Scrollbar oder bei jumpTo auf einen weit entfernten Offset, kann es das Ziel berechnen, ohne die Zeilen dazwischen zu layouten.

ListView.builder(
  itemExtent: 72, // Jede Zeile ist genau 72 Pixel hoch
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(title: Text('Eintrag $index')),
)

Kennen Sie die Höhe nicht in Pixeln (die Schriftgröße kann sich mit den Bedienungshilfen des Nutzers ändern), übergeben Sie stattdessen ein prototypeItem. Flutter misst dieses Beispiel-Widget und wendet dieselbe Höhe auf alle Zeilen an. Beide Eigenschaften lassen sich nicht kombinieren.

ListView.builder(
  prototypeItem: const ListTile(title: Text('Beispielzeile')),
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(title: Text('Eintrag $index')),
)

Scrollsteuerung

Ein „Nach oben“-Button oder Infinite Scrolling braucht einen ScrollController. Erzeugen Sie ihn im State und geben Sie ihn in dispose frei:

final ScrollController _scrollController = ScrollController();

@override
void dispose() {
  _scrollController.dispose();
  super.dispose();
}

// in build
ListView.builder(
  controller: _scrollController,
  itemCount: 50,
  itemBuilder: (context, index) => ListTile(title: Text('Eintrag $index')),
)

// Liste nach oben scrollen
_scrollController.animateTo(
  0,
  duration: const Duration(milliseconds: 500),
  curve: Curves.easeInOut,
);

Wann verwenden – und wann nicht?

Greifen Sie zu ListView, wenn Sie gleichartige Elemente in einer Anzahl zeigen, die Sie nicht kontrollieren, untereinander oder nebeneinander. Kommen die Daten aus einer Collection, nehmen Sie fast immer den builder- oder separated-Konstruktor und heben sich den children-Konstruktor für kurze, von Hand geschriebene Menüs auf.

In diesen Fällen passt ein anderes Widget besser:

  • Für wenige, unterschiedliche Widgets wie ein Formular oder eine Einstellungsseite ist Column plus SingleChildScrollView bequemer. Die Details und den Unterschied zu ListView finden Sie im Beitrag zu SingleChildScrollView, ListView und NestedScrollView.
  • Sollen die Elemente in einem Raster statt in Zeilen stehen, verwenden Sie GridView.
  • Müssen Liste, Raster und ein einklappender Header auf einem Screen gemeinsam scrollen, brauchen Sie CustomScrollView und Slivers; hat unter einem einklappenden Header jeder Tab seine eigene Liste, brauchen Sie NestedScrollView. Beides erkläre ich in dem Beitrag aus dem ersten Punkt.
  • Passt der Inhalt ohnehin auf den Bildschirm, gibt es nichts zu scrollen; eine einfache Column reicht.

Häufige Fehler

1. Eine ListView direkt in eine Column setzen

Symptom: Der Screen bleibt leer, und in der Konsole steht:

Vertical viewport was given unbounded height.

Eine Column sagt ihren Kindern „sei so hoch, wie du willst“, während eine ListView alle Höhe nehmen möchte, die sie findet. Zwei Unendlichkeiten treffen aufeinander, und Flutter kann keine Größe berechnen. Die Lösung: Geben Sie der ListView den Platz, der in der Column übrig bleibt:

Column(
  children: [
    const Text('Überschrift'),
    Expanded(
      child: ListView.builder(
        itemCount: 50,
        itemBuilder: (context, index) => ListTile(title: Text('Eintrag $index')),
      ),
    ),
  ],
)

Die horizontale Variante desselben Problems lautet Horizontal viewport was given unbounded height.; dort ist die Lösung eine SizedBox mit Höhe, wie oben gezeigt.

2. Überall shrinkWrap: true setzen, um den Fehler stumm zu schalten

shrinkWrap: true lässt den Fehler tatsächlich verschwinden, hat aber seinen Preis: Um die eigene Höhe zu kennen, muss die ListView ihren gesamten Inhalt messen. Stecken Sie das dann noch mit NeverScrollableScrollPhysics in eine SingleChildScrollView, werden alle Zeilen auf einmal gebaut, und von der Faulheit des builders bleibt nichts übrig. Symptom: Je länger die Liste, desto langsamer öffnet sich der Screen und desto mehr stockt das Scrollen.

Für eine Liste mit fünf oder zehn Zeilen ist shrinkWrap akzeptabel. Bei einer langen Liste machen Sie den Inhalt oberhalb der Liste stattdessen zu ihrem ersten Element:

ListView.builder(
  itemCount: items.length + 1,
  itemBuilder: (context, index) {
    if (index == 0) {
      return const Padding(
        padding: EdgeInsets.all(16),
        child: Text('Kopfbereich'),
      );
    }
    final item = items[index - 1];
    return ListTile(title: Text(item['title']));
  },
)

3. Große Datenmengen über children übergeben

ListView(children: products.map((p) => ProductRow(p)).toList()) funktioniert, aber map(...).toList() erzeugt bei jedem build-Aufruf alle Zeilen-Widgets neu. Symptom: ein spürbarer Hänger nach jedem setState. Die Lösung ist, dieselben Daten über ListView.builder zu geben: itemCount: products.length und ProductRow(products[index]) im itemBuilder.

Mini-Szenario: Ein Aufgabenlisten-Screen

Stellen Sie sich einen To-do-Screen vor: oben die Zahl der offenen Aufgaben, darunter abhakbare Aufgaben und ein Hinweis, wenn die Liste leer ist. Der Zähler bleibt stehen, während die Liste scrollt, und genau dafür ist das Trio Column + Expanded + ListView.separated gemacht.

class Task {
  Task(this.title, {this.done = false});

  final String title;
  bool done;
}

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

  @override
  State<TaskListPage> createState() => _TaskListPageState();
}

class _TaskListPageState extends State<TaskListPage> {
  final List<Task> _tasks = [
    Task('Flutter-Lektion abschließen'),
    Task('ListView-Beispiel schreiben', done: true),
    Task('Projekt ins Repository pushen'),
  ];

  @override
  Widget build(BuildContext context) {
    final remaining = _tasks.where((task) => !task.done).length;

    return Scaffold(
      appBar: AppBar(title: const Text('Aufgaben')),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text('$remaining Aufgaben offen'),
          ),
          Expanded(
            child: _tasks.isEmpty
                ? const Center(child: Text('Noch keine Aufgaben'))
                : ListView.separated(
                    itemCount: _tasks.length,
                    separatorBuilder: (context, index) =>
                        const Divider(height: 1),
                    itemBuilder: (context, index) {
                      final task = _tasks[index];
                      return CheckboxListTile(
                        value: task.done,
                        title: Text(task.title),
                        onChanged: (value) {
                          setState(() => task.done = value ?? false);
                        },
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Hier stecken drei Entscheidungen. Expanded gibt der Liste, was der Zähler übrig lässt, und schließt den Unbounded-Height-Fehler von vornherein aus. Der leere Zustand wird außerhalb der ListView behandelt, weil eine leere Liste dem Nutzer nichts sagt. Und die Trennlinien kommen aus separatorBuilder, statt Teil jeder Zeile zu sein. Kommen die Daten aus dem Netz, wandert dieselbe Struktur in einen FutureBuilder.

Häufig gestellte Fragen

Was ist der Unterschied zwischen ListView und ListView.builder?

ListView(children: [...]) erzeugt alle Zeilen-Widgets im Voraus, ListView.builder ruft itemBuilder nur für die sichtbaren Zeilen auf. Das erste eignet sich für kurze, feste Menüs, das zweite für alles, was aus einer Collection kommt oder lang werden kann.

Wie behebe ich „Vertical viewport was given unbounded height“?

Der Fehler entsteht, wenn eine ListView in einer Column steht, deren Höhe unbegrenzt ist. Umschließen Sie die ListView mit Expanded oder geben Sie ihr mit SizedBox eine feste Höhe; shrinkWrap: true ist nur bei kurzen Listen eine vernünftige Lösung.

Warum ist shrinkWrap: true langsam?

Um die eigene Größe zu bestimmen, muss die Liste ihren gesamten Inhalt messen, und diese Größe wird beim Scrollen neu berechnet. Bei langen Listen hebt das den Vorteil des Lazy Loading auf.

Warum ist oben in meiner ListView ein Leerraum?

Hat der Screen keine AppBar, verwendet ListView das MediaQuery-Padding als Standard-Padding, damit der Inhalt nicht unter Systembereichen wie der Statusleiste landet. Übergeben Sie padding: EdgeInsets.zero, wenn Sie das nicht möchten.

Kommentare