← Back to Blog

Flutter NestedScrollView Localization: Coordinated Scrolling for Multilingual Apps

flutternestedscrollviewtabsscrollinglocalizationrtl

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

  1. Use isScrollable: true on TabBar when translated tab labels vary significantly in length, preventing truncation.

  2. Adjust expandedHeight based on locale to accommodate longer translated titles and subtitles in the collapsing header.

  3. Use forceElevated: innerBoxIsScrolled to show elevation when inner content has scrolled, providing visual feedback.

  4. Each tab's content should be independently scrollable with its own padding and content length.

  5. Use tabAlignment: TabAlignment.start for scrollable tabs to align with RTL text direction.

  6. 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.

Further Reading