Flutter CustomScrollView Localization: Sliver-Based Layouts for Multilingual Apps
CustomScrollView is a Flutter widget that creates a scrollable area using slivers -- composable scroll effects like app bars, lists, grids, and persistent headers. In multilingual applications, CustomScrollView is essential for building complex scrollable interfaces where translated headers collapse differently based on text length, localized grid items need adaptive sizing, and multiple scrollable sections with translated content must coordinate smoothly.
Understanding CustomScrollView in Localization Context
CustomScrollView combines multiple slivers into a single scrollable viewport, enabling effects like collapsing headers, pinned navigation, and mixed content types. For multilingual apps, this enables:
- Collapsing app bars with translated titles that adapt expanded height to text length
- Mixed sliver lists and grids with locale-aware item sizing
- Persistent headers with translated section labels that pin during scroll
- Coordinated scrolling across multiple localized content sections
Why CustomScrollView Matters for Multilingual Apps
CustomScrollView provides:
- Flexible headers: SliverAppBar with translated titles that collapse smoothly
- Mixed content: Combine sliver lists, grids, and custom slivers for complex localized layouts
- Persistent sections: Translated section headers stay pinned while content scrolls
- Performance: Lazy-builds sliver children for efficient rendering of long translated content
Basic CustomScrollView Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedCustomScrollViewExample extends StatelessWidget {
const LocalizedCustomScrollViewExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(
l10n.discoverTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: Container(
color: Theme.of(context).colorScheme.primaryContainer,
),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(
l10n.featuredSectionTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
l10n.featuredDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
]),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
child: Center(
child: Text('${l10n.itemLabel} ${index + 1}'),
),
);
},
childCount: 8,
),
),
),
],
),
);
}
}
Advanced CustomScrollView Patterns for Localization
Collapsing Header with Locale-Aware Height
Translated titles and subtitles in SliverAppBar may need different expanded heights to display fully without truncation.
class LocaleAwareCollapsingHeader extends StatelessWidget {
const LocaleAwareCollapsingHeader({super.key});
double _getExpandedHeight(Locale locale) {
if (['de', 'fi', 'hu', 'nl'].contains(locale.languageCode)) {
return 240;
}
if (['ar', 'he', 'fa'].contains(locale.languageCode)) {
return 220;
}
return 200;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: _getExpandedHeight(locale),
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(
l10n.appTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: Padding(
padding: const EdgeInsets.fromLTRB(16, 80, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
l10n.heroSubtitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text('${l10n.itemLabel} ${index + 1}'),
),
childCount: 30,
),
),
],
);
}
}
Pinned Section Headers with Translated Labels
Categorized content uses pinned headers that remain visible while their section scrolls. Each header shows a translated category name.
class PinnedSectionHeaders extends StatelessWidget {
const PinnedSectionHeaders({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sections = [
(l10n.recentSectionTitle, 5),
(l10n.popularSectionTitle, 8),
(l10n.recommendedSectionTitle, 6),
];
return CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.browseCatalogTitle),
),
for (final (title, count) in sections) ...[
SliverPersistentHeader(
pinned: true,
delegate: _SectionHeaderDelegate(
title: title,
context: context,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('${l10n.itemLabel} ${index + 1}'),
subtitle: Text(l10n.itemDescription),
),
childCount: count,
),
),
],
],
);
}
}
class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;
final BuildContext context;
_SectionHeaderDelegate({required this.title, required this.context});
@override
double get minExtent => 48;
@override
double get maxExtent => 48;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
@override
bool shouldRebuild(covariant _SectionHeaderDelegate oldDelegate) {
return title != oldDelegate.title;
}
}
Mixed Slivers with Locale-Aware Grid Columns
Combine different sliver types for rich layouts, adjusting grid columns based on locale verbosity.
class MixedSliverLayout extends StatelessWidget {
const MixedSliverLayout({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final isVerbose = ['de', 'fi', 'hu'].contains(locale.languageCode);
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 160,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.shopTitle),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.shopDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isVerbose ? 2 : 3,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: isVerbose ? 0.8 : 0.9,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shopping_bag, size: 32,
color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 8),
Text(
'${l10n.productLabel} ${index + 1}',
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
childCount: 12,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
}
RTL Support and Bidirectional Layouts
CustomScrollView and its slivers respect the ambient Directionality. SliverGrid items reverse order, SliverAppBar actions reposition, and persistent headers align text correctly.
class BidirectionalCustomScrollView extends StatelessWidget {
const BidirectionalCustomScrollView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.dashboardTitle),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: l10n.searchTooltip,
onPressed: () {},
),
],
),
SliverPadding(
padding: const EdgeInsetsDirectional.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
child: ListTile(
title: Text('${l10n.taskLabel} ${index + 1}'),
subtitle: Text(l10n.taskDescription),
trailing: const Icon(Icons.chevron_right),
),
),
childCount: 20,
),
),
),
],
);
}
}
Testing CustomScrollView 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 LocalizedCustomScrollViewExample(),
);
}
testWidgets('CustomScrollView renders slivers', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(CustomScrollView), findsOneWidget);
});
testWidgets('CustomScrollView works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Adjust SliverAppBar expandedHeight per locale to accommodate longer translated titles and subtitles without truncation.
Use SliverPersistentHeader for translated section labels that pin during scrolling, keeping category context visible.
Adjust grid column counts for verbose languages -- German and Finnish labels may need fewer columns to display fully.
Use SliverPadding with EdgeInsetsDirectional for RTL-aware content insets within sliver layouts.
Set maxLines and overflow on FlexibleSpaceBar titles to prevent layout issues with long translated titles.
Test scrolling performance with large translated datasets to ensure sliver lazy-building works efficiently.
Conclusion
CustomScrollView is the most powerful scrollable layout widget in Flutter, enabling complex multi-section interfaces with collapsing headers, pinned navigation, and mixed content types. For multilingual apps, its sliver architecture handles the challenges of varying translation lengths through locale-aware header heights, adaptive grid columns, and translated persistent headers. By combining slivers with directional padding and locale-sensitive sizing, you can build rich scrollable experiences that perform well across all supported languages.