skip to content

How would you build a Flutter hotel-detail page whose photo header collapses on scroll while a tab row sticks below it, using slivers?

level: seniorimportance: should knowfreq 35%

answer

  1. pinned headers stack via overlap
  2. SliverAppBar plus FlexibleSpaceBar
  3. delegate: minExtent, maxExtent, shouldRebuild
  4. child must fill the extent
  5. PinnedHeaderSliver since 3.24

basics

~20 s

Use a CustomScrollView: a pinned SliverAppBar with expandedHeight and a FlexibleSpaceBar photo, then a pinned SliverPersistentHeader (or PinnedHeaderSliver) holding the tabs, then lazy SliverLists; pinned slivers stack, so the tabs stick under the collapsed bar.

solid answer

~40 s

I build one `CustomScrollView`. First a `SliverAppBar` with `pinned: true`, an `expandedHeight` and a `FlexibleSpaceBar` whose `background` is the photo. Then the tab row in a `SliverPersistentHeader(pinned: true)` whose delegate returns equal `minExtent` and `maxExtent` (the tab bar's preferred height), builds a widget that fills that extent, and implements `shouldRebuild`. Pinned slivers receive the previous pinned extent as `overlap`, so the tabs stick right below the collapsed app bar. Since Flutter 3.24, `PinnedHeaderSliver` does the same without a delegate, and `SliverAppBar.bottom` is the simplest option when the tabs can live inside the bar. The usual bugs: `minExtent` greater than `maxExtent`, a child shorter than the extent (a `layoutExtent` exceeds `paintExtent` assertion in debug builds) and a `shouldRebuild` that returns `false` when the tabs change.

code

dart · 62 lines
dart
import 'package:flutter/material.dart';

class _StickyTabsDelegate extends SliverPersistentHeaderDelegate {
  const _StickyTabsDelegate(this.tabBar);

  final TabBar tabBar;

  @override
  double get minExtent => tabBar.preferredSize.height;

  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return SizedBox.expand(
      child: Material(
        color: Theme.of(context).colorScheme.surface,
        elevation: overlapsContent ? 2 : 0,
        child: tabBar,
      ),
    );
  }

  @override
  bool shouldRebuild(_StickyTabsDelegate oldDelegate) => oldDelegate.tabBar != tabBar;
}

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

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        body: CustomScrollView(
          slivers: [
            SliverAppBar(
              pinned: true,
              expandedHeight: 280,
              flexibleSpace: FlexibleSpaceBar(
                title: const Text('Harbour View Hotel'),
                background: Image.asset('assets/hotel_cover.jpg', fit: BoxFit.cover),
              ),
            ),
            const SliverPersistentHeader(
              pinned: true,
              delegate: _StickyTabsDelegate(
                TabBar(tabs: [Tab(text: 'Rooms'), Tab(text: 'Amenities'), Tab(text: 'Reviews')]),
              ),
            ),
            SliverList.builder(
              itemCount: 30,
              itemBuilder: (context, index) => ListTile(title: Text('Room type ${index + 1}')),
            ),
          ],
        ),
      ),
    );
  }
}

go deeper

for a junior

Recall that a pinned SliverAppBar collapses to a toolbar and that another pinned sliver can hold the tabs.

for a middle

Explain the delegate members, what shrinkOffset and overlapsContent mean, and how pinned slivers stack through overlap.

for a senior

Diagnose the min-over-max and layoutExtent assertions and a frozen header from shouldRebuild, and choose between bottom, a delegate and PinnedHeaderSliver.

for a principal

Decide when a detail page stays one scroll with section jumps and when it becomes a nested scroll with independent tab bodies.

