Flutter RefreshIndicator Localization: Pull-to-Refresh Messages and Loading States
Pull-to-refresh is a fundamental mobile interaction pattern that users expect in scrollable content. Localizing the RefreshIndicator involves translating loading messages, error states, success feedback, and timestamp displays. This guide covers everything you need to know about creating a fully localized pull-to-refresh experience in Flutter.
Understanding RefreshIndicator Localization Needs
RefreshIndicator interactions require localization for:
- Pull instruction: "Pull down to refresh"
- Release message: "Release to refresh"
- Loading state: "Refreshing..."
- Success message: "Updated just now"
- Error message: "Couldn't refresh. Try again."
- Last updated: Relative time formatting
Basic RefreshIndicator with Localized Feedback
Start with a simple localized refresh implementation:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedRefreshableList extends StatefulWidget {
const LocalizedRefreshableList({super.key});
@override
State<LocalizedRefreshableList> createState() =>
_LocalizedRefreshableListState();
}
class _LocalizedRefreshableListState extends State<LocalizedRefreshableList> {
List<String> _items = [];
DateTime? _lastRefreshed;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadItems();
}
Future<void> _loadItems() async {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
setState(() {
_items = List.generate(20, (i) => 'Item ${i + 1}');
_lastRefreshed = DateTime.now();
_isLoading = false;
});
}
Future<void> _onRefresh() async {
await _loadItems();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.loadingContent),
],
),
);
}
return Column(
children: [
if (_lastRefreshed != null)
Container(
padding: const EdgeInsets.all(8),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
l10n.lastUpdated(_formatRelativeTime(_lastRefreshed!, l10n)),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Expanded(
child: RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) => ListTile(
title: Text(_items[index]),
),
),
),
),
],
);
}
String _formatRelativeTime(DateTime time, AppLocalizations l10n) {
final now = DateTime.now();
final difference = now.difference(time);
if (difference.inSeconds < 60) {
return l10n.justNow;
} else if (difference.inMinutes < 60) {
return l10n.minutesAgo(difference.inMinutes);
} else if (difference.inHours < 24) {
return l10n.hoursAgo(difference.inHours);
} else {
return l10n.daysAgo(difference.inDays);
}
}
}
ARB entries:
{
"loadingContent": "Loading content...",
"@loadingContent": {
"description": "Message shown while content is loading"
},
"lastUpdated": "Last updated {time}",
"@lastUpdated": {
"placeholders": {
"time": {"type": "String"}
}
},
"justNow": "just now",
"minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"@minutesAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"daysAgo": "{count, plural, =1{1 day ago} other{{count} days ago}}",
"@daysAgo": {
"placeholders": {
"count": {"type": "int"}
}
}
}
Custom Refresh Header with Pull Messages
Build a custom header showing pull-to-refresh instructions:
class CustomRefreshHeader extends StatefulWidget {
final Widget child;
final Future<void> Function() onRefresh;
const CustomRefreshHeader({
super.key,
required this.child,
required this.onRefresh,
});
@override
State<CustomRefreshHeader> createState() => _CustomRefreshHeaderState();
}
class _CustomRefreshHeaderState extends State<CustomRefreshHeader> {
RefreshState _state = RefreshState.idle;
double _pullDistance = 0;
static const double _triggerDistance = 80;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: _buildRefreshHeader(l10n),
),
widget.child,
],
),
);
}
Widget _buildRefreshHeader(AppLocalizations l10n) {
return Container(
height: _pullDistance.clamp(0, _triggerDistance + 40),
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildIndicator(),
const SizedBox(height: 8),
Text(
_getStatusMessage(l10n),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
);
}
Widget _buildIndicator() {
switch (_state) {
case RefreshState.idle:
case RefreshState.pulling:
return Transform.rotate(
angle: (_pullDistance / _triggerDistance) * 3.14,
child: const Icon(Icons.arrow_downward),
);
case RefreshState.ready:
return const Icon(Icons.refresh);
case RefreshState.refreshing:
return const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
);
case RefreshState.success:
return const Icon(Icons.check, color: Colors.green);
case RefreshState.error:
return const Icon(Icons.error, color: Colors.red);
}
}
String _getStatusMessage(AppLocalizations l10n) {
switch (_state) {
case RefreshState.idle:
case RefreshState.pulling:
return l10n.pullToRefresh;
case RefreshState.ready:
return l10n.releaseToRefresh;
case RefreshState.refreshing:
return l10n.refreshing;
case RefreshState.success:
return l10n.refreshSuccess;
case RefreshState.error:
return l10n.refreshError;
}
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollUpdateNotification) {
if (notification.metrics.pixels < 0) {
setState(() {
_pullDistance = -notification.metrics.pixels;
if (_pullDistance >= _triggerDistance) {
_state = RefreshState.ready;
} else {
_state = RefreshState.pulling;
}
});
}
} else if (notification is ScrollEndNotification) {
if (_state == RefreshState.ready) {
_performRefresh();
} else {
setState(() {
_pullDistance = 0;
_state = RefreshState.idle;
});
}
}
return false;
}
Future<void> _performRefresh() async {
setState(() => _state = RefreshState.refreshing);
try {
await widget.onRefresh();
setState(() => _state = RefreshState.success);
await Future.delayed(const Duration(milliseconds: 500));
} catch (e) {
setState(() => _state = RefreshState.error);
await Future.delayed(const Duration(seconds: 1));
}
setState(() {
_pullDistance = 0;
_state = RefreshState.idle;
});
}
}
enum RefreshState {
idle,
pulling,
ready,
refreshing,
success,
error,
}
ARB entries:
{
"pullToRefresh": "Pull down to refresh",
"@pullToRefresh": {
"description": "Instruction shown when user starts pulling down"
},
"releaseToRefresh": "Release to refresh",
"@releaseToRefresh": {
"description": "Message shown when pull threshold is reached"
},
"refreshing": "Refreshing...",
"@refreshing": {
"description": "Message shown during refresh"
},
"refreshSuccess": "Updated successfully",
"@refreshSuccess": {
"description": "Message shown after successful refresh"
},
"refreshError": "Couldn't refresh. Try again.",
"@refreshError": {
"description": "Message shown when refresh fails"
}
}
Snackbar Feedback on Refresh
Show localized feedback after refresh completes:
class RefreshWithSnackbarFeedback extends StatefulWidget {
const RefreshWithSnackbarFeedback({super.key});
@override
State<RefreshWithSnackbarFeedback> createState() =>
_RefreshWithSnackbarFeedbackState();
}
class _RefreshWithSnackbarFeedbackState
extends State<RefreshWithSnackbarFeedback> {
List<Map<String, dynamic>> _news = [];
int _newItemsCount = 0;
@override
void initState() {
super.initState();
_loadNews();
}
Future<void> _loadNews() async {
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_news = List.generate(
10,
(i) => {
'title': 'News item ${i + 1}',
'time': DateTime.now().subtract(Duration(hours: i)),
},
);
});
}
Future<void> _onRefresh() async {
final l10n = AppLocalizations.of(context)!;
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
// Simulate random new items
final random = DateTime.now().millisecond % 5;
_newItemsCount = random;
if (random > 0) {
final newItems = List.generate(
random,
(i) => {
'title': 'New item ${i + 1}',
'time': DateTime.now(),
},
);
setState(() {
_news = [...newItems, ..._news];
});
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_newItemsCount > 0
? l10n.newItemsFound(_newItemsCount)
: l10n.noNewItems,
),
duration: const Duration(seconds: 2),
action: _newItemsCount > 0
? SnackBarAction(
label: l10n.viewNew,
onPressed: () {
// Scroll to top
},
)
: null,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.refreshFailed),
backgroundColor: Colors.red,
action: SnackBarAction(
label: l10n.retry,
textColor: Colors.white,
onPressed: _onRefresh,
),
),
);
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
itemCount: _news.length,
itemBuilder: (context, index) {
final item = _news[index];
return ListTile(
title: Text(item['title'] as String),
subtitle: Text(
_formatTime(item['time'] as DateTime),
),
);
},
),
);
}
String _formatTime(DateTime time) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(time);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
}
ARB entries:
{
"newItemsFound": "{count, plural, =1{1 new item} other{{count} new items}}",
"@newItemsFound": {
"placeholders": {
"count": {"type": "int"}
}
},
"noNewItems": "You're all caught up!",
"viewNew": "View",
"refreshFailed": "Couldn't refresh. Check your connection.",
"retry": "Retry"
}
Accessibility for Pull-to-Refresh
Make refresh actions accessible:
class AccessibleRefreshableList extends StatefulWidget {
const AccessibleRefreshableList({super.key});
@override
State<AccessibleRefreshableList> createState() =>
_AccessibleRefreshableListState();
}
class _AccessibleRefreshableListState extends State<AccessibleRefreshableList> {
final List<String> _items = List.generate(20, (i) => 'Item ${i + 1}');
bool _isRefreshing = false;
DateTime? _lastRefresh;
Future<void> _onRefresh() async {
final l10n = AppLocalizations.of(context)!;
// Announce to screen readers
SemanticsService.announce(
l10n.refreshStarted,
Directionality.of(context),
);
setState(() => _isRefreshing = true);
await Future.delayed(const Duration(seconds: 1));
setState(() {
_isRefreshing = false;
_lastRefresh = DateTime.now();
});
// Announce completion
SemanticsService.announce(
l10n.refreshCompleted,
Directionality.of(context),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.listTitle),
actions: [
// Manual refresh button for accessibility
Semantics(
label: l10n.refreshButtonLabel,
hint: l10n.refreshButtonHint,
child: IconButton(
icon: _isRefreshing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isRefreshing ? null : _onRefresh,
tooltip: l10n.refreshTooltip,
),
),
],
),
body: Semantics(
label: l10n.listAccessibilityLabel(_items.length),
child: RefreshIndicator(
onRefresh: _onRefresh,
semanticsLabel: l10n.pullToRefreshAccessibility,
semanticsValue: _lastRefresh != null
? l10n.lastRefreshAccessibility(
_formatRelativeTime(_lastRefresh!, l10n),
)
: l10n.neverRefreshed,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) => Semantics(
label: l10n.listItemAccessibility(index + 1, _items[index]),
child: ListTile(
title: Text(_items[index]),
),
),
),
),
),
);
}
String _formatRelativeTime(DateTime time, AppLocalizations l10n) {
final diff = DateTime.now().difference(time);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
return l10n.hoursAgo(diff.inHours);
}
}
ARB entries:
{
"listTitle": "Items",
"refreshButtonLabel": "Refresh list",
"refreshButtonHint": "Double tap to refresh the list",
"refreshTooltip": "Refresh",
"listAccessibilityLabel": "List with {count} items",
"@listAccessibilityLabel": {
"placeholders": {
"count": {"type": "int"}
}
},
"pullToRefreshAccessibility": "Pull down to refresh list",
"lastRefreshAccessibility": "Last refreshed {time}",
"@lastRefreshAccessibility": {
"placeholders": {
"time": {"type": "String"}
}
},
"neverRefreshed": "Not yet refreshed",
"refreshStarted": "Refreshing list",
"refreshCompleted": "List refreshed successfully",
"listItemAccessibility": "Item {number}: {title}",
"@listItemAccessibility": {
"placeholders": {
"number": {"type": "int"},
"title": {"type": "String"}
}
}
}
Empty State with Refresh Option
Handle empty states with localized messages:
class RefreshableEmptyState extends StatefulWidget {
const RefreshableEmptyState({super.key});
@override
State<RefreshableEmptyState> createState() => _RefreshableEmptyStateState();
}
class _RefreshableEmptyStateState extends State<RefreshableEmptyState> {
List<String> _items = [];
bool _isLoading = false;
bool _hasError = false;
Future<void> _onRefresh() async {
setState(() {
_isLoading = true;
_hasError = false;
});
try {
await Future.delayed(const Duration(seconds: 1));
// Simulate sometimes empty, sometimes with data
if (DateTime.now().second % 3 == 0) {
throw Exception('Network error');
}
setState(() {
_items = DateTime.now().second % 2 == 0
? []
: List.generate(10, (i) => 'Item ${i + 1}');
_isLoading = false;
});
} catch (e) {
setState(() {
_hasError = true;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(l10n.loadingContent),
],
),
);
}
if (_hasError) {
return _buildErrorState(l10n);
}
if (_items.isEmpty) {
return _buildEmptyState(l10n);
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) => ListTile(
title: Text(_items[index]),
),
),
);
}
Widget _buildEmptyState(AppLocalizations l10n) {
return RefreshIndicator(
onRefresh: _onRefresh,
child: CustomScrollView(
slivers: [
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
l10n.emptyStateTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.emptyStateMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
Text(
l10n.emptyStatePullHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
),
],
),
);
}
Widget _buildErrorState(AppLocalizations l10n) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
l10n.errorStateTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
l10n.errorStateMessage,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: Text(l10n.tryAgain),
),
],
),
);
}
}
ARB entries:
{
"emptyStateTitle": "No items yet",
"@emptyStateTitle": {
"description": "Title shown when list is empty"
},
"emptyStateMessage": "New items will appear here when available",
"@emptyStateMessage": {
"description": "Description for empty state"
},
"emptyStatePullHint": "Pull down to check for new items",
"@emptyStatePullHint": {
"description": "Hint for pull to refresh on empty state"
},
"errorStateTitle": "Something went wrong",
"@errorStateTitle": {
"description": "Title for error state"
},
"errorStateMessage": "We couldn't load the content. Please check your connection.",
"@errorStateMessage": {
"description": "Description for error state"
},
"tryAgain": "Try again",
"@tryAgain": {
"description": "Retry button label"
}
}
Refresh with Content Type Context
Customize messages based on content being refreshed:
enum ContentType { news, messages, feed, orders }
class ContextualRefreshIndicator extends StatelessWidget {
final ContentType contentType;
final Future<void> Function() onRefresh;
final Widget child;
const ContextualRefreshIndicator({
super.key,
required this.contentType,
required this.onRefresh,
required this.child,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return RefreshIndicator(
onRefresh: () async {
await onRefresh();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_getSuccessMessage(l10n)),
duration: const Duration(seconds: 2),
),
);
},
child: child,
);
}
String _getSuccessMessage(AppLocalizations l10n) {
switch (contentType) {
case ContentType.news:
return l10n.newsRefreshed;
case ContentType.messages:
return l10n.messagesRefreshed;
case ContentType.feed:
return l10n.feedRefreshed;
case ContentType.orders:
return l10n.ordersRefreshed;
}
}
}
ARB entries:
{
"newsRefreshed": "News updated",
"messagesRefreshed": "Messages synced",
"feedRefreshed": "Feed refreshed",
"ordersRefreshed": "Orders updated"
}
Testing RefreshIndicator Localization
Write comprehensive tests:
void main() {
group('LocalizedRefreshableList', () {
testWidgets('shows localized loading message', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const Scaffold(
body: LocalizedRefreshableList(),
),
),
);
expect(find.text('Loading content...'), findsOneWidget);
});
testWidgets('shows localized last updated time', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const Scaffold(
body: LocalizedRefreshableList(),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Last updated'), findsOneWidget);
expect(find.textContaining('just now'), findsOneWidget);
});
testWidgets('shows Spanish translations', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('es'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const Scaffold(
body: LocalizedRefreshableList(),
),
),
);
// Verify Spanish loading message
expect(find.text('Cargando contenido...'), findsOneWidget);
});
testWidgets('announces refresh to screen readers', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
home: const Scaffold(
body: AccessibleRefreshableList(),
),
),
);
await tester.pumpAndSettle();
// Find and tap refresh button
final refreshButton = find.byIcon(Icons.refresh);
await tester.tap(refreshButton);
await tester.pumpAndSettle();
// Verify refresh completed
expect(find.byIcon(Icons.refresh), findsOneWidget);
});
});
}
Best Practices Summary
- Show clear states: Pulling, ready to release, refreshing, success, error
- Format times locally: Use relative time formatting appropriate for locale
- Provide feedback: Snackbar or inline messages after refresh
- Support accessibility: Screen reader announcements and manual refresh button
- Handle empty states: Allow refresh even when list is empty
- Context-specific messages: Customize based on content type
- Test multiple locales: Verify pluralization and relative times
Conclusion
Localizing RefreshIndicator in Flutter involves more than just translating the loading message. By implementing localized pull instructions, relative time formatting, content-specific feedback, and proper accessibility announcements, you create a refresh experience that feels native to users worldwide. Remember to test with screen readers and verify that pluralization works correctly for relative time displays.