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

Flutter: SliverAppBar for Category Headers and Search Fields

Ahmet Balaman

Last updated:

9 min read

FlutterSliverAppBarSliverPersistentHeaderCustomScrollViewSearchCategory
Flutter: SliverAppBar for Category Headers and Search Fields

Catalog, store and content-feed screens share one layout: a search field at the top, a row of horizontally scrolling category chips under it, and as the list scrolls the big title collapses while search and chips stay put. This post builds exactly that layout: where to place the search field between the bottom and flexibleSpace areas of SliverAppBar, what each combination of pinned, floating and snap does, and how to write a SliverPersistentHeader for a sticky category row that is independent of the app bar. Along the way we also build the large-image header that collapses on scroll, common on detail screens.

Live Demo

You can try AppBar structures in the interactive example below:

💡 If the example above doesn't load, click DartPad to run it in a new tab.

Basic Structure: a Search Field in bottom

SliverAppBar only lives inside the slivers list of a CustomScrollView. The simplest setup is a pinned app bar with a search field in its bottom area:

CustomScrollView(
  slivers: [
    SliverAppBar(
      pinned: true,
      title: const Text('Categories'),
      bottom: PreferredSize(
        preferredSize: const Size.fromHeight(64),
        child: Padding(
          padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
          child: TextField(
            decoration: InputDecoration(
              hintText: 'Search categories...',
              prefixIcon: const Icon(Icons.search),
              border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
              isDense: true,
            ),
          ),
        ),
      ),
    ),
    SliverList.builder(
      itemCount: 40,
      itemBuilder: (context, index) => ListTile(title: Text('Category $index')),
    ),
  ],
)

The bottom slot expects a PreferredSizeWidget; PreferredSize lets you declare a height for any widget. With pinned: true, SliverAppBar never hides its bottom while collapsing: the toolbar and the bottom stay at the top together. It is the same mechanism as bottom on a classic AppBar, which is covered in the AppBar post.

pinned, floating and snap Combinations

The three flags look independent, but it is the combination that defines the behavior:

Combination What happens on scroll When to pick it
none The app bar scrolls away with the content and returns only at the top Title is unimportant, space is precious
pinned Collapses, but toolbar + bottom stay at the top Search and chips must always be reachable
floating Scrolls away on the way down, reappears as soon as you scroll up Search is secondary but one flick away
floating + snap Same, but never half-open: it fully opens or fully closes You do not want a jittery, half-visible app bar
pinned + floating bottom stays fixed, the expanded area above it returns when scrolling up Fixed search plus a returning header

snap cannot be used without floating; get the combination wrong and you meet the assertion in the mistakes section below. expandedHeight and flexibleSpace are independent of this table: they define how much room the app bar takes when open and what is drawn in that room.

Search Field: bottom or flexibleSpace?

The two placements are two different product decisions.

bottom: together with pinned, the search field is always visible; the user can search from the bottom of the list too. This is the default for catalog and store screens.

flexibleSpace: the search field is part of the expanding area and collapses with the title as the list scrolls. Combine this placement with floating and snap and a small upward gesture brings it back:

SliverAppBar(
  floating: true,
  snap: true,
  expandedHeight: 120,
  title: const Text('Explore'),
  flexibleSpace: FlexibleSpaceBar(
    background: Align(
      alignment: Alignment.bottomCenter,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
        child: TextField(
          decoration: InputDecoration(
            hintText: 'Search...',
            prefixIcon: const Icon(Icons.search),
            filled: true,
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(24),
              borderSide: BorderSide.none,
            ),
          ),
        ),
      ),
    ),
  ),
)

Rule of thumb: put the area the user interacts with (search, filters) in bottom, and the area that only informs (category name, item count, image) in flexibleSpace. Placing an interactive control in the collapsing region raises the question "where did the search box go?".

Large-Image Header: expandedHeight and FlexibleSpaceBar

Detail and campaign screens often have a large image at the top; as the list scrolls the image collapses and a plain toolbar is left. expandedHeight sets the height while expanded, and FlexibleSpaceBar decides what is drawn in that area:

