Flutter NestedScrollView Localization: Coordinated Scrolling for Multilingual Apps
NestedScrollView is a Flutter widget that coordinates scrolling between an outer scroll view and inner scrollable children, typically used with SliverAppBar and TabBarView. In multilingual applications, NestedScrollView is critical for tabbed interfaces where each tab contains translated content of different lengths, collapsing headers must accommodate translated titles, and tab labels need locale-aware sizing.
Understanding NestedScrollView in Localization Context
NestedScrollView synchronizes an outer scrollable area (usually containing a SliverAppBar with tabs) with inner scrollable content per tab. For multilingual apps, this enables:
- Collapsing headers with translated titles and tab labels that scroll smoothly
- Independent scrollable content per tab with different translated content lengths
- Tab labels that adapt width to accommodate translations of varying lengths
- Coordinated scroll behavior between header and localized tab content
Why NestedScrollView Matters for Multilingual Apps
NestedScrollView provides:
- Tab coordination: Each tab scrolls independently while sharing a collapsing header
- Header flexibility: Translated titles and subtitles in the collapsing region
- Tab label sizing: Scrollable tabs accommodate long translated labels
- Content independence: Each tab's translated content scrolls to its own extent
Basic NestedScrollView Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedNestedScrollViewExample extends StatelessWidget {
const LocalizedNestedScrollViewExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DefaultTabController(
length: 3,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
expandedHeight: 180,
pinned: true,
forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar(
title: Text(
l10n.exploreTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
bottom: TabBar(
isScrollable: true,
tabs: [
Tab(text: l10n.latestTab),
Tab(text: l10n.popularTab),
Tab(text: l10n.favoritesTab),
],
),
),
];
},
body: TabBarView(
children: [
_TabContent(label: l10n.latestTab, count: 20),
_TabContent(label: l10n.popularTab, count: 15),
_TabContent(label: l10n.favoritesTab, count: 8),
],
),
),
),
);
}
}
class _TabContent extends StatelessWidget {
final String label;
final int count;
const _TabContent({required this.label, required this.count});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: count,
itemBuilder: (context, index) {
return Card(
child: ListTile(
title: Text('$label - ${l10n.itemLabel} ${index + 1}'),
subtitle: Text(l10n.itemDescription),
),
);
},
);
}
}
Advanced NestedScrollView Patterns for Localization
Tabbed Categories with Localized Content
Category-based browsing interfaces use NestedScrollView for smooth header-to-content transitions with translated category names.
class LocalizedCategoryBrowser extends StatelessWidget {
const LocalizedCategoryBrowser({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = [
(l10n.categoryAll, Icons.apps),
(l10n.categoryArticles, Icons.article),
(l10n.categoryVideos, Icons.play_circle),
(l10n.categoryPodcasts, Icons.podcasts),
(l10n.categoryEvents, Icons.event),
];
return DefaultTabController(
length: categories.length,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
expandedHeight: 200,
pinned: true,
forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.contentLibraryTitle),
background: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Text(
l10n.contentLibrarySubtitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
),
bottom: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: categories.map((cat) {
return Tab(
icon: Icon(cat.$2),
text: cat.$1,
);
}).toList(),
),
),
];
},
body: TabBarView(
children: categories.map((cat) {
return _CategoryContent(categoryName: cat.$1);
}).toList(),
),
),
),
);
}
}
class _CategoryContent extends StatelessWidget {
final String categoryName;
const _CategoryContent({required this.categoryName});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 15,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$categoryName: ${l10n.contentTitle} ${index + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.contentExcerpt,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
);
}
}
Profile Page with Tabbed Sections
User profile pages combine a collapsing profile header with tabbed content sections, all using translated labels.
class LocalizedProfilePage extends StatelessWidget {
const LocalizedProfilePage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DefaultTabController(
length: 3,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
expandedHeight: 260,
pinned: true,
forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.userName),
background: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 60, 16, 60),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircleAvatar(
radius: 40,
child: Icon(Icons.person, size: 40),
),
const SizedBox(height: 12),
Text(
l10n.userBio,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
maxLines: 2,
),
],
),
),
),
),
bottom: TabBar(
tabs: [
Tab(text: l10n.postsTab),
Tab(text: l10n.aboutTab),
Tab(text: l10n.activityTab),
],
),
),
];
},
body: TabBarView(
children: [
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 10,
itemBuilder: (context, index) => Card(
child: ListTile(
title: Text('${l10n.postTitle} ${index + 1}'),
subtitle: Text(l10n.postDate),
),
),
),
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
l10n.aboutContent,
style: Theme.of(context).textTheme.bodyLarge,
),
),
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 20,
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.history),
title: Text('${l10n.activityLabel} ${index + 1}'),
subtitle: Text(l10n.activityTimestamp),
),
),
],
),
),
),
);
}
}
RTL Support and Bidirectional Layouts
NestedScrollView and its slivers respect the ambient Directionality. TabBar tabs reverse order, SliverAppBar actions reposition, and tab content scrolls with correct RTL semantics.
class BidirectionalNestedScrollView extends StatelessWidget {
const BidirectionalNestedScrollView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DefaultTabController(
length: 2,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverAppBar(
pinned: true,
title: Text(l10n.dashboardTitle),
bottom: TabBar(
tabs: [
Tab(text: l10n.overviewTab),
Tab(text: l10n.detailsTab),
],
),
),
];
},
body: TabBarView(
children: [
ListView(
padding: const EdgeInsetsDirectional.all(16),
children: [
Text(l10n.overviewContent,
style: Theme.of(context).textTheme.bodyLarge),
],
),
ListView(
padding: const EdgeInsetsDirectional.all(16),
children: [
Text(l10n.detailsContent,
style: Theme.of(context).textTheme.bodyLarge),
],
),
],
),
),
);
}
}
Testing NestedScrollView Localization
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
Widget buildTestWidget({Locale locale = const Locale('en')}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LocalizedNestedScrollViewExample(),
);
}
testWidgets('NestedScrollView renders tabs', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(TabBar), findsOneWidget);
});
testWidgets('NestedScrollView works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Use
isScrollable: trueon TabBar when translated tab labels vary significantly in length, preventing truncation.Adjust
expandedHeightbased on locale to accommodate longer translated titles and subtitles in the collapsing header.Use
forceElevated: innerBoxIsScrolledto show elevation when inner content has scrolled, providing visual feedback.Each tab's content should be independently scrollable with its own padding and content length.
Use
tabAlignment: TabAlignment.startfor scrollable tabs to align with RTL text direction.Test tab switching in multiple locales to verify content loads correctly and scroll positions reset appropriately.
Conclusion
NestedScrollView is essential for building tabbed interfaces with collapsing headers in Flutter. For multilingual apps, it solves the challenge of coordinating scrolling between a translated header region and multiple tabs with independent localized content. By using scrollable tabs for long translated labels, locale-aware header heights, and directional padding, you can build polished tabbed experiences that work smoothly across all supported languages.