Nested Scrolling
Understand how to handle nested scrollable widgets in Flutter.
What is it?
Nested scrolling occurs when you have scrollable widgets inside other scrollable widgets (e.g., a ListView inside a CustomScrollView, or a TabBarView with lists). Flutter provides mechanisms to handle nested scrolling so that parent and child scroll views work together smoothly, without conflicts or unexpected behavior.
Why does it exist?
Nested scrolling exists to:
- Enable complex scrollable layouts
- Combine multiple scrollable widgets
- Handle scroll conflicts automatically
- Create smooth scrolling experiences
- Support nested scroll views
- Implement complex UI patterns
- Provide flexible scrollable hierarchies
Nested ScrollView
NestedScrollView handles nested scrolling.
// Basic NestedScrollView
class BasicNestedScrollView extends StatelessWidget {
const BasicNestedScrollView({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
title: const Text('Nested Scroll'),
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
background: ColoredBox(
color: Colors.blue,
child: Center(
child: Text(
'Header',
style: TextStyle(color: Colors.white),
),
),
),
),
pinned: true,
bottom: const TabBar(
tabs: [
Tab(text: 'Tab 1'),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3'),
],
),
),
];
},
body: TabBarView(
children: [
// Each tab has its own scrollable content
_buildTabContent('Tab 1', Colors.blue),
_buildTabContent('Tab 2', Colors.green),
_buildTabContent('Tab 3', Colors.orange),
],
),
),
),
);
}
Widget _buildTabContent(String label, Color color) {
return ListView.builder(
itemCount: 30,
itemBuilder: (context, index) {
return Container(
height: 60,
color: color.withOpacity(0.3 + (index % 5) * 0.1),
margin: const EdgeInsets.all(4),
child: Center(
child: Text(
'$label - Item $index',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
},
);
}
}
// NestedScrollView properties:
// 1. headerSliverBuilder - Builds header slivers
// 2. body - Main scrollable content
// 3. physics - Scroll physics
// 4. controller - Scroll controller
// 5. floatHeaderSlivers - Whether headers float
What's happening here? - NestedScrollView manages nested scrolling - Header slivers scroll with content - TabBarView contains scrollable lists - Smooth scrolling between header and body
NestedScrollView with Multiple Tabs
NestedScrollView with tabbed content.
// NestedScrollView with tabs
class TabbedNestedScrollView extends StatefulWidget {
const TabbedNestedScrollView({super.key});
@override
State<TabbedNestedScrollView> createState() => _TabbedNestedScrollViewState();
}
class _TabbedNestedScrollViewState extends State<TabbedNestedScrollView>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
title: const Text('Tabbed Nested Scroll'),
expandedHeight: 150,
flexibleSpace: const FlexibleSpaceBar(
background: ColoredBox(
color: Colors.blue,
child: Center(
child: Text(
'Profile Header',
style: TextStyle(color: Colors.white),
),
),
),
),
pinned: true,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.list), text: 'Posts'),
Tab(icon: Icon(Icons.photo), text: 'Photos'),
Tab(icon: Icon(Icons.person), text: 'About'),
],
),
),
];
},
body: TabBarView(
controller: _tabController,
children: [
// Posts tab
ListView.builder(
itemCount: 30,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text('Post ${index + 1}'),
subtitle: Text('This is post number ${index + 1}'),
trailing: const Icon(Icons.favorite_border),
);
},
),
// Photos tab
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 30,
itemBuilder: (context, index) {
return Container(
color: Colors.blue[100 * (index % 9 + 1)],
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(color: Colors.white),
),
),
);
},
),
// About tab
ListView(
children: [
ListTile(
leading: const Icon(Icons.person),
title: const Text('Name'),
subtitle: const Text('John Doe'),
),
ListTile(
leading: const Icon(Icons.email),
title: const Text('Email'),
subtitle: const Text('john@example.com'),
),
ListTile(
leading: const Icon(Icons.phone),
title: const Text('Phone'),
subtitle: const Text('+1 234 567 890'),
),
ListTile(
leading: const Icon(Icons.location_on),
title: const Text('Location'),
subtitle: const Text('New York, USA'),
),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'Bio: Flutter developer with 5 years of experience.',
),
),
],
),
],
),
),
);
}
}
What's happening here? - TabController manages tabs - Each tab has different scrollable content - Header stays pinned while tabs change - NestedScrollView coordinates scrolling
Nested Scroll with CustomScrollView
CustomScrollView with nested scrolling.
// Nested scrolling with CustomScrollView
class NestedCustomScrollView extends StatelessWidget {
const NestedCustomScrollView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// App bar
SliverAppBar(
title: const Text('Nested Custom Scroll'),
expandedHeight: 150,
flexibleSpace: const FlexibleSpaceBar(
background: ColoredBox(
color: Colors.blue,
child: Center(
child: Text(
'Header',
style: TextStyle(color: Colors.white),
),
),
),
),
pinned: true,
),
// Section 1: Horizontal scroll
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Horizontal Scroll',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 15,
itemBuilder: (context, index) {
return Container(
width: 100,
margin: const EdgeInsets.symmetric(horizontal: 4),
color: Colors.blue[100 * (index % 9 + 1)],
child: Center(
child: Text(
'H $index',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
);
},
),
),
),
// Section 2: Vertical list
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Vertical List',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(
height: 60,
color: Colors.green[100 * (index % 9 + 1)],
margin: const EdgeInsets.all(4),
child: Center(
child: Text(
'Item $index',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
childCount: 20,
),
),
// Section 3: Grid
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Grid',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(
color: Colors.orange[100 * (index % 9 + 1)],
child: Center(child: Text('$index')),
),
childCount: 12,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
),
],
),
);
}
}
What's happening here? - CustomScrollView with mixed content - Horizontal scroll nested inside vertical - Different sliver types combined - Smooth nested scrolling
Scroll Conflicts
Handling scroll conflicts in nested scroll.
// Handling scroll conflicts
class ScrollConflictExample extends StatelessWidget {
const ScrollConflictExample({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scroll Conflict'),
),
body: Column(
children: [
// Fixed header
Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Text(
'Fixed Header',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
// Nested scrollable content
Expanded(
child: ListView.builder(
itemCount: 30,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Child scrollable widget
Container(
padding: const EdgeInsets.all(8),
child: const Text(
'Section Header',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
// Nested horizontal scroll
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, childIndex) {
return Container(
width: 80,
margin: const EdgeInsets.all(4),
color: Colors.blue[100 * ((index + childIndex) % 9 + 1)],
child: Center(
child: Text(
'${index + 1}-${childIndex + 1}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
);
},
),
),
],
),
);
},
),
),
],
),
);
}
}
What's happening here? - Vertical scroll with nested horizontal scroll - No conflict because directions differ - Horizontal scroll works independently - Parent scrolls vertically, child scrolls horizontally
Real-World Examples
Common patterns with nested scrolling.
// 1. Profile with tabs (Instagram-like)
class InstagramProfile extends StatelessWidget {
const InstagramProfile({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
title: const Text('@john_doe'),
expandedHeight: 250,
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.blue, Colors.purple],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircleAvatar(
radius: 50,
backgroundImage: NetworkImage('https://example.com/avatar.jpg'),
),
const SizedBox(height: 16),
const Text(
'John Doe',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const Text(
'Flutter Developer | Blogger',
style: TextStyle(
color: Colors.white70,
fontSize: 16,
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildStat('Posts', '150'),
_buildStat('Followers', '2.5K'),
_buildStat('Following', '350'),
],
),
],
),
),
),
bottom: TabBar(
tabs: const [
Tab(icon: Icon(Icons.grid_on)),
Tab(icon: Icon(Icons.video_library)),
Tab(icon: Icon(Icons.person)),
],
indicatorColor: Colors.white,
),
),
];
},
body: TabBarView(
children: [
// Grid posts
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 30,
itemBuilder: (context, index) {
return Container(
color: Colors.blue[100 * (index % 9 + 1)],
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(color: Colors.white),
),
),
);
},
),
// Videos
ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(
leading: Container(
width: 80,
height: 60,
color: Colors.blue[100 * (index % 9 + 1)],
child: const Icon(Icons.play_arrow, color: Colors.white),
),
title: Text('Video ${index + 1}'),
subtitle: Text('${index + 1} min ago'),
);
},
),
// About
ListView(
children: const [
ListTile(
leading: Icon(Icons.person),
title: Text('Name'),
subtitle: Text('John Doe'),
),
ListTile(
leading: Icon(Icons.email),
title: Text('Email'),
subtitle: Text('john@example.com'),
),
ListTile(
leading: Icon(Icons.link),
title: Text('Website'),
subtitle: Text('johndoe.dev'),
),
],
),
],
),
),
),
);
}
Widget _buildStat(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
],
);
}
}
// 2. Product page with sections
class ProductPageNested extends StatelessWidget {
const ProductPageNested({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
title: const Text('Product'),
expandedHeight: 200,
flexibleSpace: const FlexibleSpaceBar(
background: ColoredBox(
color: Colors.blue,
child: Center(
child: Icon(
Icons.shopping_bag,
size: 80,
color: Colors.white,
),
),
),
),
pinned: true,
),
// Product info
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Product Name',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'\$99.99',
style: TextStyle(
fontSize: 20,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
Text(
'Product description goes here. '
'This is a detailed description of the product.',
style: TextStyle(fontSize: 16),
),
],
),
),
),
// Related products (horizontal scroll)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Related Products',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) {
return Container(
width: 150,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.blue[100 * (index % 9 + 1)],
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.shopping_bag,
color: Colors.white,
size: 40,
),
const SizedBox(height: 8),
Text(
'Product ${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
);
},
),
),
),
// Reviews
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Reviews',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text('Review ${index + 1}'),
subtitle: Text('Great product! ${index + 1} stars'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
color: Colors.yellow[700],
size: 16,
),
Text(' ${5 - (index % 2)}'),
],
),
),
childCount: 10,
),
),
],
),
);
}
}
What's happening here? - Instagram-like profile with tabs - Product page with horizontal scroll sections - Nested scrolling in complex layouts - Real-world scrolling patterns
Best Practices
Use NestedScrollView for Tabbed Content
// Good - NestedScrollView with tabs
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
title: const Text('Title'),
bottom: const TabBar(tabs: [...])
),
];
},
body: TabBarView(children: [...]),
)
Avoid Scroll Conflicts
// Good - Different scroll directions
ListView.builder(
itemBuilder: (context, index) {
return ListView.builder(
scrollDirection: Axis.horizontal, // Different direction
itemBuilder: (context, childIndex) => ...,
);
},
)
Use Appropriate Physics
// Good - Physics for nested scroll
ListView.builder(
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(), // Disable nested scroll
scrollDirection: Axis.horizontal,
itemBuilder: (context, childIndex) => ...,
);
},
)
Common Mistakes
Conflicting Scroll Directions
Wrong:
// Both scrolling vertically - conflict
ListView.builder(
itemBuilder: (context, index) {
return ListView.builder( // Conflicts with parent
itemBuilder: (context, childIndex) => ...,
);
},
)
Correct:
// Different directions or disable nested
ListView.builder(
itemBuilder: (context, index) {
return ListView.builder(
scrollDirection: Axis.horizontal, // No conflict
itemBuilder: (context, childIndex) => ...,
);
},
)
Forgetting NestedScrollView
Wrong:
// Manual nesting without coordination
Scaffold(
body: Column(
children: [
SliverAppBar(...), // Not a sliver
Expanded(
child: ListView.builder(...), // Won't coordinate
),
],
),
)
Correct:
// Use NestedScrollView
Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [SliverAppBar(...)];
},
body: ListView.builder(...),
),
)
Summary
Nested scrolling enables complex scrollable layouts where multiple scrollable widgets work together. Use NestedScrollView for tabbed content with headers, CustomScrollView for mixed slivers, and be mindful of scroll directions to avoid conflicts.
Next Steps
Did You Know?
- NestedScrollView coordinates parent-child scrolling
- TabBarView works with NestedScrollView
- Scroll directions should differ to avoid conflicts
- CustomScrollView combines multiple slivers
- SliverAppBar can be pinned in nested scroll
- Nested scroll is used in most apps
- TabController manages tab navigation
- NestedScrollView is efficient and smooth