Flutter: SliverAppBar für Kategorie-Header und Suchfelder
Zuletzt aktualisiert:
8 Min. Lesezeit

Katalog-, Shop- und Feed-Screens teilen sich ein Layout: oben ein Suchfeld, darunter eine Reihe horizontal scrollender Kategorie-Chips, und während die Liste scrollt, klappt der große Titel zusammen, Suche und Chips bleiben aber stehen. Dieser Beitrag baut genau dieses Layout: wo das Suchfeld hingehört, in bottom oder in flexibleSpace der SliverAppBar, was die Kombinationen aus pinned, floating und snap bewirken und wie Sie mit SliverPersistentHeader eine fixierte Kategoriezeile schreiben, die von der App Bar unabhängig ist. Nebenbei bauen wir auch den Header mit großem Bild, der beim Scrollen zusammenklappt und auf Detailseiten häufig vorkommt.
Live-Demo
Sie können die AppBar-Strukturen im interaktiven Beispiel unten ausprobieren:
💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.
Grundstruktur: ein Suchfeld in bottom
Eine SliverAppBar lebt ausschließlich in der slivers-Liste einer CustomScrollView. Der einfachste Aufbau ist eine fixierte App Bar mit einem Suchfeld im Bereich bottom:
CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: const Text('Kategorien'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Kategorien durchsuchen...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
isDense: true,
),
),
),
),
),
SliverList.builder(
itemCount: 40,
itemBuilder: (context, index) => ListTile(title: Text('Kategorie $index')),
),
],
)Der Slot bottom erwartet ein PreferredSizeWidget; mit PreferredSize geben Sie einem beliebigen Widget eine Höhe. Mit pinned: true versteckt die SliverAppBar ihren bottom-Bereich beim Zusammenklappen nie: Toolbar und bottom bleiben gemeinsam oben. Es ist derselbe Mechanismus wie bottom bei der klassischen AppBar, den ich im AppBar-Beitrag beschreibe.
Kombinationen aus pinned, floating und snap
Die drei Flags wirken unabhängig, aber erst die Kombination legt das Verhalten fest:
| Kombination | Was beim Scrollen passiert | Wann Sie sie wählen |
|---|---|---|
| keine | Die App Bar scrollt mit dem Inhalt weg und kommt erst ganz oben zurück | Titel unwichtig, Platz wertvoll |
pinned |
Klappt zusammen, aber Toolbar + bottom bleiben oben |
Suche und Chips müssen immer erreichbar sein |
floating |
Verschwindet beim Runterscrollen, erscheint beim ersten Hochscrollen wieder | Suche zweitrangig, aber eine Geste entfernt |
floating + snap |
Dasselbe, aber nie halb offen: ganz auf oder ganz zu | Sie wollen keine zitternde, halb sichtbare App Bar |
pinned + floating |
bottom bleibt fest, der erweiterte Bereich darüber kommt beim Hochscrollen zurück |
Feste Suche plus zurückkehrender Header |
snap lässt sich ohne floating nicht verwenden; bei einer falschen Kombination begegnet Ihnen die Assertion aus dem Abschnitt zu den Fehlern. expandedHeight und flexibleSpace sind von dieser Tabelle unabhängig: Sie legen fest, wie viel Platz die geöffnete App Bar einnimmt und was dort gezeichnet wird.
Suchfeld: bottom oder flexibleSpace?
Die beiden Platzierungen sind zwei verschiedene Produktentscheidungen.
bottom: Zusammen mit pinned ist das Suchfeld immer sichtbar; der Nutzer kann auch am Ende der Liste suchen. Das ist der Standard für Katalog- und Shop-Screens.
flexibleSpace: Das Suchfeld ist Teil des erweiterten Bereichs und klappt beim Scrollen mit dem Titel zusammen. Kombinieren Sie diese Platzierung mit floating und snap, holt eine kleine Aufwärtsgeste es zurück:
SliverAppBar(
floating: true,
snap: true,
expandedHeight: 120,
title: const Text('Entdecken'),
flexibleSpace: FlexibleSpaceBar(
background: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Suchen...',
prefixIcon: const Icon(Icons.search),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
),
),
),
),
),
)Faustregel: Alles, womit der Nutzer interagiert (Suche, Filter), kommt in bottom; alles, was nur informiert (Kategoriename, Anzahl der Artikel, Bild), in flexibleSpace. Ein interaktives Element im zusammenklappenden Bereich provoziert die Frage „Wo ist das Suchfeld hin?“.
Header mit großem Bild: expandedHeight und FlexibleSpaceBar
Detail- und Kampagnenseiten haben oben oft ein großes Bild; scrollt die Liste, klappt das Bild zusammen, und übrig bleibt eine schlichte Toolbar. expandedHeight legt die Höhe im ausgeklappten Zustand fest, FlexibleSpaceBar bestimmt, was in diesem Bereich gezeichnet wird:
SliverAppBar(
pinned: true,
expandedHeight: 220,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Entdecken', style: TextStyle(color: Colors.white)),
background: Stack(
fit: StackFit.expand,
children: [
Image.network(
'https://example.com/banner.jpg',
fit: BoxFit.cover,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
],
),
),
)FlexibleSpaceBar zeichnet den Titel ausgeklappt groß und eingeklappt klein und scrollt den Hintergrund standardmäßig mit CollapseMode.parallax langsamer als den Inhalt. Der dunkler werdende Verlauf am unteren Bildrand hält einen weißen Titel auf einem hellen Foto lesbar. Wegen pinned: true bleibt oben eine Leiste in Toolbar-Höhe stehen, sobald das Bild ganz eingeklappt ist; ohne pinned verlässt auch der Titel mit dem Bild den Bildschirm. Wählen Sie expandedHeight nicht so groß, dass auf kleinen Telefonen die ersten Listenzeilen verdeckt werden: Das Bild ist Dekoration, die Liste der eigentliche Inhalt.
Kategorie-Chips innerhalb von bottom
Auch die Chip-Zeile passt in bottom; eine horizontale ListView macht sie scrollbar. Halten Sie die gewählte Kategorie im State und verwenden Sie ChoiceChip:
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: SizedBox(
height: 56,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _categories.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = _categories[index];
return ChoiceChip(
label: Text(category),
selected: category == _selected,
onSelected: (_) => setState(() => _selected = category),
);
},
),
),
),Sollen Suchfeld und Chip-Zeile beide in bottom, packen Sie sie in eine Column und setzen preferredSize auf die Gesamthöhe. Meist ist es aber flexibler, die Chip-Zeile als eigenen Sliver von der App Bar zu trennen; der nächste Abschnitt zeigt, wie.
Fixierte Kategoriezeile mit SliverPersistentHeader
Wenn die Chip-Zeile außerhalb der App Bar liegen soll, etwa nach einem Banner am Listenanfang oder als Abschnittsüberschrift, verwenden Sie SliverPersistentHeader. Dieser Sliver braucht einen SliverPersistentHeaderDelegate; der Delegate kennt vier Dinge: die minimale und maximale Höhe, was gezeichnet wird und wann neu gezeichnet werden muss:
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
const _StickyHeaderDelegate({required this.child, this.height = 56});
final Widget child;
final double height;
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Material(
color: Theme.of(context).colorScheme.surface,
elevation: overlapsContent ? 2 : 0, // Schatten, sobald Inhalt darunter rutscht
child: SizedBox.expand(child: child),
);
}
@override
bool shouldRebuild(covariant _StickyHeaderDelegate oldDelegate) =>
child != oldDelegate.child || height != oldDelegate.height;
}Verwendung:
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(child: _buildCategoryChips()),
),Sind minExtent und maxExtent gleich, schrumpft der Header nicht, er bleibt nur kleben. Mit unterschiedlichen Werten lesen Sie in build über shrinkOffset den Grad des Zusammenklappens ab und können etwa die Chips verkleinern. overlapsContent wird true, sobald Inhalt unter den Header rutscht; ideal, um den Schatten nur in diesem Moment zu zeichnen. shouldRebuild ist wichtig: Ändert sich die Chip-Auswahl per setState, entsteht ein neues child, der Delegate bemerkt das und zeichnet den Header neu. Geben Sie pauschal false zurück, erscheint die Auswahl nie auf dem Bildschirm.
Wann verwenden – und wann nicht?
Wählen Sie dieses Layout, wenn der Rumpf des Screens eine einzige lange Liste ist und der obere Bereich auf das Scrollen reagieren muss: Produktkatalog, Nachrichten-Feed, Bibliotheks-Screen. Suche und Chips beim Scrollen an Ort und Stelle zu halten und gleichzeitig den großen Titel einzuklappen geht nur mit Slivern.
In diesen Fällen ist ein einfacheres oder anderes Werkzeug richtig:
- Der obere Bereich reagiert nicht auf das Scrollen: Dann braucht es keine
CustomScrollView. Nehmen Sie eine klassischeAppBarmit dem Suchfeld inbottomund eine normaleListViewals Rumpf. Weniger Code, gleiches Aussehen. - Jede Kategorie ist eine eigene Seite und der Nutzer wischt zwischen ihnen: Verwenden Sie Tabs statt Chips, siehe TabBar und TabBarView. Eine scrollbare
TabBarersetzt die Chip-Zeile. - Jede Tab-Seite hat eine eigene scrollbare Liste und die App Bar soll über allen zusammenklappen: Das erfordert
NestedScrollView;CustomScrollViewverwaltet nur einen Scrollbereich. Den Unterschied erkläre ich im Abschnitt zu NestedScrollView im Beitrag zu ScrollView und Listen-Widgets. - Sie brauchen die mittleren und großen Titel-Layouts von Material 3: Die Konstruktoren
SliverAppBar.mediumundSliverAppBar.largeliefern sie fertig; sehen Sie sie sich an, bevor Sie eigenesflexibleSpaceschreiben.
Häufige Fehler
1. Ein normales Widget in die slivers-Liste setzen
Symptom, ein roter Bildschirm mit dieser Meldung:
A RenderViewport expected a child of type RenderSliver but received a child of type RenderBox.CustomScrollView.slivers akzeptiert ausschließlich Sliver; Box-Widgets wie Container, Text oder Padding können nicht direkt hinein. Die Lösung: die Box in SliverToBoxAdapter einpacken:
slivers: [
const SliverAppBar(title: Text('Katalog')),
const SliverToBoxAdapter(
child: Padding(padding: EdgeInsets.all(16), child: Text('Empfehlungen')),
),
SliverList.builder(/* ... */),
]Weitere Mitglieder derselben Familie: SliverPadding für Ränder, SliverFillRemaining zum Füllen des Restplatzes, SliverGrid für Raster. Eine ListView in einen SliverToBoxAdapter zu setzen erzeugt einen anderen Fehler („Vertical viewport was given unbounded height“); die Liste gehört von vornherein als SliverList geschrieben.
2. snap ohne floating aktivieren
Symptom: Die App bleibt schon im ersten Frame mit dieser Assertion stehen:
The "snap" argument only makes sense for floating app bars.snap: true kommt immer zusammen mit floating: true. pinned ist von beiden unabhängig; alle drei gleichzeitig sind gültig.
3. Höhe von bottom passt nicht zur Inhaltshöhe
Symptom: Der untere Teil der Chips wirkt abgeschnitten, oder in der Konsole steht „A RenderFlex overflowed by N pixels on the bottom“. PreferredSize.preferredSize sagt der App Bar, wie viel Platz sie reservieren soll; ist die SizedBox oder das Padding darin höher, läuft es über, ist es niedriger, bleibt eine Lücke. Leiten Sie beides aus derselben Konstante ab:
const kChipRowHeight = 56.0;
PreferredSize(
preferredSize: const Size.fromHeight(kChipRowHeight),
child: SizedBox(height: kChipRowHeight, child: /* Chip-Zeile */),
)4. Den TextEditingController in build erzeugen
Symptom: Ein Tipp auf einen Chip ruft setState auf, und der Text im Suchfeld verschwindet. Haben Sie TextEditingController() in build geschrieben, entsteht bei jedem Neuaufbau ein neuer Controller, und der alte Text geht verloren. Halten Sie den Controller in einem Feld des State und geben Sie ihn in dispose frei; das Szenario unten macht das.
Mini-Szenario: ein durchsuchbarer Produktkatalog
Stellen Sie sich einen Produktkatalog vor: oben ein einklappender Titel, darunter ein festes Suchfeld, darunter eine fixierte Kategoriezeile und die gefilterte Liste. Suchtext und gewählte Kategorie filtern die Liste gemeinsam; ist das Ergebnis leer, erscheint eine Meldung in der Mitte des Screens.
import 'package:flutter/material.dart';
typedef Product = ({String name, String category});
const _products = <Product>[
(name: 'Kabellose Kopfhörer', category: 'Elektronik'),
(name: 'Mechanische Tastatur', category: 'Elektronik'),
(name: 'Laufschuhe', category: 'Sport'),
(name: 'Yogamatte', category: 'Sport'),
(name: 'Flutter-Buch', category: 'Bücher'),
(name: 'Algorithmen-Buch', category: 'Bücher'),
];
const _categories = ['Alle', 'Elektronik', 'Sport', 'Bücher'];
const _headerHeight = 56.0;
class CatalogPage extends StatefulWidget {
const CatalogPage({super.key});
@override
State<CatalogPage> createState() => _CatalogPageState();
}
class _CatalogPageState extends State<CatalogPage> {
final _searchController = TextEditingController();
String _query = '';
String _selected = 'Alle';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Product> get _filtered => _products.where((p) {
final inCategory = _selected == 'Alle' || p.category == _selected;
final matches = p.name.toLowerCase().contains(_query.toLowerCase());
return inCategory && matches;
}).toList();
Widget _buildCategoryChips() {
return ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _categories.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = _categories[index];
return ChoiceChip(
label: Text(category),
selected: category == _selected,
onSelected: (_) => setState(() => _selected = category),
);
},
);
}
@override
Widget build(BuildContext context) {
final items = _filtered;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 160,
flexibleSpace: const FlexibleSpaceBar(
title: Text('Katalog'),
titlePadding: EdgeInsets.only(left: 16, bottom: 72),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
hintText: 'Produkte suchen...',
prefixIcon: const Icon(Icons.search),
isDense: true,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
height: _headerHeight,
child: _buildCategoryChips(),
),
),
if (items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('Keine Treffer')),
)
else
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final product = items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(product.name),
subtitle: Text(product.category),
);
},
),
],
),
);
}
}_StickyHeaderDelegate ist die Klasse aus dem vorigen Abschnitt. Hier stecken vier Entscheidungen. Der Titel sitzt in flexibleSpace, weil er nur informiert; der untere Wert von titlePadding ist aus der Höhe des bottom-Bereichs abgeleitet, damit der Titel über dem Suchfeld bleibt. Das Suchfeld liegt in bottom und die App Bar ist pinned, also bleibt das Feld an Ort und Stelle, egal wo Sie in der Liste sind. Die Chip-Zeile ist ein eigener SliverPersistentHeader; sobald die App Bar zusammengeklappt ist, klebt sie direkt darunter, und dank shouldRebuild wird sie bei jeder Auswahl neu gezeichnet. Das leere Ergebnis übernimmt SliverFillRemaining; mit hasScrollBody: false sitzt die Meldung zentriert im Restplatz, und anders als bei SliverToBoxAdapter muss keine Höhe berechnet werden. Weil der TextEditingController im State lebt, überlebt der Suchtext eine Chip-Auswahl.
Häufig gestellte Fragen
Was ist der Unterschied zwischen SliverAppBar und AppBar?
AppBar ist eine Box mit fester Höhe, die in Scaffold.appBar sitzt. SliverAppBar lebt in einer CustomScrollView und reagiert aufs Scrollen: Sie klappt mit expandedHeight auf, klappt beim Scrollen der Liste zusammen, und pinned, floating und snap steuern, wann sie sichtbar ist. Muss der obere Bereich nicht aufs Scrollen reagieren, reicht eine klassische AppBar.
Was bedeutet „A RenderViewport expected a child of type RenderSliver“?
Sie haben ein normales Box-Widget in die Liste CustomScrollView.slivers gesetzt. Packen Sie dieses Widget in SliverToBoxAdapter; für Listen verwenden Sie SliverList, für Ränder SliverPadding.
Gehört das Suchfeld in bottom oder in flexibleSpace?
Wenn der Nutzer jederzeit sucht: bottom mit pinned: true. Darf das Feld beim Scrollen verschwinden und soll bei einer Aufwärtsgeste zurückkommen: flexibleSpace mit floating und snap. Für interaktive Elemente ist bottom der Standard.
Was ist der Unterschied zwischen SliverPersistentHeader und SliverAppBar?
SliverAppBar ist eine fertige Toolbar: Titel, Icons, bottom und Themenfarben sind dabei. SliverPersistentHeader ist eine leere Leinwand; was gezeichnet wird und wie hoch es ist, bestimmen Sie über den Delegate. Für Abschnittsüberschriften und Chip-Zeilen, die mitten in einer Liste kleben, passt der zweite.
Können pinned, floating und snap zusammen verwendet werden?
Ja. snap setzt nur floating voraus; pinned ist unabhängig. Mit allen dreien bleibt der bottom-Bereich fest, während der erweiterte obere Bereich beim ersten Hochscrollen zurückkehrt und sich vollständig öffnet, statt auf halbem Weg stehen zu bleiben.
Verwandte Artikel
Flutter: Helles und dunkles Theme mit ThemeData
Ein Flutter-Theme mit ThemeData aufbauen: ColorScheme.fromSeed, helles und dunkles Theme, ThemeMode, Theme.of, TextTheme, Komponenten-Themes, ThemeExtension.
Flutter: TextField und TextEditingController verwenden
TextField in Flutter: TextEditingController und dispose, InputDecoration, Tastaturtypen, inputFormatters, Passwortfelder, FocusNode und onChanged im Vergleich.
Flutter: Widgets überlagern mit Stack und Positioned
Stack und Positioned in Flutter: wie ein Stack seine Größe bestimmt, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, Badge und Overlays.