Flutter SliverAppBar Localization: Flexible Headers, Collapsing Titles, and Pinned Actions
SliverAppBar provides flexible, collapsing headers that respond to scroll behavior. Localizing these headers requires handling expanded titles, collapsed states, floating actions, and search functionality across different languages. This guide covers everything you need to know about localizing SliverAppBar in Flutter.
Understanding SliverAppBar Localization
SliverAppBar requires localization for:
- Expanded titles: Large text displayed when fully expanded
- Collapsed titles: Compact text shown when scrolled
- Flexible space content: Background text and overlays
- Action buttons: Tooltips and accessibility labels
- Search functionality: Hints and suggestions
- RTL support: Proper layout mirroring
Basic SliverAppBar with Localized Title
Start with a simple localized collapsing header:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSliverAppBar extends StatelessWidget {
const LocalizedSliverAppBar({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.homeTitle),
centerTitle: true,
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Theme.of(context).primaryColor,
Theme.of(context).primaryColor.withOpacity(0.7),
],
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 60),
child: Text(
l10n.welcomeMessage,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.white70,
),
),
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: l10n.searchTooltip,
onPressed: () => _openSearch(context),
),
IconButton(
icon: const Icon(Icons.notifications_outlined),
tooltip: l10n.notificationsTooltip,
onPressed: () => _openNotifications(context),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text(l10n.itemTitle(index + 1)),
subtitle: Text(l10n.itemSubtitle),
),
childCount: 20,
),
),
],
),
);
}
void _openSearch(BuildContext context) {
// Navigate to search
}
void _openNotifications(BuildContext context) {
// Navigate to notifications
}
}
SliverAppBar with Dynamic Title Based on Scroll
Show different titles when expanded vs collapsed:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class DynamicTitleSliverAppBar extends StatefulWidget {
const DynamicTitleSliverAppBar({super.key});
@override
State<DynamicTitleSliverAppBar> createState() =>
_DynamicTitleSliverAppBarState();
}
class _DynamicTitleSliverAppBarState extends State<DynamicTitleSliverAppBar> {
final ScrollController _scrollController = ScrollController();
bool _isCollapsed = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final isCollapsed = _scrollController.hasClients &&
_scrollController.offset > (200 - kToolbarHeight);
if (isCollapsed != _isCollapsed) {
setState(() => _isCollapsed = isCollapsed);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
title: AnimatedOpacity(
opacity: _isCollapsed ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(l10n.profileTitle),
),
flexibleSpace: FlexibleSpaceBar(
background: _buildExpandedContent(context, l10n),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.aboutSectionTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(l10n.aboutSectionContent),
],
),
),
),
],
),
);
}
Widget _buildExpandedContent(BuildContext context, AppLocalizations l10n) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).primaryColor,
Theme.of(context).colorScheme.secondary,
],
),
),
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircleAvatar(
radius: 40,
child: Icon(Icons.person, size: 40),
),
const SizedBox(height: 12),
Text(
l10n.userName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
l10n.userRole,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
),
),
],
),
),
);
}
}
SliverAppBar with Search Integration
Implement localized search in the app bar:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SearchableSliverAppBar extends StatefulWidget {
const SearchableSliverAppBar({super.key});
@override
State<SearchableSliverAppBar> createState() => _SearchableSliverAppBarState();
}
class _SearchableSliverAppBarState extends State<SearchableSliverAppBar> {
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: _isSearching ? kToolbarHeight : 160,
pinned: true,
floating: true,
title: _isSearching
? _buildSearchField(l10n)
: null,
flexibleSpace: _isSearching
? null
: FlexibleSpaceBar(
title: Text(l10n.productsTitle),
background: Container(
color: Theme.of(context).primaryColor,
child: Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 50),
child: Text(
l10n.browseProductsMessage,
style: const TextStyle(
color: Colors.white70,
fontSize: 16,
),
),
),
),
),
),
actions: [
if (_isSearching)
IconButton(
icon: const Icon(Icons.close),
tooltip: l10n.closeSearchTooltip,
onPressed: () {
setState(() {
_isSearching = false;
_searchQuery = '';
_searchController.clear();
});
},
)
else ...[
IconButton(
icon: const Icon(Icons.search),
tooltip: l10n.searchTooltip,
onPressed: () {
setState(() => _isSearching = true);
},
),
IconButton(
icon: const Icon(Icons.filter_list),
tooltip: l10n.filterTooltip,
onPressed: () => _showFilterSheet(context, l10n),
),
],
],
),
_buildResultsList(l10n),
],
),
);
}
Widget _buildSearchField(AppLocalizations l10n) {
return TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: l10n.searchProductsHint,
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.white.withOpacity(0.7)),
),
style: const TextStyle(color: Colors.white),
onChanged: (value) {
setState(() => _searchQuery = value);
},
);
}
Widget _buildResultsList(AppLocalizations l10n) {
if (_isSearching && _searchQuery.isEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
l10n.searchPromptMessage,
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
],
),
),
);
}
if (_isSearching && _searchQuery.isNotEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.searchResultsFor(_searchQuery),
style: Theme.of(context).textTheme.titleMedium,
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
leading: Container(
width: 50,
height: 50,
color: Colors.grey[300],
child: const Icon(Icons.image),
),
title: Text(l10n.productName(index + 1)),
subtitle: Text(l10n.productPrice(19.99 + index)),
trailing: IconButton(
icon: const Icon(Icons.add_shopping_cart),
tooltip: l10n.addToCartTooltip,
onPressed: () {},
),
),
childCount: 15,
),
);
}
void _showFilterSheet(BuildContext context, AppLocalizations l10n) {
showModalBottomSheet(
context: context,
builder: (context) => Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.filterTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.filterByPrice),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
title: Text(l10n.filterByCategory),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
title: Text(l10n.filterByRating),
trailing: const Icon(Icons.chevron_right),
),
],
),
),
);
}
}
SliverAppBar with Tabs
Create a localized tabbed interface with SliverAppBar:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class TabbedSliverAppBar extends StatelessWidget {
const TabbedSliverAppBar({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: 200,
pinned: true,
floating: true,
forceElevated: innerBoxIsScrolled,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.storeName),
background: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Theme.of(context).primaryColor,
Theme.of(context).primaryColor.withOpacity(0.8),
],
),
),
),
Positioned(
bottom: 60,
left: 16,
right: 16,
child: Text(
l10n.storeTagline,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
),
],
),
),
actions: [
IconButton(
icon: const Icon(Icons.shopping_cart_outlined),
tooltip: l10n.cartTooltip,
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: l10n.moreOptionsTooltip,
onPressed: () => _showOptions(context, l10n),
),
],
bottom: TabBar(
tabs: [
Tab(text: l10n.tabProducts),
Tab(text: l10n.tabCategories),
Tab(text: l10n.tabDeals),
],
),
),
];
},
body: TabBarView(
children: [
_buildProductsTab(l10n),
_buildCategoriesTab(l10n),
_buildDealsTab(l10n),
],
),
),
),
);
}
Widget _buildProductsTab(AppLocalizations l10n) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 10,
itemBuilder: (context, index) => Card(
child: ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(l10n.productName(index + 1)),
subtitle: Text(l10n.productDescription),
trailing: Text(l10n.productPrice(29.99)),
),
),
);
}
Widget _buildCategoriesTab(AppLocalizations l10n) {
final categories = [
l10n.categoryElectronics,
l10n.categoryClothing,
l10n.categoryHome,
l10n.categorySports,
l10n.categoryBooks,
];
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: categories.length,
itemBuilder: (context, index) => Card(
child: ListTile(
leading: const Icon(Icons.category),
title: Text(categories[index]),
trailing: const Icon(Icons.chevron_right),
),
),
);
}
Widget _buildDealsTab(AppLocalizations l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_offer, size: 64, color: Colors.orange),
const SizedBox(height: 16),
Text(
l10n.noDealsMessage,
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 8),
Text(
l10n.checkBackLater,
style: TextStyle(color: Colors.grey[600]),
),
],
),
);
}
void _showOptions(BuildContext context, AppLocalizations l10n) {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.settings),
title: Text(l10n.settingsOption),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.help_outline),
title: Text(l10n.helpOption),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(l10n.aboutOption),
onTap: () => Navigator.pop(context),
),
],
),
);
}
}
SliverAppBar with RTL Support
Handle right-to-left layouts properly:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RTLSliverAppBar extends StatelessWidget {
const RTLSliverAppBar({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(l10n.newsTitle),
// Adjust title position for RTL
centerTitle: false,
titlePadding: EdgeInsetsDirectional.only(
start: 16,
bottom: 16,
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
// Mirror gradient direction for RTL
begin: isRTL ? Alignment.topRight : Alignment.topLeft,
end: isRTL ? Alignment.bottomLeft : Alignment.bottomRight,
colors: [
Theme.of(context).primaryColor,
Theme.of(context).colorScheme.secondary,
],
),
),
),
),
// Actions automatically flip in RTL
actions: [
IconButton(
icon: const Icon(Icons.bookmark_border),
tooltip: l10n.bookmarksTooltip,
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.share),
tooltip: l10n.shareTooltip,
onPressed: () {},
),
],
),
SliverPadding(
// Use directional padding for RTL
padding: const EdgeInsetsDirectional.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.newsHeadline(index + 1),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
l10n.newsExcerpt,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.timeAgo(index + 1),
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
TextButton(
onPressed: () {},
child: Text(l10n.readMoreButton),
),
],
),
],
),
),
),
childCount: 10,
),
),
),
],
),
);
}
}
Accessibility for SliverAppBar
Ensure proper accessibility labels:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AccessibleSliverAppBar extends StatelessWidget {
const AccessibleSliverAppBar({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: Semantics(
label: l10n.headerAccessibilityLabel,
child: FlexibleSpaceBar(
title: Semantics(
header: true,
child: Text(l10n.dashboardTitle),
),
background: Container(
color: Theme.of(context).primaryColor,
child: Center(
child: Semantics(
label: l10n.statsAccessibilityLabel,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'1,234',
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
Text(
l10n.totalOrdersLabel,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
),
),
),
),
actions: [
Semantics(
button: true,
label: l10n.refreshAccessibilityLabel,
child: IconButton(
icon: const Icon(Icons.refresh),
tooltip: l10n.refreshTooltip,
onPressed: () {
// Announce refresh action
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.refreshingMessage),
),
);
},
),
),
Semantics(
button: true,
label: l10n.settingsAccessibilityLabel,
child: IconButton(
icon: const Icon(Icons.settings),
tooltip: l10n.settingsTooltip,
onPressed: () {},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Semantics(
label: l10n.orderItemAccessibilityLabel(index + 1),
child: ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text(l10n.orderNumber(index + 1000)),
subtitle: Text(l10n.orderStatus),
trailing: Icon(
Icons.chevron_right,
semanticLabel: l10n.viewDetailsLabel,
),
),
),
childCount: 20,
),
),
],
),
);
}
}
ARB Translations for SliverAppBar
Add these entries to your ARB files:
{
"homeTitle": "Home",
"@homeTitle": {
"description": "Title for home screen"
},
"welcomeMessage": "Welcome back!",
"@welcomeMessage": {
"description": "Welcome message in expanded header"
},
"searchTooltip": "Search",
"notificationsTooltip": "Notifications",
"itemTitle": "Item {number}",
"@itemTitle": {
"placeholders": {
"number": {"type": "int"}
}
},
"itemSubtitle": "Tap for details",
"profileTitle": "Profile",
"userName": "John Doe",
"userRole": "Premium Member",
"aboutSectionTitle": "About",
"aboutSectionContent": "User profile information and settings.",
"productsTitle": "Products",
"browseProductsMessage": "Browse our collection",
"closeSearchTooltip": "Close search",
"filterTooltip": "Filter",
"searchProductsHint": "Search products...",
"searchPromptMessage": "Enter a search term",
"searchResultsFor": "Results for \"{query}\"",
"@searchResultsFor": {
"placeholders": {
"query": {"type": "String"}
}
},
"productName": "Product {number}",
"@productName": {
"placeholders": {
"number": {"type": "int"}
}
},
"productPrice": "${price}",
"@productPrice": {
"placeholders": {
"price": {"type": "double", "format": "currency", "optionalParameters": {"symbol": "$"}}
}
},
"productDescription": "High quality product",
"addToCartTooltip": "Add to cart",
"filterTitle": "Filters",
"filterByPrice": "Price",
"filterByCategory": "Category",
"filterByRating": "Rating",
"storeName": "My Store",
"storeTagline": "Quality products for everyone",
"cartTooltip": "Shopping cart",
"moreOptionsTooltip": "More options",
"tabProducts": "Products",
"tabCategories": "Categories",
"tabDeals": "Deals",
"categoryElectronics": "Electronics",
"categoryClothing": "Clothing",
"categoryHome": "Home & Garden",
"categorySports": "Sports",
"categoryBooks": "Books",
"noDealsMessage": "No deals available",
"checkBackLater": "Check back later for special offers",
"settingsOption": "Settings",
"helpOption": "Help",
"aboutOption": "About",
"newsTitle": "News",
"bookmarksTooltip": "Bookmarks",
"shareTooltip": "Share",
"newsHeadline": "Breaking News Story {number}",
"@newsHeadline": {
"placeholders": {
"number": {"type": "int"}
}
},
"newsExcerpt": "This is a brief excerpt of the news article...",
"timeAgo": "{hours} hours ago",
"@timeAgo": {
"placeholders": {
"hours": {"type": "int"}
}
},
"readMoreButton": "Read More",
"headerAccessibilityLabel": "Page header with statistics",
"dashboardTitle": "Dashboard",
"statsAccessibilityLabel": "Total orders: 1,234",
"totalOrdersLabel": "Total Orders",
"refreshAccessibilityLabel": "Refresh data",
"refreshTooltip": "Refresh",
"refreshingMessage": "Refreshing data...",
"settingsAccessibilityLabel": "Open settings",
"settingsTooltip": "Settings",
"orderItemAccessibilityLabel": "Order item {number}",
"@orderItemAccessibilityLabel": {
"placeholders": {
"number": {"type": "int"}
}
},
"orderNumber": "Order #{number}",
"@orderNumber": {
"placeholders": {
"number": {"type": "int"}
}
},
"orderStatus": "Processing",
"viewDetailsLabel": "View details"
}
Testing SliverAppBar Localization
Write tests for your localized SliverAppBar:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedSliverAppBar', () {
testWidgets('displays localized title in English', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const LocalizedSliverAppBar(),
),
);
expect(find.text('Home'), findsOneWidget);
expect(find.text('Welcome back!'), findsOneWidget);
});
testWidgets('displays localized title in Spanish', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const LocalizedSliverAppBar(),
),
);
expect(find.text('Inicio'), findsOneWidget);
expect(find.text('¡Bienvenido de nuevo!'), findsOneWidget);
});
testWidgets('action buttons have localized tooltips', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const LocalizedSliverAppBar(),
),
);
final searchButton = find.byIcon(Icons.search);
expect(searchButton, findsOneWidget);
// Long press to show tooltip
await tester.longPress(searchButton);
await tester.pumpAndSettle();
expect(find.text('Search'), findsOneWidget);
});
testWidgets('handles RTL layout correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: const Directionality(
textDirection: TextDirection.rtl,
child: RTLSliverAppBar(),
),
),
);
// Verify RTL text is displayed
expect(find.text('الأخبار'), findsOneWidget);
});
});
}
Summary
Localizing SliverAppBar in Flutter requires:
- Dynamic title handling for expanded and collapsed states
- Localized action tooltips for all icon buttons
- Search integration with localized hints and messages
- Tab labels in the user's language
- RTL support with proper directional padding
- Accessibility labels for screen reader users
- Flexible space content with translated text
- Comprehensive testing across different locales
SliverAppBar provides a powerful way to create engaging headers, and proper localization ensures your app feels polished for users in any language.