Flutter AnimatedSwitcher Localization: Content Transitions, Widget Swapping, and Localized Animations
AnimatedSwitcher provides smooth transitions when switching between widgets. Proper localization ensures that content swaps, loading states, and dynamic UI updates work seamlessly across languages and text directions. This guide covers comprehensive strategies for localizing AnimatedSwitcher widgets in Flutter.
Understanding AnimatedSwitcher Localization
AnimatedSwitcher widgets require localization for:
- Content transitions: Switching between localized text or content
- Loading states: Transitioning from loaders to localized content
- Dynamic updates: Animating between different localized states
- RTL transitions: Ensuring animations flow correctly in RTL layouts
- Accessibility announcements: Screen reader feedback for content changes
- Error/success states: Transitioning between localized status messages
Basic AnimatedSwitcher with Localized Content
Start with a simple AnimatedSwitcher that handles localized content:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedContentSwitcher extends StatefulWidget {
const LocalizedContentSwitcher({super.key});
@override
State<LocalizedContentSwitcher> createState() => _LocalizedContentSwitcherState();
}
class _LocalizedContentSwitcherState extends State<LocalizedContentSwitcher> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
final contents = [
_ContentItem(l10n.welcomeTitle, l10n.welcomeDescription, Icons.waving_hand),
_ContentItem(l10n.featuresTitle, l10n.featuresDescription, Icons.star),
_ContentItem(l10n.getStartedTitle, l10n.getStartedDescription, Icons.rocket_launch),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.onboardingTitle)),
body: Column(
children: [
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
// Use directional slide for RTL support
final slideAnimation = Tween<Offset>(
begin: Offset(isRtl ? -1.0 : 1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Semantics(
key: ValueKey(_currentIndex),
liveRegion: true,
label: l10n.onboardingStepAccessibility(
_currentIndex + 1,
contents.length,
),
child: _buildContentCard(contents[_currentIndex]),
),
),
),
// Navigation dots
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(contents.length, (index) {
return GestureDetector(
onTap: () => setState(() => _currentIndex = index),
child: Semantics(
button: true,
label: l10n.goToStep(index + 1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentIndex == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentIndex == index
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}),
),
),
// Navigation buttons
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _currentIndex > 0
? () => setState(() => _currentIndex--)
: null,
child: Text(l10n.previousButton),
),
ElevatedButton(
onPressed: _currentIndex < contents.length - 1
? () => setState(() => _currentIndex++)
: () => _completeOnboarding(),
child: Text(
_currentIndex < contents.length - 1
? l10n.nextButton
: l10n.finishButton,
),
),
],
),
),
],
),
);
}
Widget _buildContentCard(_ContentItem item) {
return Padding(
key: ValueKey(item.title),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
item.title,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
item.description,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
);
}
void _completeOnboarding() {
// Handle onboarding completion
}
}
class _ContentItem {
final String title;
final String description;
final IconData icon;
_ContentItem(this.title, this.description, this.icon);
}
ARB File Structure for AnimatedSwitcher
{
"onboardingTitle": "Get Started",
"@onboardingTitle": {
"description": "Title for onboarding screen"
},
"welcomeTitle": "Welcome",
"@welcomeTitle": {
"description": "First onboarding step title"
},
"welcomeDescription": "Discover all the features our app has to offer",
"@welcomeDescription": {
"description": "First onboarding step description"
},
"featuresTitle": "Powerful Features",
"@featuresTitle": {
"description": "Second onboarding step title"
},
"featuresDescription": "Tools designed to make your life easier",
"@featuresDescription": {
"description": "Second onboarding step description"
},
"getStartedTitle": "Ready to Begin",
"@getStartedTitle": {
"description": "Third onboarding step title"
},
"getStartedDescription": "Start your journey with us today",
"@getStartedDescription": {
"description": "Third onboarding step description"
},
"onboardingStepAccessibility": "Step {current} of {total}",
"@onboardingStepAccessibility": {
"description": "Accessibility label for current step",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"goToStep": "Go to step {step}",
"@goToStep": {
"description": "Accessibility label for navigation dot",
"placeholders": {
"step": {"type": "int"}
}
},
"previousButton": "Previous",
"nextButton": "Next",
"finishButton": "Get Started"
}
Loading State Transitions
Handle transitions between loading and content states:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum LoadingState { loading, success, error, empty }
class LocalizedLoadingTransition extends StatefulWidget {
const LocalizedLoadingTransition({super.key});
@override
State<LocalizedLoadingTransition> createState() => _LocalizedLoadingTransitionState();
}
class _LocalizedLoadingTransitionState extends State<LocalizedLoadingTransition> {
LoadingState _state = LoadingState.loading;
List<String> _items = [];
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _state = LoadingState.loading);
try {
await Future.delayed(const Duration(seconds: 2));
// Simulate data fetch
final items = ['Item 1', 'Item 2', 'Item 3'];
setState(() {
_items = items;
_state = items.isEmpty ? LoadingState.empty : LoadingState.success;
});
} catch (e) {
setState(() => _state = LoadingState.error);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.dataListTitle)),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: child,
),
);
},
child: _buildStateWidget(l10n),
),
);
}
Widget _buildStateWidget(AppLocalizations l10n) {
return switch (_state) {
LoadingState.loading => _LoadingWidget(
key: const ValueKey('loading'),
message: l10n.loadingData,
),
LoadingState.success => _SuccessWidget(
key: const ValueKey('success'),
items: _items,
l10n: l10n,
),
LoadingState.error => _ErrorWidget(
key: const ValueKey('error'),
message: l10n.errorLoadingData,
retryLabel: l10n.retryButton,
onRetry: _loadData,
),
LoadingState.empty => _EmptyWidget(
key: const ValueKey('empty'),
message: l10n.noDataAvailable,
actionLabel: l10n.refreshButton,
onAction: _loadData,
),
};
}
}
class _LoadingWidget extends StatelessWidget {
final String message;
const _LoadingWidget({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Semantics(
label: message,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
],
),
),
);
}
}
class _SuccessWidget extends StatelessWidget {
final List<String> items;
final AppLocalizations l10n;
const _SuccessWidget({
super.key,
required this.items,
required this.l10n,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: l10n.itemsLoadedAccessibility(items.length),
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(items[index]),
);
},
),
);
}
}
class _ErrorWidget extends StatelessWidget {
final String message;
final String retryLabel;
final VoidCallback onRetry;
const _ErrorWidget({
super.key,
required this.message,
required this.retryLabel,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(retryLabel),
),
],
),
);
}
}
class _EmptyWidget extends StatelessWidget {
final String message;
final String actionLabel;
final VoidCallback onAction;
const _EmptyWidget({
super.key,
required this.message,
required this.actionLabel,
required this.onAction,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
OutlinedButton(
onPressed: onAction,
child: Text(actionLabel),
),
],
),
);
}
}
Counter with Animated Transitions
Create an animated counter with localized number formatting:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
class LocalizedAnimatedCounter extends StatefulWidget {
const LocalizedAnimatedCounter({super.key});
@override
State<LocalizedAnimatedCounter> createState() => _LocalizedAnimatedCounterState();
}
class _LocalizedAnimatedCounterState extends State<LocalizedAnimatedCounter> {
int _count = 0;
int _previousCount = 0;
void _increment() {
setState(() {
_previousCount = _count;
_count++;
});
}
void _decrement() {
if (_count > 0) {
setState(() {
_previousCount = _count;
_count--;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final numberFormat = NumberFormat.decimalPattern(locale.toString());
final isIncreasing = _count > _previousCount;
return Scaffold(
appBar: AppBar(title: Text(l10n.counterTitle)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.currentCount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
// Slide up for increment, slide down for decrement
final slideAnimation = Tween<Offset>(
begin: Offset(0, isIncreasing ? 0.5 : -0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
));
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Semantics(
key: ValueKey(_count),
liveRegion: true,
label: l10n.counterValueAccessibility(_count),
child: Text(
numberFormat.format(_count),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
fontFeatures: [const FontFeature.tabularFigures()],
),
),
),
),
const SizedBox(height: 24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: 'decrement',
onPressed: _count > 0 ? _decrement : null,
tooltip: l10n.decrementTooltip,
child: const Icon(Icons.remove),
),
const SizedBox(width: 16),
FloatingActionButton(
heroTag: 'increment',
onPressed: _increment,
tooltip: l10n.incrementTooltip,
child: const Icon(Icons.add),
),
],
),
const SizedBox(height: 16),
TextButton(
onPressed: _count > 0
? () => setState(() {
_previousCount = _count;
_count = 0;
})
: null,
child: Text(l10n.resetCounter),
),
],
),
),
);
}
}
View Mode Switcher
Switch between different view modes with localized labels:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum ViewMode { list, grid, compact }
class LocalizedViewModeSwitcher extends StatefulWidget {
const LocalizedViewModeSwitcher({super.key});
@override
State<LocalizedViewModeSwitcher> createState() => _LocalizedViewModeSwitcherState();
}
class _LocalizedViewModeSwitcherState extends State<LocalizedViewModeSwitcher> {
ViewMode _viewMode = ViewMode.list;
final List<int> _items = List.generate(20, (i) => i + 1);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.itemsTitle),
actions: [
PopupMenuButton<ViewMode>(
icon: Icon(_getViewModeIcon(_viewMode)),
tooltip: l10n.changeViewMode,
onSelected: (mode) => setState(() => _viewMode = mode),
itemBuilder: (context) => ViewMode.values.map((mode) {
return PopupMenuItem(
value: mode,
child: Row(
children: [
Icon(_getViewModeIcon(mode)),
const SizedBox(width: 8),
Text(_getViewModeLabel(l10n, mode)),
],
),
);
}).toList(),
),
],
),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
child: Semantics(
key: ValueKey(_viewMode),
label: l10n.viewModeAccessibility(_getViewModeLabel(l10n, _viewMode)),
child: _buildViewContent(l10n),
),
),
);
}
Widget _buildViewContent(AppLocalizations l10n) {
return switch (_viewMode) {
ViewMode.list => _buildListView(l10n),
ViewMode.grid => _buildGridView(l10n),
ViewMode.compact => _buildCompactView(l10n),
};
}
Widget _buildListView(AppLocalizations l10n) {
return ListView.builder(
key: const ValueKey('list'),
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text('${_items[index]}'),
),
title: Text(l10n.itemTitle(_items[index])),
subtitle: Text(l10n.itemDescription(_items[index])),
trailing: const Icon(Icons.chevron_right),
);
},
);
}
Widget _buildGridView(AppLocalizations l10n) {
return GridView.builder(
key: const ValueKey('grid'),
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: _items.length,
itemBuilder: (context, index) {
return Card(
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 24,
child: Text('${_items[index]}'),
),
const SizedBox(height: 8),
Text(
l10n.itemTitle(_items[index]),
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
],
),
),
),
);
},
);
}
Widget _buildCompactView(AppLocalizations l10n) {
return ListView.separated(
key: const ValueKey('compact'),
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
'${_items[index]}.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(child: Text(l10n.itemTitle(_items[index]))),
],
),
);
},
);
}
IconData _getViewModeIcon(ViewMode mode) {
return switch (mode) {
ViewMode.list => Icons.view_list,
ViewMode.grid => Icons.grid_view,
ViewMode.compact => Icons.view_headline,
};
}
String _getViewModeLabel(AppLocalizations l10n, ViewMode mode) {
return switch (mode) {
ViewMode.list => l10n.viewModeList,
ViewMode.grid => l10n.viewModeGrid,
ViewMode.compact => l10n.viewModeCompact,
};
}
}
Status Message Transitions
Animated status messages with localization:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum FormStatus { idle, validating, success, error }
class LocalizedStatusTransition extends StatefulWidget {
const LocalizedStatusTransition({super.key});
@override
State<LocalizedStatusTransition> createState() => _LocalizedStatusTransitionState();
}
class _LocalizedStatusTransitionState extends State<LocalizedStatusTransition> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
FormStatus _status = FormStatus.idle;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _status = FormStatus.validating);
await Future.delayed(const Duration(seconds: 2));
// Simulate validation
final email = _emailController.text;
if (email.contains('test')) {
setState(() {
_status = FormStatus.error;
_errorMessage = 'emailAlreadyExists';
});
} else {
setState(() => _status = FormStatus.success);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.subscribeTitle)),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailLabel,
hintText: l10n.emailHint,
prefixIcon: const Icon(Icons.email),
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.emailRequired;
}
if (!value.contains('@')) {
return l10n.emailInvalid;
}
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _status == FormStatus.validating ? null : _submit,
child: _status == FormStatus.validating
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Text(l10n.subscribeButton),
),
const SizedBox(height: 24),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: _buildStatusMessage(l10n),
),
],
),
),
),
);
}
Widget _buildStatusMessage(AppLocalizations l10n) {
return switch (_status) {
FormStatus.idle => const SizedBox.shrink(key: ValueKey('idle')),
FormStatus.validating => _StatusCard(
key: const ValueKey('validating'),
icon: Icons.hourglass_empty,
message: l10n.validatingEmail,
color: Theme.of(context).colorScheme.primary,
),
FormStatus.success => _StatusCard(
key: const ValueKey('success'),
icon: Icons.check_circle,
message: l10n.subscriptionSuccess,
color: Colors.green,
),
FormStatus.error => _StatusCard(
key: const ValueKey('error'),
icon: Icons.error,
message: _getErrorMessage(l10n),
color: Theme.of(context).colorScheme.error,
),
};
}
String _getErrorMessage(AppLocalizations l10n) {
return switch (_errorMessage) {
'emailAlreadyExists' => l10n.emailAlreadyExists,
_ => l10n.subscriptionError,
};
}
}
class _StatusCard extends StatelessWidget {
final IconData icon;
final String message;
final Color color;
const _StatusCard({
super.key,
required this.icon,
required this.message,
required this.color,
});
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(color: color),
),
),
],
),
),
);
}
}
Language Switcher with Animated Transition
Create an animated language switcher:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedLanguageSwitcher extends StatefulWidget {
final Function(Locale) onLocaleChanged;
const LocalizedLanguageSwitcher({
super.key,
required this.onLocaleChanged,
});
@override
State<LocalizedLanguageSwitcher> createState() => _LocalizedLanguageSwitcherState();
}
class _LocalizedLanguageSwitcherState extends State<LocalizedLanguageSwitcher> {
final List<_LanguageOption> _languages = [
_LanguageOption(const Locale('en'), 'English', 'EN'),
_LanguageOption(const Locale('ar'), 'العربية', 'AR'),
_LanguageOption(const Locale('es'), 'Español', 'ES'),
_LanguageOption(const Locale('fr'), 'Français', 'FR'),
_LanguageOption(const Locale('de'), 'Deutsch', 'DE'),
_LanguageOption(const Locale('ja'), '日本語', 'JA'),
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final currentLocale = Localizations.localeOf(context);
final currentLanguage = _languages.firstWhere(
(lang) => lang.locale.languageCode == currentLocale.languageCode,
orElse: () => _languages.first,
);
return Scaffold(
appBar: AppBar(title: Text(l10n.languageSettingsTitle)),
body: Column(
children: [
// Current language display
Padding(
padding: const EdgeInsets.all(24),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Column(
key: ValueKey(currentLanguage.locale),
children: [
CircleAvatar(
radius: 40,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text(
currentLanguage.code,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 16),
Text(
currentLanguage.name,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
l10n.currentLanguage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
),
const Divider(),
// Language list
Expanded(
child: ListView.builder(
itemCount: _languages.length,
itemBuilder: (context, index) {
final language = _languages[index];
final isSelected = language.locale.languageCode ==
currentLocale.languageCode;
return Semantics(
selected: isSelected,
label: l10n.selectLanguageAccessibility(language.name),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceVariant,
child: Text(
language.code,
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
title: Text(language.name),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: () => widget.onLocaleChanged(language.locale),
),
);
},
),
),
],
),
);
}
}
class _LanguageOption {
final Locale locale;
final String name;
final String code;
_LanguageOption(this.locale, this.name, this.code);
}
Complete ARB File for AnimatedSwitcher
{
"@@locale": "en",
"onboardingTitle": "Get Started",
"@onboardingTitle": {
"description": "Title for onboarding screen"
},
"welcomeTitle": "Welcome",
"welcomeDescription": "Discover all the features our app has to offer",
"featuresTitle": "Powerful Features",
"featuresDescription": "Tools designed to make your life easier",
"getStartedTitle": "Ready to Begin",
"getStartedDescription": "Start your journey with us today",
"onboardingStepAccessibility": "Step {current} of {total}",
"@onboardingStepAccessibility": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"goToStep": "Go to step {step}",
"@goToStep": {
"placeholders": {
"step": {"type": "int"}
}
},
"previousButton": "Previous",
"nextButton": "Next",
"finishButton": "Get Started",
"dataListTitle": "Data List",
"loadingData": "Loading data...",
"errorLoadingData": "Failed to load data. Please try again.",
"noDataAvailable": "No data available",
"retryButton": "Retry",
"refreshButton": "Refresh",
"itemsLoadedAccessibility": "{count} items loaded",
"@itemsLoadedAccessibility": {
"placeholders": {
"count": {"type": "int"}
}
},
"counterTitle": "Counter",
"currentCount": "Current count",
"counterValueAccessibility": "Count is {value}",
"@counterValueAccessibility": {
"placeholders": {
"value": {"type": "int"}
}
},
"incrementTooltip": "Increment",
"decrementTooltip": "Decrement",
"resetCounter": "Reset",
"itemsTitle": "Items",
"changeViewMode": "Change view mode",
"viewModeList": "List view",
"viewModeGrid": "Grid view",
"viewModeCompact": "Compact view",
"viewModeAccessibility": "Currently showing {mode}",
"@viewModeAccessibility": {
"placeholders": {
"mode": {"type": "String"}
}
},
"itemTitle": "Item {number}",
"@itemTitle": {
"placeholders": {
"number": {"type": "int"}
}
},
"itemDescription": "Description for item {number}",
"@itemDescription": {
"placeholders": {
"number": {"type": "int"}
}
},
"subscribeTitle": "Subscribe",
"emailLabel": "Email address",
"emailHint": "Enter your email",
"emailRequired": "Email is required",
"emailInvalid": "Enter a valid email address",
"subscribeButton": "Subscribe",
"validatingEmail": "Validating your email...",
"subscriptionSuccess": "Successfully subscribed! Check your inbox.",
"subscriptionError": "Subscription failed. Please try again.",
"emailAlreadyExists": "This email is already subscribed.",
"languageSettingsTitle": "Language",
"currentLanguage": "Current language",
"selectLanguageAccessibility": "Select {language}",
"@selectLanguageAccessibility": {
"placeholders": {
"language": {"type": "String"}
}
}
}
Best Practices Summary
- Use ValueKey for switching: Always provide unique keys to enable proper animations
- Handle RTL transitions: Adjust slide directions based on text direction
- Announce content changes: Use Semantics with liveRegion for accessibility
- Format numbers locally: Use NumberFormat for locale-specific number display
- Provide status feedback: Include loading, success, and error states with localized messages
- Use appropriate durations: Balance smoothness with responsiveness (200-400ms typical)
- Combine transitions: Mix fade, slide, and scale for polished effects
- Test with different locales: Verify animations work in both LTR and RTL layouts
- Handle different text lengths: Ensure containers accommodate longer translations
- Support screen readers: Announce state changes for visually impaired users
Conclusion
AnimatedSwitcher is a versatile widget for creating smooth content transitions in multilingual Flutter apps. By properly handling RTL layouts, providing accessibility announcements, and formatting content for different locales, you create seamless experiences for users worldwide. The patterns shown here—onboarding flows, loading states, counters, and view mode switchers—can be adapted for any application requiring animated content transitions.
Remember to test your transitions with various locales to ensure that animations flow naturally and content displays correctly regardless of the user's language preference.