SliverAppBar collapsible en Flutter: ejercicio resuelto

SliverAppBar collapsible en Flutter: ejercicio resuelto

AppBar es fijo. SliverAppBar dentro de un CustomScrollView se expande, colapsa y ancla según el scroll, creando el efecto de cabecera que ves en perfiles de redes sociales o pantallas de detalle de producto.

Enunciado

Crea una pantalla de perfil con:

  • Cabecera colapsable con imagen de fondo y nombre de usuario.
  • Tabs ancladas al tope cuando la cabecera colapsa completamente.
  • Lista de contenido con 30 ítems debajo de los tabs.
  • Botón de acción flotante que se oculta al bajar y reaparece al subir.

Dependencias

Solo Flutter SDK.

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() => runApp(const MaterialApp(home: ProfilePage()));

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

  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage>
    with SingleTickerProviderStateMixin {
  late final TabController _tabController;
  late final ScrollController _scrollController;
  bool _fabVisible = true;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
    _scrollController = ScrollController()
      ..addListener(() {
        // Ocultar FAB al bajar, mostrar al subir
        final direction = _scrollController.position.userScrollDirection;
        setState(() {
          _fabVisible = direction != ScrollDirection.reverse;
        });
      });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: AnimatedScale(
        scale: _fabVisible ? 1 : 0,
        duration: const Duration(milliseconds: 200),
        child: FloatingActionButton(
          onPressed: () {},
          child: const Icon(Icons.add),
        ),
      ),
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          // ── Cabecera colapsable ──────────────────────────────────────────
          SliverAppBar(
            expandedHeight: 260,
            pinned: true,   // se ancla al tope al colapsar
            floating: false,
            stretch: true,  // se estira al hacer overscroll hacia arriba
            flexibleSpace: FlexibleSpaceBar(
              title: const Text(
                'Ana García',
                style: TextStyle(
                  shadows: [Shadow(blurRadius: 8, color: Colors.black54)],
                ),
              ),
              background: Stack(
                fit: StackFit.expand,
                children: [
                  // Imagen de cabecera (reemplaza por NetworkImage en prod)
                  Container(
                    decoration: const BoxDecoration(
                      gradient: LinearGradient(
                        begin: Alignment.topLeft,
                        end: Alignment.bottomRight,
                        colors: [Color(0xFF1a3a5c), Color(0xFF4fc3f7)],
                      ),
                    ),
                  ),
                  // Avatar centrado
                  Positioned(
                    bottom: 48,
                    left: 0,
                    right: 0,
                    child: Center(
                      child: CircleAvatar(
                        radius: 44,
                        backgroundColor: Colors.white,
                        child: CircleAvatar(
                          radius: 40,
                          backgroundColor: Colors.blue.shade200,
                          child: const Icon(Icons.person, size: 40, color: Colors.white),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
              collapseMode: CollapseMode.parallax,
            ),
          ),

          // ── Estadísticas de perfil ───────────────────────────────────────
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: const [
                  _StatItem(value: '142', label: 'Posts'),
                  _StatItem(value: '4.2K', label: 'Seguidores'),
                  _StatItem(value: '318', label: 'Siguiendo'),
                ],
              ),
            ),
          ),

          // ── Tabs ancladas ────────────────────────────────────────────────
          SliverPersistentHeader(
            pinned: true,
            delegate: _TabBarDelegate(
              TabBar(
                controller: _tabController,
                tabs: const [
                  Tab(icon: Icon(Icons.grid_on)),
                  Tab(icon: Icon(Icons.bookmark_border)),
                  Tab(icon: Icon(Icons.person_outline)),
                ],
              ),
            ),
          ),

          // ── Lista de contenido ───────────────────────────────────────────
          SliverList.builder(
            itemCount: 30,
            itemBuilder: (_, index) => ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.blue.shade100,
                child: Text('${index + 1}'),
              ),
              title: Text('Post número ${index + 1}'),
              subtitle: const Text('Hace 2 horas · ❤️ 24 comentarios'),
              trailing: const Icon(Icons.more_vert),
            ),
          ),
        ],
      ),
    );
  }
}

// ── Widgets auxiliares ────────────────────────────────────────────────────────

class _StatItem extends StatelessWidget {
  final String value;
  final String label;
  const _StatItem({required this.value, required this.label});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
        const SizedBox(height: 2),
        Text(label, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)),
      ],
    );
  }
}

/// Delegate para SliverPersistentHeader con altura fija (TabBar).
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar tabBar;
  const _TabBarDelegate(this.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 ColoredBox(
      color: Theme.of(context).scaffoldBackgroundColor,
      child: tabBar,
    );
  }

  @override
  bool shouldRebuild(_TabBarDelegate old) => old.tabBar != tabBar;
}

Resultado esperado

  • Al hacer scroll hacia abajo, la cabecera con la imagen se colapsa hasta quedar solo el AppBar con el nombre.
  • Los tabs se anclan justo debajo del AppBar y no desaparecen.
  • El FAB se oculta suavemente al bajar y reaparece al subir.

Parámetros clave de SliverAppBar

ParámetroEfecto
pinned: trueEl AppBar colapsado permanece visible
floating: trueEl AppBar reaparece al primer scroll hacia arriba
snap: trueRequiere floating: true; el AppBar salta al completo al aparecer
stretch: trueSe estira al hacer overscroll (iOS style)
expandedHeightAltura máxima cuando está expandido

Errores frecuentes

  • SliverAppBar fuera de CustomScrollView: lanza FlutterError: SliverAppBar must be used inside a CustomScrollView.
  • Usar Scaffold.appBar junto a SliverAppBar: se duplican las AppBars. Usa solo una.
  • SliverPersistentHeader sin delegate: la clase abstracta necesita implementación o usa SliverToBoxAdapter + Positioned para casos simples.

Aplicación práctica

Pantallas de perfil, fichas de producto en e-commerce, listas con cabecera informativa y cualquier UI donde el contenido principal necesita maximizar el espacio de lectura.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Cómo añado una imagen real de red como fondo?

Reemplaza el Container con gradiente por:

1
Image.network(url, fit: BoxFit.cover)

Dentro del Stack de FlexibleSpaceBar.background.

¿Cómo consigo el efecto parallax?

collapseMode: CollapseMode.parallax en FlexibleSpaceBar. Con CollapseMode.pin el fondo permanece fijo; con parallax se mueve más lento que el scroll, creando profundidad.

¿Puedo tener varios SliverList en el mismo CustomScrollView?

Sí. Añade tantos slivers como quieras en la lista slivers: [...]. Se renderizan en orden vertical.