← Back to Blog

Flutter AspectRatio Localization: Maintaining Proportions Across Languages

flutteraspectratioproportionslayoutlocalizationmedia

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

  1. Use AspectRatio for media containers to maintain proportions
  2. Test with RTL layouts to ensure proper positioning
  3. Use PositionedDirectional for overlays in RTL-aware layouts
  4. Consider text overflow in verbose languages
  5. Maintain consistent ratios across the app

Don'ts

  1. Don't hardcode pixel dimensions when ratios work better
  2. Don't ignore text overflow in aspect ratio containers
  3. Don't use absolute positioning without RTL consideration
  4. 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.

Further Reading