Flutter AspectRatio Localization: Maintaining Proportions Across Languages
AspectRatio is a Flutter widget that sizes its child to a specific aspect ratio. In multilingual applications, maintaining proper proportions while accommodating varying content lengths is essential for creating visually consistent experiences across all locales.
Understanding AspectRatio in Localization Context
AspectRatio attempts to size its child to a specific width-to-height ratio. For multilingual apps, this creates interesting challenges:
- Text content may overflow in verbose languages
- Image-text combinations need careful proportion management
- RTL layouts must maintain visual balance
- Different scripts have varying visual densities
Why AspectRatio Matters for Multilingual Apps
Proper aspect ratio management ensures:
- Visual consistency: Cards and containers look balanced across languages
- Media integrity: Images and videos maintain correct proportions
- Responsive design: Layouts adapt without breaking
- Brand consistency: Design system proportions are preserved
Basic AspectRatio Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedAspectRatioExample extends StatelessWidget {
const LocalizedAspectRatioExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.videoPlaceholder,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
),
);
}
}
Localized Media Cards
Video Card with Localized Overlay
class LocalizedVideoCard extends StatelessWidget {
final String thumbnailUrl;
final String title;
final String duration;
final VoidCallback onTap;
const LocalizedVideoCard({
super.key,
required this.thumbnailUrl,
required this.title,
required this.duration,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
thumbnailUrl,
fit: BoxFit.cover,
),
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
duration,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 32,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
RTL-Aware Media Card
class RtlAwareMediaCard extends StatelessWidget {
final String imageUrl;
final String title;
final String subtitle;
final String badge;
const RtlAwareMediaCard({
super.key,
required this.imageUrl,
required this.title,
required this.subtitle,
required this.badge,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 4 / 3,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
PositionedDirectional(
top: 8,
start: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
badge,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
start: 12,
end: 12,
top: 12,
bottom: 8,
),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
start: 12,
end: 12,
bottom: 12,
),
child: Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
Adaptive Aspect Ratios
Language-Aware Aspect Ratio
class AdaptiveAspectRatio extends StatelessWidget {
final Widget child;
final double baseAspectRatio;
const AdaptiveAspectRatio({
super.key,
required this.child,
this.baseAspectRatio = 16 / 9,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final adjustedRatio = _getAdjustedRatio(locale);
return AspectRatio(
aspectRatio: adjustedRatio,
child: child,
);
}
double _getAdjustedRatio(Locale locale) {
// Adjust for languages with longer text
switch (locale.languageCode) {
case 'de': // German - typically 30% longer
case 'ru': // Russian
case 'fi': // Finnish
return baseAspectRatio * 0.9; // Slightly taller
case 'ja': // Japanese - more compact
case 'zh': // Chinese
case 'ko': // Korean
return baseAspectRatio * 1.05; // Slightly wider
default:
return baseAspectRatio;
}
}
}
Content-Aware Aspect Ratio
class ContentAwareAspectRatio extends StatelessWidget {
final String text;
final Widget child;
const ContentAwareAspectRatio({
super.key,
required this.text,
required this.child,
});
@override
Widget build(BuildContext context) {
// Calculate aspect ratio based on text length
final textLength = text.length;
double aspectRatio;
if (textLength < 50) {
aspectRatio = 16 / 9;
} else if (textLength < 100) {
aspectRatio = 4 / 3;
} else {
aspectRatio = 1.0; // Square for longer content
}
return AspectRatio(
aspectRatio: aspectRatio,
child: child,
);
}
}
Grid Layouts with Aspect Ratio
Localized Photo Grid
class LocalizedPhotoGrid extends StatelessWidget {
const LocalizedPhotoGrid({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(
start: 16,
end: 16,
bottom: 12,
),
child: Text(
l10n.photoGalleryTitle,
style: Theme.of(context).textTheme.titleLarge,
),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.0, // Square photos
),
itemCount: 4,
itemBuilder: (context, index) {
return _PhotoTile(
imageUrl: 'https://picsum.photos/400?random=$index',
label: l10n.photoLabel(index + 1),
);
},
),
],
);
}
}
class _PhotoTile extends StatelessWidget {
final String imageUrl;
final String label;
const _PhotoTile({
required this.imageUrl,
required this.label,
});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
}
Product Display with Aspect Ratio
Localized Product Card
class LocalizedProductCard extends StatelessWidget {
final String imageUrl;
final String name;
final String price;
final String? discount;
const LocalizedProductCard({
super.key,
required this.imageUrl,
required this.name,
required this.price,
this.discount,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1.0, // Square product image
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
if (discount != null)
PositionedDirectional(
top: 8,
start: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
discount!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
PositionedDirectional(
top: 8,
end: 8,
child: IconButton(
onPressed: () {},
icon: const Icon(Icons.favorite_border),
style: IconButton.styleFrom(
backgroundColor: Colors.white,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
price,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"videoPlaceholder": "Video content will appear here",
"@videoPlaceholder": {
"description": "Placeholder text for video container"
},
"photoGalleryTitle": "Photo Gallery",
"@photoGalleryTitle": {
"description": "Title for photo gallery section"
},
"photoLabel": "Photo {number}",
"@photoLabel": {
"description": "Label for photo in gallery",
"placeholders": {
"number": {
"type": "int",
"example": "1"
}
}
},
"productAddToCart": "Add to Cart",
"@productAddToCart": {
"description": "Add to cart button text"
},
"discountBadge": "{percent}% OFF",
"@discountBadge": {
"description": "Discount badge text",
"placeholders": {
"percent": {
"type": "int",
"example": "20"
}
}
}
}
German (app_de.arb)
{
"@@locale": "de",
"videoPlaceholder": "Videoinhalt wird hier angezeigt",
"photoGalleryTitle": "Fotogalerie",
"photoLabel": "Foto {number}",
"productAddToCart": "In den Warenkorb",
"discountBadge": "{percent}% RABATT"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"videoPlaceholder": "سيظهر محتوى الفيديو هنا",
"photoGalleryTitle": "معرض الصور",
"photoLabel": "صورة {number}",
"productAddToCart": "أضف إلى السلة",
"discountBadge": "خصم {percent}%"
}
Best Practices Summary
Do's
- Use AspectRatio for media containers to maintain proportions
- Test with RTL layouts to ensure proper positioning
- Use PositionedDirectional for overlays in RTL-aware layouts
- Consider text overflow in verbose languages
- Maintain consistent ratios across the app
Don'ts
- Don't hardcode pixel dimensions when ratios work better
- Don't ignore text overflow in aspect ratio containers
- Don't use absolute positioning without RTL consideration
- Don't assume content fits without testing translations
Accessibility Considerations
class AccessibleAspectRatioCard extends StatelessWidget {
final String imageUrl;
final String title;
final String description;
const AccessibleAspectRatioCard({
super.key,
required this.imageUrl,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$title. $description',
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
imageUrl,
fit: BoxFit.cover,
semanticLabel: title,
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
description,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
);
}
}
Conclusion
AspectRatio is essential for maintaining visual consistency in multilingual Flutter applications. By combining it with RTL-aware positioning, language-adaptive adjustments, and proper overflow handling, you can create layouts that look polished in any locale. Always test your aspect ratio containers with actual translations to ensure content displays correctly across all supported languages.