## The shape of the page A hotel-detail screen with a collapsing photo and sticky tabs is a textbook sliver composition. One `CustomScrollView` holds, in order: 1. **`SliverAppBar`** — `pinned: true`, `expandedHeight` around 250 to 300, `flexibleSpace: FlexibleSpaceBar(background: photo, title: name)`. 2. **The tab row** — a pinned header sliver that stays directly beneath the collapsed app bar. 3. **Content sections** — `SliverList.builder` for rooms, `SliverToBoxAdapter` for the amenities block, `SliverList` for reviews. 4. Optionally **`SliverFillRemaining(hasScrollBody: false)`** with a booking button when the content is short. ## The collapsing photo header `SliverAppBar` shrinks from `expandedHeight` to its collapsed height (toolbar plus any `bottom`, plus the status-bar padding). `FlexibleSpaceBar` lays out the expanded area; its default `collapseMode` is `CollapseMode.parallax`, so the photo slides slower than the content, and its title scales down into the toolbar. `stretch: true` adds the pull-to-zoom effect on bouncing physics. ## Sticky tabs: three ways | Approach | How | Fits when | |---|---|---| | `SliverAppBar.bottom` | Pass the tab row as the bar's `bottom` | Tabs sit flush under the toolbar and share its background | | `SliverPersistentHeader(pinned: true)` | Write a `SliverPersistentHeaderDelegate` | You need custom sizing, a shadow when content scrolls under, or a resizing header | | `PinnedHeaderSliver` (Flutter 3.24+) | Wrap the tab row as its `child` | A fixed-height sticky row with no delegate boilerplate | Why the tabs stop *below* the app bar and not on top of it: the viewport passes each sliver an `overlap` equal to the space pinned slivers before it already occupy, and a pinned persistent header paints from that origin. Pinned headers therefore **stack**. `SliverResizingHeader` (also 3.24) covers the in-between case of a header that pins and resizes between two prototype sizes. ## The delegate contract A `SliverPersistentHeaderDelegate` has four members to get right: - **`build(context, shrinkOffset, overlapsContent)`** — `shrinkOffset` runs from 0 (full `maxExtent`) to `maxExtent - minExtent`; `overlapsContent` is typically true once content scrolls beneath, which is when to add elevation. - **`minExtent`** and **`maxExtent`** — the header shrinks between them; `minExtent` must not exceed `maxExtent`. For a fixed tab row both equal the tab bar's `preferredSize.height`. - **`shouldRebuild(oldDelegate)`** — must return `true` whenever a new delegate would produce different extents or a different widget; returning `false` freezes the header. - The extents should be derived only from constructor arguments, never from mutable state. ## Failure modes seen in production - **`The maxExtent for this ... is less than its minExtent.`** — extents swapped or computed from inconsistent sources. - **`SliverGeometry is not valid: The "layoutExtent" exceeds the "paintExtent".`** — the header's child is laid out with loose constraints up to the current extent, so a 40-pixel child in a 48-pixel header paints less than it lays out. This is a debug-build assertion; fix it by wrapping the child in `SizedBox.expand` or by returning the child's real height as the extents. - **Tabs that never update** — `shouldRebuild` hard-coded to `false` while the selected-tab styling lives in the delegate. - **Header drawn under the status bar** — the pinned header placed before the app bar, or a `SliverAppBar` with `primary: false`. ## What slivers alone do not solve Tapping a tab here scrolls to a section of **one** list, for example with `Scrollable.ensureVisible` on a key placed in each section. If each tab must instead show its own independently scrolling body that you swipe between, that is `NestedScrollView` with a `TabBarView` — a different widget with its own overlap-absorber rules, and a different question.

  • When is PinnedHeaderSliver a better choice than SliverPersistentHeader?
    When the sticky row has one fixed height and no shrink behaviour. `PinnedHeaderSliver` (Flutter 3.24+) takes a plain `child`, sizes to it, and needs no delegate, extents or `shouldRebuild`. Keep `SliverPersistentHeader` for headers that shrink between two extents or need `shrinkOffset` and `overlapsContent` in their build.
  • What does shrinkOffset tell the delegate's build method?
    How far the header has shrunk from `maxExtent` toward `minExtent`: 0 when fully expanded, `maxExtent - minExtent` when fully collapsed. Dividing it by that difference gives a 0-to-1 progress value for fading a title or scaling an image. For a fixed-height tab row it stays 0.
  • The tabs must each show their own list that the user swipes between. Does this design still work?
    No. One `CustomScrollView` has one scroll position, so swipeable, independently scrolling tab bodies need `NestedScrollView` with a `TabBarView`, where the header slivers live in `headerSliverBuilder`. Within a single scroll, tabs can only jump to sections of the same list.

saying these in an interview costs you the question

  • Two pinned headers will draw on top of each other.
  • minExtent can be larger than maxExtent for a sticky row.
  • shouldRebuild can always return false for a performance win.
  • The header child is stretched to the extent automatically.
  • Sticky tabs with swipeable bodies need only one CustomScrollView.