SliverAppBar(
  pinned: true,
  expandedHeight: 220,
  flexibleSpace: FlexibleSpaceBar(
    title: const Text('Explore', 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 draws the title large while expanded and small while collapsed, and by default scrolls the background more slowly than the content with CollapseMode.parallax. The darkening gradient at the bottom of the image keeps a white title readable on top of a light photo. Because of pinned: true, a toolbar-height bar stays at the top once the image has fully collapsed; without pinned, the title leaves the screen together with the image. Do not pick an expandedHeight so large that it hides the first rows of the list on small phones: the image is decoration, the list is the real content.

Category Chip Row Inside bottom

The chip row also fits into bottom; a horizontal ListView makes it scrollable. Keep the selected category in State and use 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),
        );
      },
    ),
  ),
),

If both the search field and the chip row go into bottom, put them in a Column and set preferredSize to the combined height. But making the chip row a sliver of its own, separate from the app bar, is usually more flexible; the next section shows how.

A Sticky Category Row with SliverPersistentHeader

When you want the chip row outside the app bar, say after a banner at the top of the list or as a section header, you use SliverPersistentHeader. This sliver takes a SliverPersistentHeaderDelegate; the delegate knows four things: the minimum and maximum height, what to draw and when to redraw:

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, // shadow once content slides underneath
      child: SizedBox.expand(child: child),
    );
  }

  @override
  bool shouldRebuild(covariant _StickyHeaderDelegate oldDelegate) =>
      child != oldDelegate.child || height != oldDelegate.height;
}

Usage:

SliverPersistentHeader(
  pinned: true,
  delegate: _StickyHeaderDelegate(child: _buildCategoryChips()),
),

When minExtent equals maxExtent the header does not shrink, it only sticks. Give them different values and you can read the collapse amount from shrinkOffset inside build, for instance to scale the chips down. overlapsContent becomes true once content slides under the header, which is ideal for drawing the shadow only at that moment. shouldRebuild matters: when a chip selection changes through setState, a new child is produced, the delegate notices and redraws the header. Return false unconditionally and the selection never shows on screen.

When to Use It and When Not To

Pick this layout when the body of the screen is one long list and the top area has to react to scrolling: a product catalog, a news feed, a library screen. Keeping search and chips in place while the list scrolls and collapsing the big title is only possible with slivers.

In these cases a simpler or different tool is right:

  • The top area does not react to scrolling: no need for CustomScrollView. Use a classic AppBar with the search field in bottom and a normal ListView as the body. Less code, same look.
  • Each category is its own page and the user swipes between them: use tabs instead of chips, see TabBar and TabBarView. A scrollable TabBar replaces the chip row.
  • Each tabbed page has its own scrollable list and the app bar should collapse above all of them: that needs NestedScrollView; CustomScrollView manages a single scroll area. The distinction is covered in the NestedScrollView section of the ScrollView and list widgets post.
  • You need the Material 3 medium and large title layouts: the SliverAppBar.medium and SliverAppBar.large constructors come ready-made; look at them before writing your own flexibleSpace.

Common Mistakes

1. Putting a normal widget in the slivers list

Symptom, a red screen with this message:

A RenderViewport expected a child of type RenderSliver but received a child of type RenderBox.

CustomScrollView.slivers accepts slivers only; box widgets such as Container, Text or Padding cannot go there directly. The fix is to wrap the box in SliverToBoxAdapter:

slivers: [
  const SliverAppBar(title: Text('Catalog')),
  const SliverToBoxAdapter(
    child: Padding(padding: EdgeInsets.all(16), child: Text('Featured')),
  ),
  SliverList.builder(/* ... */),
]

Other members of the same family: SliverPadding for margins, SliverFillRemaining to fill the leftover space, SliverGrid for grids. Putting a ListView inside SliverToBoxAdapter produces a different error ("Vertical viewport was given unbounded height"); the list should be written as a SliverList in the first place.

2. Enabling snap without floating

