Flutter SliverToBoxAdapter Localization: Non-Sliver Widgets in Custom Scroll Views for Multilingual Apps
SliverToBoxAdapter is a Flutter widget that wraps a regular (box) widget so it can be used inside a CustomScrollView. In multilingual applications, SliverToBoxAdapter is essential for embedding translated headers, banners, and info panels between slivers, inserting localized empty states and placeholder messages in sliver scroll views, adding translated section dividers and promotional content in mixed sliver layouts, and bridging non-sliver widgets into sliver-based scrollable interfaces.
Understanding SliverToBoxAdapter in Localization Context
SliverToBoxAdapter takes a single child widget and adapts it to the sliver protocol, allowing any regular widget to participate in a CustomScrollView. For multilingual apps, this enables:
- Translated section headers between sliver lists and grids
- Localized banners, promotions, and announcement cards
- Empty state messages with translated text and action buttons
- Info panels and statistics displays with localized labels
Why SliverToBoxAdapter Matters for Multilingual Apps
SliverToBoxAdapter provides:
- Bridge widget: Puts any translated widget inside a CustomScrollView
- Layout flexibility: Headers, banners, and panels between sliver lists and grids
- Simple API: Wraps a single child with no additional configuration
- Essential glue: Required for mixing non-sliver translated content with slivers
Basic SliverToBoxAdapter Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSliverToBoxAdapterExample extends StatelessWidget {
const LocalizedSliverToBoxAdapterExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 150,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.homeTitle),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeBannerTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
const SizedBox(height: 8),
Text(
l10n.welcomeBannerMessage,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {},
child: Text(l10n.getStartedLabel),
),
],
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 8, 16, 8),
child: Text(
l10n.recentActivityLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
),
SliverList.builder(
itemCount: 10,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.history),
title: Text('${l10n.activityLabel} ${index + 1}'),
subtitle: Text(l10n.activityDescription),
);
},
),
],
),
);
}
}
Advanced SliverToBoxAdapter Patterns for Localization
Section Headers with Action Buttons
SliverToBoxAdapter for translated section headers with "See all" action buttons.
class SectionHeaderAdapter extends StatelessWidget {
const SectionHeaderAdapter({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.shopTitle),
),
_buildSectionHeader(context, l10n.trendingLabel, l10n.seeAllLabel),
SliverToBoxAdapter(
child: SizedBox(
height: 180,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsetsDirectional.only(start: 16),
itemCount: 6,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsetsDirectional.only(end: 12),
child: SizedBox(
width: 140,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.shopping_bag, size: 48),
const SizedBox(height: 8),
Text(
'${l10n.productLabel} ${index + 1}',
textAlign: TextAlign.center,
),
],
),
),
);
},
),
),
),
_buildSectionHeader(context, l10n.categoriesLabel, l10n.seeAllLabel),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: 6,
itemBuilder: (context, index) {
final categories = [
(Icons.phone_android, l10n.electronicsCategory),
(Icons.checkroom, l10n.clothingCategory),
(Icons.menu_book, l10n.booksCategory),
(Icons.sports_soccer, l10n.sportsCategory),
(Icons.home, l10n.homeCategory),
(Icons.toys, l10n.toysCategory),
];
final (icon, label) = categories[index];
return Card(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
),
_buildSectionHeader(
context, l10n.recentlyViewedLabel, l10n.clearLabel),
SliverList.builder(
itemCount: 5,
itemBuilder: (context, index) {
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.history)),
title: Text('${l10n.itemLabel} ${index + 1}'),
subtitle: Text(l10n.viewedRecentlyLabel),
);
},
),
],
),
);
}
Widget _buildSectionHeader(
BuildContext context, String title, String actionLabel) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 24, 8, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: () {},
child: Text(actionLabel),
),
],
),
),
);
}
}
Empty State with Translated Message
SliverToBoxAdapter for displaying a localized empty state when no data is available.
class EmptyStateSliverAdapter extends StatelessWidget {
final bool hasData;
const EmptyStateSliverAdapter({super.key, this.hasData = false});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.searchResultsTitle),
),
if (!hasData)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 64,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 80,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.noResultsTitle,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.noResultsMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.refresh),
label: Text(l10n.tryAgainLabel),
),
],
),
),
)
else
SliverList.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(
title: Text('${l10n.resultLabel} ${index + 1}'),
);
},
),
],
),
);
}
}
Statistics Panel Between Slivers
SliverToBoxAdapter for a localized statistics dashboard panel.
class StatsPanelAdapter extends StatelessWidget {
const StatsPanelAdapter({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.dashboardTitle),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _StatCard(
label: l10n.totalUsersLabel,
value: '1,247',
icon: Icons.people,
color: Colors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
label: l10n.activeSessionsLabel,
value: '89',
icon: Icons.show_chart,
color: Colors.green,
),
),
const SizedBox(width: 12),
Expanded(
child: _StatCard(
label: l10n.revenueLabel,
value: '\$12.4K',
icon: Icons.attach_money,
color: Colors.orange,
),
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 8, 16, 8),
child: Text(
l10n.recentOrdersLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
),
SliverList.builder(
itemCount: 10,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('#${index + 1}')),
title: Text('${l10n.orderLabel} ${1000 + index}'),
subtitle: Text(l10n.orderDescription),
trailing: Text(
'\$${((index + 1) * 29.99).toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleSmall,
),
);
},
),
],
),
);
}
}
class _StatCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _StatCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
],
),
),
);
}
}
RTL Support and Bidirectional Layouts
SliverToBoxAdapter passes RTL directionality to its child widget. Use EdgeInsetsDirectional and directional alignment to ensure content adapts correctly.
class BidirectionalSliverAdapter extends StatelessWidget {
const BidirectionalSliverAdapter({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: Text(l10n.profileTitle),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsetsDirectional.all(16),
child: Row(
children: [
CircleAvatar(
radius: 32,
child: Text(l10n.userInitials),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.displayName,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
l10n.memberSinceLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
SliverToBoxAdapter(
child: Divider(
indent: 16,
endIndent: 16,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
SliverList.builder(
itemCount: 5,
itemBuilder: (context, index) {
final items = [
(Icons.edit, l10n.editProfileLabel),
(Icons.lock, l10n.changePasswordLabel),
(Icons.notifications, l10n.notificationSettingsLabel),
(Icons.language, l10n.languageSettingsLabel),
(Icons.logout, l10n.signOutLabel),
];
final (icon, label) = items[index];
return ListTile(
leading: Icon(icon),
title: Text(label),
trailing: Icon(
Directionality.of(context) == TextDirection.rtl
? Icons.chevron_left
: Icons.chevron_right,
),
);
},
),
],
),
);
}
}
Testing SliverToBoxAdapter 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 LocalizedSliverToBoxAdapterExample(),
);
}
testWidgets('SliverToBoxAdapter renders localized content', (tester) async {
await tester.pumpWidget(buildTestWidget());
await tester.pumpAndSettle();
expect(find.byType(CustomScrollView), findsOneWidget);
});
testWidgets('SliverToBoxAdapter works in RTL', (tester) async {
await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
Best Practices
Use SliverToBoxAdapter for section headers with translated text and action buttons between SliverList and SliverGrid sections.
Use
EdgeInsetsDirectionalfor all padding inside SliverToBoxAdapter children so margins adapt correctly in RTL layouts.Show empty states with translated messages using SliverToBoxAdapter when a sliver list or grid has no data.
Keep SliverToBoxAdapter children lightweight since they are always built, unlike lazy slivers that only build visible items.
Combine with
SliverFillRemainingfor centered empty states that fill the remaining scroll view space with translated content.Test with verbose translations to verify banners, headers, and info panels don't overflow or clip with longer text.
Conclusion
SliverToBoxAdapter provides the essential bridge between regular widgets and sliver-based scroll views in Flutter. For multilingual apps, it enables translated section headers, promotional banners, empty states, and statistics panels to sit alongside SliverList and SliverGrid in a single CustomScrollView. By using SliverToBoxAdapter with directional padding, localized banners, and mixed sliver layouts, you can build rich scrollable interfaces that integrate translated content seamlessly across all supported languages.