Flutter AnimatedScale Localization: Zoom Effects, Interactive Feedback, and Accessible Scaling
AnimatedScale provides smooth scaling animations for widgets. Proper localization ensures that zoom effects, press feedback, and emphasis animations work seamlessly across languages with appropriate accessibility announcements. This guide covers comprehensive strategies for localizing AnimatedScale widgets in Flutter.
Understanding AnimatedScale Localization
AnimatedScale widgets require localization for:
- Button press feedback: Scaling effects with localized labels
- Selection indicators: Emphasis animations for selected items
- Card highlights: Hover and focus states for interactive elements
- Image zoom: Preview scaling with accessible descriptions
- Attention animations: Drawing focus to important localized content
- Accessibility feedback: Screen reader announcements for scale changes
Basic AnimatedScale with Interactive Feedback
Start with a simple button that scales on press:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedScaleButton extends StatefulWidget {
final VoidCallback onPressed;
final String label;
const LocalizedScaleButton({
super.key,
required this.onPressed,
required this.label,
});
@override
State<LocalizedScaleButton> createState() => _LocalizedScaleButtonState();
}
class _LocalizedScaleButtonState extends State<LocalizedScaleButton> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) {
setState(() => _isPressed = false);
widget.onPressed();
},
onTapCancel: () => setState(() => _isPressed = false),
child: Semantics(
button: true,
label: widget.label,
child: AnimatedScale(
scale: _isPressed ? 0.95 : 1.0,
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
boxShadow: _isPressed
? []
: [
BoxShadow(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Text(
widget.label,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}
}
class LocalizedScaleButtonDemo extends StatelessWidget {
const LocalizedScaleButtonDemo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.buttonDemoTitle)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LocalizedScaleButton(
label: l10n.submitButton,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.formSubmitted)),
);
},
),
const SizedBox(height: 16),
LocalizedScaleButton(
label: l10n.cancelButton,
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
);
}
}
ARB File Structure for AnimatedScale
{
"buttonDemoTitle": "Button Demo",
"@buttonDemoTitle": {
"description": "Title for button demo screen"
},
"submitButton": "Submit",
"@submitButton": {
"description": "Label for submit button"
},
"cancelButton": "Cancel",
"@cancelButton": {
"description": "Label for cancel button"
},
"formSubmitted": "Form submitted successfully",
"@formSubmitted": {
"description": "Success message after form submission"
}
}
Selectable Card Grid with Scale Animation
Create a grid of selectable cards with scaling effects:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedSelectableCardGrid extends StatefulWidget {
const LocalizedSelectableCardGrid({super.key});
@override
State<LocalizedSelectableCardGrid> createState() => _LocalizedSelectableCardGridState();
}
class _LocalizedSelectableCardGridState extends State<LocalizedSelectableCardGrid> {
final Set<int> _selectedIndices = {};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = [
_Category(l10n.categoryTechnology, Icons.computer, Colors.blue),
_Category(l10n.categoryTravel, Icons.flight, Colors.green),
_Category(l10n.categoryFood, Icons.restaurant, Colors.orange),
_Category(l10n.categorySports, Icons.sports_soccer, Colors.red),
_Category(l10n.categoryMusic, Icons.music_note, Colors.purple),
_Category(l10n.categoryArt, Icons.palette, Colors.pink),
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.selectInterestsTitle),
actions: [
if (_selectedIndices.isNotEmpty)
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.interestsSelected(_selectedIndices.length)),
),
);
},
child: Text(l10n.doneButton),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.selectInterestsDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = _selectedIndices.contains(index);
return _SelectableCard(
category: category,
isSelected: isSelected,
accessibilityLabel: l10n.categoryAccessibility(
category.name,
isSelected ? l10n.selected : l10n.notSelected,
),
onTap: () {
setState(() {
if (isSelected) {
_selectedIndices.remove(index);
} else {
_selectedIndices.add(index);
}
});
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.selectedCount(_selectedIndices.length),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
),
],
),
);
}
}
class _Category {
final String name;
final IconData icon;
final Color color;
_Category(this.name, this.icon, this.color);
}
class _SelectableCard extends StatefulWidget {
final _Category category;
final bool isSelected;
final String accessibilityLabel;
final VoidCallback onTap;
const _SelectableCard({
required this.category,
required this.isSelected,
required this.accessibilityLabel,
required this.onTap,
});
@override
State<_SelectableCard> createState() => _SelectableCardState();
}
class _SelectableCardState extends State<_SelectableCard> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) {
setState(() => _isPressed = false);
widget.onTap();
},
onTapCancel: () => setState(() => _isPressed = false),
child: Semantics(
button: true,
selected: widget.isSelected,
label: widget.accessibilityLabel,
child: AnimatedScale(
scale: _isPressed
? 0.95
: widget.isSelected
? 1.05
: 1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: widget.isSelected
? widget.category.color.withOpacity(0.2)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: widget.isSelected
? widget.category.color
: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: widget.isSelected ? 2 : 1,
),
boxShadow: widget.isSelected
? [
BoxShadow(
color: widget.category.color.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: [],
),
child: Stack(
children: [
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.category.icon,
size: 40,
color: widget.isSelected
? widget.category.color
: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
widget.category.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: widget.isSelected
? widget.category.color
: Theme.of(context).colorScheme.onSurface,
fontWeight: widget.isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
if (widget.isSelected)
Positioned(
top: 8,
right: 8,
child: Icon(
Icons.check_circle,
color: widget.category.color,
),
),
],
),
),
),
),
);
}
}
Image Preview with Zoom Animation
Create an image preview with scale-based zoom:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedImagePreview extends StatefulWidget {
const LocalizedImagePreview({super.key});
@override
State<LocalizedImagePreview> createState() => _LocalizedImagePreviewState();
}
class _LocalizedImagePreviewState extends State<LocalizedImagePreview> {
bool _isZoomed = false;
int _selectedIndex = 0;
final List<String> _imageUrls = [
'https://picsum.photos/400/300?random=1',
'https://picsum.photos/400/300?random=2',
'https://picsum.photos/400/300?random=3',
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.imageGalleryTitle),
actions: [
IconButton(
icon: Icon(_isZoomed ? Icons.zoom_out : Icons.zoom_in),
tooltip: _isZoomed ? l10n.zoomOutTooltip : l10n.zoomInTooltip,
onPressed: () => setState(() => _isZoomed = !_isZoomed),
),
],
),
body: Column(
children: [
Expanded(
child: GestureDetector(
onDoubleTap: () => setState(() => _isZoomed = !_isZoomed),
child: Semantics(
image: true,
label: l10n.imageAccessibility(
_selectedIndex + 1,
_imageUrls.length,
_isZoomed ? l10n.zoomed : l10n.normal,
),
child: Center(
child: AnimatedScale(
scale: _isZoomed ? 1.5 : 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
_imageUrls[_selectedIndex],
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width * 0.8,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: MediaQuery.of(context).size.width * 0.8,
height: 200,
color: Theme.of(context).colorScheme.surfaceVariant,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(l10n.loadingImage),
],
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
width: MediaQuery.of(context).size.width * 0.8,
height: 200,
color: Theme.of(context).colorScheme.errorContainer,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 8),
Text(l10n.imageLoadError),
],
),
),
);
},
),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.doubleTapToZoom,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
),
// Thumbnail strip
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _imageUrls.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedIndex;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => setState(() {
_selectedIndex = index;
_isZoomed = false;
}),
child: Semantics(
button: true,
selected: isSelected,
label: l10n.selectImage(index + 1),
child: AnimatedScale(
scale: isSelected ? 1.1 : 1.0,
duration: const Duration(milliseconds: 200),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 3,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
_imageUrls[index],
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
),
),
),
);
},
),
),
const SizedBox(height: 16),
],
),
);
}
}
Notification Badge with Pulse Animation
Create a notification badge with attention-grabbing scale animation:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedNotificationBadge extends StatefulWidget {
final int count;
final Widget child;
final String itemLabel;
const LocalizedNotificationBadge({
super.key,
required this.count,
required this.child,
required this.itemLabel,
});
@override
State<LocalizedNotificationBadge> createState() => _LocalizedNotificationBadgeState();
}
class _LocalizedNotificationBadgeState extends State<LocalizedNotificationBadge>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
int _previousCount = 0;
@override
void initState() {
super.initState();
_previousCount = widget.count;
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void didUpdateWidget(LocalizedNotificationBadge oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count > _previousCount) {
_pulseController.forward().then((_) => _pulseController.reverse());
}
_previousCount = widget.count;
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: widget.count > 0
? l10n.notificationBadgeAccessibility(widget.itemLabel, widget.count)
: widget.itemLabel,
child: Stack(
clipBehavior: Clip.none,
children: [
widget.child,
if (widget.count > 0)
Positioned(
top: -4,
right: -4,
child: AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return AnimatedScale(
scale: 1.0 + (_pulseController.value * 0.3),
duration: Duration.zero,
child: child,
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 20),
child: Text(
widget.count > 99 ? '99+' : widget.count.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
fontSize: 12,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
),
],
),
);
}
}
class NotificationBadgeDemo extends StatefulWidget {
const NotificationBadgeDemo({super.key});
@override
State<NotificationBadgeDemo> createState() => _NotificationBadgeDemoState();
}
class _NotificationBadgeDemoState extends State<NotificationBadgeDemo> {
int _messageCount = 3;
int _notificationCount = 0;
int _cartCount = 1;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.notificationDemoTitle),
actions: [
LocalizedNotificationBadge(
count: _notificationCount,
itemLabel: l10n.notificationsLabel,
child: IconButton(
icon: const Icon(Icons.notifications),
onPressed: () => setState(() => _notificationCount = 0),
tooltip: l10n.notificationsLabel,
),
),
LocalizedNotificationBadge(
count: _cartCount,
itemLabel: l10n.cartLabel,
child: IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {},
tooltip: l10n.cartLabel,
),
),
],
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LocalizedNotificationBadge(
count: _messageCount,
itemLabel: l10n.messagesLabel,
child: FloatingActionButton(
heroTag: 'messages',
onPressed: () {},
child: const Icon(Icons.message),
),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => setState(() => _messageCount++),
child: Text(l10n.addMessage),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => setState(() => _notificationCount++),
child: Text(l10n.addNotification),
),
],
),
const SizedBox(height: 16),
TextButton(
onPressed: () => setState(() {
_messageCount = 0;
_notificationCount = 0;
_cartCount = 0;
}),
child: Text(l10n.clearAll),
),
],
),
),
);
}
}
Floating Action Button with Scale Effect
Create a FAB menu with scale animations:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedScalableFAB extends StatefulWidget {
const LocalizedScalableFAB({super.key});
@override
State<LocalizedScalableFAB> createState() => _LocalizedScalableFABState();
}
class _LocalizedScalableFABState extends State<LocalizedScalableFAB> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
final actions = [
_FABAction(Icons.camera_alt, l10n.takePhoto, Colors.blue),
_FABAction(Icons.photo_library, l10n.chooseFromGallery, Colors.green),
_FABAction(Icons.insert_drive_file, l10n.attachFile, Colors.orange),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.fabMenuTitle)),
body: Center(
child: Text(l10n.fabMenuDescription),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: isRtl ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
...actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final delay = (actions.length - 1 - index) * 50;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: AnimatedScale(
scale: _isExpanded ? 1.0 : 0.0,
duration: Duration(milliseconds: 200 + delay),
curve: Curves.easeOutBack,
child: AnimatedOpacity(
opacity: _isExpanded ? 1.0 : 0.0,
duration: Duration(milliseconds: 150 + delay),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isRtl)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Text(action.label),
),
if (!isRtl) const SizedBox(width: 8),
FloatingActionButton.small(
heroTag: 'fab_$index',
backgroundColor: action.color,
onPressed: () {
setState(() => _isExpanded = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(action.label)),
);
},
tooltip: action.label,
child: Icon(action.icon, color: Colors.white),
),
if (isRtl) const SizedBox(width: 8),
if (isRtl)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Text(action.label),
),
],
),
),
),
);
}),
FloatingActionButton(
onPressed: () => setState(() => _isExpanded = !_isExpanded),
tooltip: _isExpanded ? l10n.closeMenu : l10n.openMenu,
child: AnimatedRotation(
turns: _isExpanded ? 0.125 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.add),
),
),
],
),
);
}
}
class _FABAction {
final IconData icon;
final String label;
final Color color;
_FABAction(this.icon, this.label, this.color);
}
Complete ARB File for AnimatedScale
{
"@@locale": "en",
"buttonDemoTitle": "Button Demo",
"submitButton": "Submit",
"cancelButton": "Cancel",
"formSubmitted": "Form submitted successfully",
"selectInterestsTitle": "Select Your Interests",
"selectInterestsDescription": "Choose the categories that interest you most. Tap to select or deselect.",
"categoryTechnology": "Technology",
"categoryTravel": "Travel",
"categoryFood": "Food",
"categorySports": "Sports",
"categoryMusic": "Music",
"categoryArt": "Art",
"selected": "selected",
"notSelected": "not selected",
"categoryAccessibility": "{name}, {state}",
"@categoryAccessibility": {
"placeholders": {
"name": {"type": "String"},
"state": {"type": "String"}
}
},
"selectedCount": "{count} selected",
"@selectedCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"doneButton": "Done",
"interestsSelected": "Saved {count} interests",
"@interestsSelected": {
"placeholders": {
"count": {"type": "int"}
}
},
"imageGalleryTitle": "Image Gallery",
"zoomInTooltip": "Zoom in",
"zoomOutTooltip": "Zoom out",
"zoomed": "zoomed in",
"normal": "normal view",
"imageAccessibility": "Image {current} of {total}, {state}",
"@imageAccessibility": {
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"},
"state": {"type": "String"}
}
},
"loadingImage": "Loading image...",
"imageLoadError": "Failed to load image",
"doubleTapToZoom": "Double-tap to zoom",
"selectImage": "Select image {number}",
"@selectImage": {
"placeholders": {
"number": {"type": "int"}
}
},
"notificationDemoTitle": "Notifications",
"notificationsLabel": "Notifications",
"cartLabel": "Shopping cart",
"messagesLabel": "Messages",
"notificationBadgeAccessibility": "{item} with {count} new items",
"@notificationBadgeAccessibility": {
"placeholders": {
"item": {"type": "String"},
"count": {"type": "int"}
}
},
"addMessage": "Add Message",
"addNotification": "Add Notification",
"clearAll": "Clear All",
"fabMenuTitle": "FAB Menu",
"fabMenuDescription": "Tap the + button to see actions",
"takePhoto": "Take Photo",
"chooseFromGallery": "Choose from Gallery",
"attachFile": "Attach File",
"openMenu": "Open menu",
"closeMenu": "Close menu"
}
Best Practices Summary
- Use subtle scale values: 0.95-1.05 for interactive feedback, larger for emphasis
- Provide press feedback: Scale down on tap to indicate interaction
- Handle accessibility: Announce scale changes for meaningful state transitions
- Combine with other effects: Mix scale with opacity and shadow changes
- Choose appropriate durations: 100-150ms for press feedback, 200-300ms for emphasis
- Test RTL layouts: Ensure scale animations position correctly in both directions
- Use easeOutBack for bounce: Creates satisfying spring effect on scale up
- Avoid excessive scaling: Keep within 0.8-1.2 range for most UI elements
- Consider reduced motion: Respect user preferences for reduced animations
- Maintain touch targets: Ensure scaled-down elements remain tappable
Conclusion
AnimatedScale is a versatile widget for creating smooth scaling animations in multilingual Flutter apps. By providing appropriate accessibility announcements, using subtle scale values for feedback, and combining with other visual effects, you create intuitive experiences for users worldwide. The patterns shown here—interactive buttons, selectable cards, image zoom, and notification badges—can be adapted for any application requiring animated scaling transitions.
Remember to test your scale animations with various screen sizes and accessibility settings to ensure they remain effective and accessible for all users.