Symptom: the app stops on the very first frame with this assertion:

The "snap" argument only makes sense for floating app bars.

snap: true always comes together with floating: true. pinned is independent of the two; enabling all three is valid.

3. bottom height does not match the content height

Symptom: the lower part of the chips looks clipped, or the console shows "A RenderFlex overflowed by N pixels on the bottom". PreferredSize.preferredSize tells the app bar how much room to reserve; if the SizedBox or Padding inside is taller it overflows, if shorter there is a gap. Derive both from the same constant:

const kChipRowHeight = 56.0;

PreferredSize(
  preferredSize: const Size.fromHeight(kChipRowHeight),
  child: SizedBox(height: kChipRowHeight, child: /* chip row */),
)

4. Creating the TextEditingController inside build

Symptom: tapping a chip calls setState and the text in the search field disappears. If you wrote TextEditingController() inside build, every rebuild creates a new controller and the old text is lost. Keep the controller in a field of State and dispose it in dispose; the scenario below does that.

Mini Scenario: a Searchable Product Catalog

Picture a product catalog: a collapsing title at the top, a fixed search field under it, a sticky category row below that and the filtered list. Both the search text and the selected category filter the list; when the result is empty a message appears in the middle of the screen.

import 'package:flutter/material.dart';

typedef Product = ({String name, String category});

const _products = <Product>[
  (name: 'Wireless Headphones', category: 'Electronics'),
  (name: 'Mechanical Keyboard', category: 'Electronics'),
  (name: 'Running Shoes', category: 'Sports'),
  (name: 'Yoga Mat', category: 'Sports'),
  (name: 'Flutter Book', category: 'Books'),
  (name: 'Algorithms Book', category: 'Books'),
];

const _categories = ['All', 'Electronics', 'Sports', 'Books'];
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 = 'All';

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

  List<Product> get _filtered => _products.where((p) {
        final inCategory = _selected == 'All' || 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('Catalog'),
              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: 'Search products...',
                    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('No results')),
            )
          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 is the class from the previous section. Four decisions live here. The title sits in flexibleSpace because it only informs; the bottom value of titlePadding is derived from the height of the bottom area so the title stays above the search field. The search field is in bottom and the app bar is pinned, so wherever you are in the list the field stays in place. The chip row is a separate SliverPersistentHeader; once the app bar has collapsed it sticks right underneath, and thanks to shouldRebuild it redraws on every selection. SliverFillRemaining handles the empty result; with hasScrollBody: false the message is centered in the leftover space, and there is no height to calculate as there would be with SliverToBoxAdapter. Because the TextEditingController lives in State, the search text survives a chip selection.

Frequently Asked Questions

What is the difference between SliverAppBar and AppBar?

AppBar is a fixed-height box placed in Scaffold.appBar. SliverAppBar lives inside a CustomScrollView and reacts to scrolling: it expands with expandedHeight, collapses as the list scrolls, and pinned, floating and snap control when it is visible. If the top area does not need to react to scrolling, a classic AppBar is enough.

What does "A RenderViewport expected a child of type RenderSliver" mean?

You put a normal box widget into the CustomScrollView.slivers list. Wrap that widget in SliverToBoxAdapter; use SliverList for lists and SliverPadding for margins.

Should the search field go into bottom or flexibleSpace?

If the user searches at any moment, bottom with pinned: true; if the field may disappear while scrolling and should return on an upward gesture, flexibleSpace with floating and snap. For interactive controls the default is bottom.

What is the difference between SliverPersistentHeader and SliverAppBar?

SliverAppBar is a ready-made toolbar: title, icons, bottom and theme colors are included. SliverPersistentHeader is a blank canvas; you define what is drawn and how tall it is through the delegate. For section headers and chip rows that stick in the middle of a list, the second one fits.

Can pinned, floating and snap be used all together?

Yes. snap only requires floating; pinned is independent. With all three enabled the bottom area stays fixed, while the expanded top area returns as soon as you scroll up and opens fully instead of stopping halfway.

Comments