Flutter AnimatedRotation Localization: Spinning Icons, Direction Indicators, and RTL-Aware Rotations
AnimatedRotation provides smooth rotation animations for widgets. Proper localization ensures that spinning icons, directional indicators, and loading animations work seamlessly across languages and handle RTL layouts correctly. This guide covers comprehensive strategies for localizing AnimatedRotation widgets in Flutter.
Understanding AnimatedRotation Localization
AnimatedRotation widgets require localization for:
- Directional indicators: Arrows and chevrons that flip in RTL layouts
- Loading spinners: Rotation direction and accessibility announcements
- Expandable sections: Rotation icons for collapsible content
- Refresh indicators: Pull-to-refresh rotation states
- Navigation arrows: Back/forward indicators respecting text direction
- Accessibility feedback: Screen reader announcements for rotation states
Basic AnimatedRotation with RTL Support
Start with a simple expandable section with RTL-aware rotation:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedExpandableSection extends StatefulWidget {
const LocalizedExpandableSection({super.key});
@override
State<LocalizedExpandableSection> createState() => _LocalizedExpandableSectionState();
}
class _LocalizedExpandableSectionState extends State<LocalizedExpandableSection> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Card(
margin: const EdgeInsets.all(16),
child: Column(
children: [
InkWell(
onTap: () {
setState(() => _isExpanded = !_isExpanded);
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Semantics(
button: true,
expanded: _isExpanded,
label: l10n.expandSectionAccessibility(
_isExpanded ? l10n.expanded : l10n.collapsed,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
l10n.moreDetails,
style: Theme.of(context).textTheme.titleMedium,
),
),
AnimatedRotation(
turns: _isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.all(16),
child: Text(l10n.detailsContent),
),
crossFadeState: _isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
),
],
),
);
}
}
ARB File Structure for AnimatedRotation
{
"moreDetails": "More Details",
"@moreDetails": {
"description": "Header for expandable section"
},
"detailsContent": "Here is the detailed content that appears when you expand this section. It contains additional information relevant to the topic.",
"@detailsContent": {
"description": "Content shown when section is expanded"
},
"expanded": "expanded",
"@expanded": {
"description": "State label for expanded section"
},
"collapsed": "collapsed",
"@collapsed": {
"description": "State label for collapsed section"
},
"expandSectionAccessibility": "Section is {state}. Tap to toggle.",
"@expandSectionAccessibility": {
"description": "Accessibility label for expandable section",
"placeholders": {
"state": {"type": "String"}
}
}
}
RTL-Aware Navigation Arrows
Handle directional arrows that respect text direction:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedNavigationArrows extends StatefulWidget {
const LocalizedNavigationArrows({super.key});
@override
State<LocalizedNavigationArrows> createState() => _LocalizedNavigationArrowsState();
}
class _LocalizedNavigationArrowsState extends State<LocalizedNavigationArrows> {
int _currentPage = 0;
static const int _totalPages = 5;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
appBar: AppBar(title: Text(l10n.pageNavigationTitle)),
body: Column(
children: [
Expanded(
child: Center(
child: Text(
l10n.pageNumber(_currentPage + 1, _totalPages),
style: Theme.of(context).textTheme.headlineLarge,
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Back button
_DirectionalButton(
isEnabled: _currentPage > 0,
isForward: false,
isRtl: isRtl,
label: l10n.previousPage,
onPressed: () => setState(() => _currentPage--),
),
// Page indicators
Row(
children: List.generate(_totalPages, (index) {
return Semantics(
label: l10n.pageIndicatorAccessibility(index + 1),
selected: index == _currentPage,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: index == _currentPage ? 12 : 8,
height: index == _currentPage ? 12 : 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _currentPage
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
),
);
}),
),
// Forward button
_DirectionalButton(
isEnabled: _currentPage < _totalPages - 1,
isForward: true,
isRtl: isRtl,
label: l10n.nextPage,
onPressed: () => setState(() => _currentPage++),
),
],
),
),
],
),
);
}
}
class _DirectionalButton extends StatefulWidget {
final bool isEnabled;
final bool isForward;
final bool isRtl;
final String label;
final VoidCallback onPressed;
const _DirectionalButton({
required this.isEnabled,
required this.isForward,
required this.isRtl,
required this.label,
required this.onPressed,
});
@override
State<_DirectionalButton> createState() => _DirectionalButtonState();
}
class _DirectionalButtonState extends State<_DirectionalButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
// Calculate rotation based on direction and RTL
// Forward arrow: points right in LTR, left in RTL
// Back arrow: points left in LTR, right in RTL
double baseRotation = widget.isForward ? 0 : 0.5;
if (widget.isRtl) {
baseRotation = widget.isForward ? 0.5 : 0;
}
// Add subtle rotation on hover
double hoverOffset = _isHovered && widget.isEnabled
? (widget.isForward ? 0.02 : -0.02)
: 0;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Semantics(
button: true,
enabled: widget.isEnabled,
label: widget.label,
child: IconButton(
onPressed: widget.isEnabled ? widget.onPressed : null,
icon: AnimatedRotation(
turns: baseRotation + hoverOffset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: Icon(
Icons.arrow_forward_ios,
color: widget.isEnabled
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
),
),
),
);
}
}
Loading Spinner with Status Messages
Create a loading spinner with localized status updates:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
enum LoadingPhase { connecting, fetching, processing, complete, error }
class LocalizedLoadingSpinner extends StatefulWidget {
const LocalizedLoadingSpinner({super.key});
@override
State<LocalizedLoadingSpinner> createState() => _LocalizedLoadingSpinnerState();
}
class _LocalizedLoadingSpinnerState extends State<LocalizedLoadingSpinner>
with SingleTickerProviderStateMixin {
late AnimationController _spinController;
LoadingPhase _phase = LoadingPhase.connecting;
bool _isLoading = false;
@override
void initState() {
super.initState();
_spinController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
}
@override
void dispose() {
_spinController.dispose();
super.dispose();
}
Future<void> _startLoading() async {
setState(() {
_isLoading = true;
_phase = LoadingPhase.connecting;
});
_spinController.repeat();
await Future.delayed(const Duration(seconds: 1));
if (mounted) setState(() => _phase = LoadingPhase.fetching);
await Future.delayed(const Duration(seconds: 1));
if (mounted) setState(() => _phase = LoadingPhase.processing);
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() {
_phase = LoadingPhase.complete;
_isLoading = false;
});
_spinController.stop();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.loadingDemoTitle)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _spinController,
builder: (context, child) {
return AnimatedRotation(
turns: _isLoading ? _spinController.value : 0,
duration: Duration.zero,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getPhaseColor(context),
),
child: Icon(
_getPhaseIcon(),
size: 40,
color: Colors.white,
),
),
);
},
),
const SizedBox(height: 24),
Semantics(
liveRegion: true,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_getPhaseMessage(l10n),
key: ValueKey(_phase),
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 32),
if (!_isLoading && _phase != LoadingPhase.complete)
ElevatedButton(
onPressed: _startLoading,
child: Text(l10n.startLoadingButton),
),
if (_phase == LoadingPhase.complete)
ElevatedButton.icon(
onPressed: () {
setState(() => _phase = LoadingPhase.connecting);
},
icon: const Icon(Icons.refresh),
label: Text(l10n.loadAgainButton),
),
],
),
),
);
}
Color _getPhaseColor(BuildContext context) {
return switch (_phase) {
LoadingPhase.connecting => Colors.blue,
LoadingPhase.fetching => Colors.orange,
LoadingPhase.processing => Colors.purple,
LoadingPhase.complete => Colors.green,
LoadingPhase.error => Theme.of(context).colorScheme.error,
};
}
IconData _getPhaseIcon() {
return switch (_phase) {
LoadingPhase.connecting => Icons.wifi,
LoadingPhase.fetching => Icons.download,
LoadingPhase.processing => Icons.settings,
LoadingPhase.complete => Icons.check,
LoadingPhase.error => Icons.error,
};
}
String _getPhaseMessage(AppLocalizations l10n) {
return switch (_phase) {
LoadingPhase.connecting => l10n.connectingToServer,
LoadingPhase.fetching => l10n.fetchingData,
LoadingPhase.processing => l10n.processingData,
LoadingPhase.complete => l10n.loadingComplete,
LoadingPhase.error => l10n.loadingError,
};
}
}
Accordion Menu with Rotating Icons
Build an accordion menu with properly localized rotation animations:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedAccordionMenu extends StatefulWidget {
const LocalizedAccordionMenu({super.key});
@override
State<LocalizedAccordionMenu> createState() => _LocalizedAccordionMenuState();
}
class _LocalizedAccordionMenuState extends State<LocalizedAccordionMenu> {
int? _expandedIndex;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final sections = [
_AccordionSection(
title: l10n.faqPaymentTitle,
content: l10n.faqPaymentContent,
icon: Icons.payment,
),
_AccordionSection(
title: l10n.faqShippingTitle,
content: l10n.faqShippingContent,
icon: Icons.local_shipping,
),
_AccordionSection(
title: l10n.faqReturnsTitle,
content: l10n.faqReturnsContent,
icon: Icons.assignment_return,
),
_AccordionSection(
title: l10n.faqSupportTitle,
content: l10n.faqSupportContent,
icon: Icons.support_agent,
),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.faqTitle)),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: sections.length,
itemBuilder: (context, index) {
final section = sections[index];
final isExpanded = _expandedIndex == index;
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
InkWell(
onTap: () {
setState(() {
_expandedIndex = isExpanded ? null : index;
});
},
child: Semantics(
button: true,
expanded: isExpanded,
label: l10n.faqItemAccessibility(
section.title,
isExpanded ? l10n.expanded : l10n.collapsed,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
section.icon,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Text(
section.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
AnimatedRotation(
turns: isExpanded ? 0.125 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
),
AnimatedCrossFade(
firstChild: const SizedBox(width: double.infinity),
secondChild: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
section.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
],
),
);
},
),
);
}
}
class _AccordionSection {
final String title;
final String content;
final IconData icon;
_AccordionSection({
required this.title,
required this.content,
required this.icon,
});
}
Refresh Button with Rotation Animation
Create a refresh button that rotates when pressed:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedRefreshButton extends StatefulWidget {
const LocalizedRefreshButton({super.key});
@override
State<LocalizedRefreshButton> createState() => _LocalizedRefreshButtonState();
}
class _LocalizedRefreshButtonState extends State<LocalizedRefreshButton> {
double _rotationTurns = 0;
bool _isRefreshing = false;
DateTime _lastUpdated = DateTime.now();
Future<void> _refresh() async {
if (_isRefreshing) return;
setState(() {
_isRefreshing = true;
_rotationTurns += 1;
});
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isRefreshing = false;
_lastUpdated = DateTime.now();
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Scaffold(
appBar: AppBar(
title: Text(l10n.dataViewTitle),
actions: [
Semantics(
button: true,
enabled: !_isRefreshing,
label: _isRefreshing
? l10n.refreshingData
: l10n.refreshDataButton,
child: IconButton(
onPressed: _isRefreshing ? null : _refresh,
icon: AnimatedRotation(
turns: _rotationTurns,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
child: Icon(
Icons.refresh,
color: _isRefreshing
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
),
),
),
],
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
l10n.dataUpToDate,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
l10n.lastUpdated(_formatDateTime(_lastUpdated, locale)),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
if (_isRefreshing) ...[
const SizedBox(height: 24),
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(l10n.refreshingData),
],
],
),
),
);
}
String _formatDateTime(DateTime dateTime, Locale locale) {
// Use locale-aware formatting
final hour = dateTime.hour.toString().padLeft(2, '0');
final minute = dateTime.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
}
Settings Toggle with Animated Icon
Create settings toggles with rotating icons:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSettingsToggles extends StatefulWidget {
const LocalizedSettingsToggles({super.key});
@override
State<LocalizedSettingsToggles> createState() => _LocalizedSettingsTogglesState();
}
class _LocalizedSettingsTogglesState extends State<LocalizedSettingsToggles> {
bool _darkMode = false;
bool _autoRotate = true;
bool _syncEnabled = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)),
body: ListView(
children: [
_SettingsTile(
icon: Icons.dark_mode,
title: l10n.darkModeTitle,
subtitle: l10n.darkModeSubtitle,
isEnabled: _darkMode,
rotateIcon: true,
onChanged: (value) => setState(() => _darkMode = value),
),
_SettingsTile(
icon: Icons.screen_rotation,
title: l10n.autoRotateTitle,
subtitle: l10n.autoRotateSubtitle,
isEnabled: _autoRotate,
rotateIcon: true,
onChanged: (value) => setState(() => _autoRotate = value),
),
_SettingsTile(
icon: Icons.sync,
title: l10n.syncTitle,
subtitle: l10n.syncSubtitle,
isEnabled: _syncEnabled,
rotateIcon: true,
onChanged: (value) => setState(() => _syncEnabled = value),
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool isEnabled;
final bool rotateIcon;
final ValueChanged<bool> onChanged;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.isEnabled,
required this.rotateIcon,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: AnimatedRotation(
turns: rotateIcon && isEnabled ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isEnabled
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isEnabled
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
title: Text(title),
subtitle: Text(subtitle),
trailing: Switch(
value: isEnabled,
onChanged: onChanged,
),
);
}
}
Complete ARB File for AnimatedRotation
{
"@@locale": "en",
"moreDetails": "More Details",
"detailsContent": "Here is the detailed content that appears when you expand this section. It contains additional information relevant to the topic.",
"expanded": "expanded",
"collapsed": "collapsed",
"expandSectionAccessibility": "Section is {state}. Tap to toggle.",
"@expandSectionAccessibility": {
"placeholders": {
"state": {"type": "String"}
}
},
"pageNavigationTitle": "Page Navigation",
"pageNumber": "Page {current} of {total}",
"@pageNumber": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"previousPage": "Previous page",
"nextPage": "Next page",
"pageIndicatorAccessibility": "Page {number}",
"@pageIndicatorAccessibility": {
"placeholders": {
"number": {"type": "int"}
}
},
"loadingDemoTitle": "Loading Demo",
"connectingToServer": "Connecting to server...",
"fetchingData": "Fetching data...",
"processingData": "Processing data...",
"loadingComplete": "Loading complete!",
"loadingError": "An error occurred. Please try again.",
"startLoadingButton": "Start Loading",
"loadAgainButton": "Load Again",
"faqTitle": "Frequently Asked Questions",
"faqPaymentTitle": "Payment Methods",
"faqPaymentContent": "We accept all major credit cards, PayPal, and bank transfers. Your payment information is encrypted and secure.",
"faqShippingTitle": "Shipping Information",
"faqShippingContent": "We offer free shipping on orders over $50. Standard delivery takes 3-5 business days, while express delivery takes 1-2 business days.",
"faqReturnsTitle": "Returns & Refunds",
"faqReturnsContent": "Items can be returned within 30 days of purchase. Refunds are processed within 5-7 business days after we receive the returned item.",
"faqSupportTitle": "Customer Support",
"faqSupportContent": "Our support team is available 24/7. You can reach us via email, phone, or live chat. We aim to respond within 2 hours.",
"faqItemAccessibility": "{title}, {state}",
"@faqItemAccessibility": {
"placeholders": {
"title": {"type": "String"},
"state": {"type": "String"}
}
},
"dataViewTitle": "Data View",
"refreshDataButton": "Refresh data",
"refreshingData": "Refreshing...",
"dataUpToDate": "Data is up to date",
"lastUpdated": "Last updated: {time}",
"@lastUpdated": {
"placeholders": {
"time": {"type": "String"}
}
},
"settingsTitle": "Settings",
"darkModeTitle": "Dark Mode",
"darkModeSubtitle": "Switch between light and dark themes",
"autoRotateTitle": "Auto-Rotate",
"autoRotateSubtitle": "Automatically rotate screen orientation",
"syncTitle": "Background Sync",
"syncSubtitle": "Keep data synced in the background"
}
Best Practices Summary
- Handle RTL layouts: Flip rotation direction for directional indicators
- Use appropriate turns values: 0.25 = 90°, 0.5 = 180°, 1.0 = 360°
- Provide accessibility labels: Announce rotation state changes to screen readers
- Combine with other animations: Mix rotation with opacity or scale for polish
- Choose meaningful rotation directions: Up/down for expand/collapse, full rotation for refresh
- Test with different locales: Verify directional animations work correctly in RTL
- Use semantic rotation values: 0.125 (45°) for subtle effects, 0.5 (180°) for flip indicators
- Consider animation duration: Shorter for interactive feedback, longer for loading states
- Handle disabled states: Reduce opacity or change color when rotation is not active
- Maintain consistency: Use the same rotation patterns throughout your app
Conclusion
AnimatedRotation is a versatile widget for creating smooth rotational animations in multilingual Flutter apps. By properly handling RTL layouts, providing meaningful accessibility announcements, and choosing appropriate rotation values, you create intuitive experiences for users worldwide. The patterns shown here—expandable sections, navigation arrows, loading spinners, and settings toggles—can be adapted for any application requiring animated rotational transitions.
Remember to test your rotation animations with various locales to ensure that directional indicators behave correctly regardless of the user's language preference.