Flutter FittedBox Localization: Scaling Content for Multilingual Apps
FittedBox is a Flutter widget that scales and positions its child within itself according to a fit parameter. In multilingual applications, FittedBox helps handle varying text lengths by automatically scaling content to fit available space.
Understanding FittedBox in Localization Context
FittedBox scales its child to fit within the available constraints. For multilingual apps, this creates valuable capabilities:
- Long translations automatically scale to fit
- Icons and text maintain proportional relationships
- RTL content scales correctly
- Different scripts display at appropriate sizes
Why FittedBox Matters for Multilingual Apps
Automatic scaling ensures:
- Text accommodation: Long translations don't overflow
- Visual consistency: Elements maintain proportions across languages
- Adaptive layouts: Content scales to available space
- No truncation: Full text remains visible, just smaller
Basic FittedBox Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedFittedBoxExample extends StatelessWidget {
const LocalizedFittedBoxExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
width: 200,
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
l10n.buttonLabel,
style: Theme.of(context).textTheme.titleMedium,
),
),
);
}
}
Button Text Scaling
Adaptive Button Component
class LocalizedScalingButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
final IconData? icon;
final double maxWidth;
const LocalizedScalingButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.maxWidth = 200,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: maxWidth,
height: 48,
child: FilledButton(
onPressed: onPressed,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 20),
const SizedBox(width: 8),
],
Text(label),
],
),
),
),
);
}
}
// Usage
class ButtonExample extends StatelessWidget {
const ButtonExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedScalingButton(
label: l10n.submitButton,
icon: Icons.check,
onPressed: () {},
),
const SizedBox(height: 16),
LocalizedScalingButton(
label: l10n.cancelSubscriptionButton, // Longer text
icon: Icons.cancel,
onPressed: () {},
),
],
);
}
}
Button Row with Scaling
class LocalizedButtonRow extends StatelessWidget {
const LocalizedButtonRow({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
onPressed: () {},
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(l10n.cancelButton),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 48,
child: FilledButton(
onPressed: () {},
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(l10n.confirmButton),
),
),
),
),
],
);
}
}
Header and Title Scaling
Scaling Page Title
class LocalizedScalingTitle extends StatelessWidget {
final String title;
final TextStyle? style;
const LocalizedScalingTitle({
super.key,
required this.title,
this.style,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 60,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
title,
style: style ?? Theme.of(context).textTheme.headlineLarge,
),
),
);
}
}
// Page with scaling title
class LocalizedPage extends StatelessWidget {
const LocalizedPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocalizedScalingTitle(
title: l10n.pageTitle,
),
const SizedBox(height: 16),
Text(
l10n.pageDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
);
}
}
AppBar Title Scaling
class ScalingAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
const ScalingAppBar({
super.key,
required this.title,
this.actions,
});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
return AppBar(
title: FittedBox(
fit: BoxFit.scaleDown,
child: Text(title),
),
actions: actions,
);
}
}
Card and Tile Scaling
Product Card with Scaling Labels
class LocalizedProductCard extends StatelessWidget {
final String imageUrl;
final String name;
final String price;
final String? badge;
final VoidCallback onTap;
const LocalizedProductCard({
super.key,
required this.imageUrl,
required this.name,
required this.price,
this.badge,
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: 1,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
),
if (badge != null)
PositionedDirectional(
top: 8,
start: 8,
child: Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
badge!,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 40,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: AlignmentDirectional.centerStart,
child: Text(
name,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 4),
Text(
price,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
);
}
}
Tab Labels with Scaling
Scaling Tab Bar
class LocalizedScalingTabBar extends StatelessWidget {
final List<String> labels;
final TabController controller;
const LocalizedScalingTabBar({
super.key,
required this.labels,
required this.controller,
});
@override
Widget build(BuildContext context) {
return TabBar(
controller: controller,
tabs: labels.map((label) {
return Tab(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(label),
),
);
}).toList(),
);
}
}
// Usage
class TabExample extends StatefulWidget {
const TabExample({super.key});
@override
State<TabExample> createState() => _TabExampleState();
}
class _TabExampleState extends State<TabExample> with SingleTickerProviderStateMixin {
late TabController _controller;
@override
void initState() {
super.initState();
_controller = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
LocalizedScalingTabBar(
controller: _controller,
labels: [
l10n.tabOverview,
l10n.tabDetails,
l10n.tabReviews,
l10n.tabRelated,
],
),
Expanded(
child: TabBarView(
controller: _controller,
children: const [
Center(child: Text('Overview')),
Center(child: Text('Details')),
Center(child: Text('Reviews')),
Center(child: Text('Related')),
],
),
),
],
);
}
}
Navigation Items
Scaling Bottom Navigation
class LocalizedBottomNavigation extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const LocalizedBottomNavigation({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
type: BottomNavigationBarType.fixed,
items: [
_buildNavItem(Icons.home, l10n.navHome),
_buildNavItem(Icons.search, l10n.navSearch),
_buildNavItem(Icons.favorite, l10n.navFavorites),
_buildNavItem(Icons.person, l10n.navProfile),
],
);
}
BottomNavigationBarItem _buildNavItem(IconData icon, String label) {
return BottomNavigationBarItem(
icon: Icon(icon),
label: label,
// Note: For very long labels, consider using a custom bottom nav
);
}
}
// Custom bottom nav with FittedBox for labels
class CustomScalingBottomNav extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const CustomScalingBottomNav({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final items = [
_NavItem(Icons.home, l10n.navHome),
_NavItem(Icons.search, l10n.navSearch),
_NavItem(Icons.favorite, l10n.navFavorites),
_NavItem(Icons.person, l10n.navProfile),
];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = index == currentIndex;
return Expanded(
child: InkWell(
onTap: () => onTap(index),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 4),
SizedBox(
width: 70,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item.label,
style: TextStyle(
fontSize: 12,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
),
),
),
],
),
),
);
}),
),
),
),
);
}
}
class _NavItem {
final IconData icon;
final String label;
_NavItem(this.icon, this.label);
}
Statistics and Metrics Display
Scaling Stat Cards
class LocalizedStatCard extends StatelessWidget {
final String value;
final String label;
final IconData icon;
final Color? color;
const LocalizedStatCard({
super.key,
required this.value,
required this.label,
required this.icon,
this.color,
});
@override
Widget build(BuildContext context) {
final cardColor = color ?? Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 32,
color: cardColor,
),
const SizedBox(height: 8),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: cardColor,
),
),
),
const SizedBox(height: 4),
SizedBox(
height: 36,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
);
}
}
// Usage
class StatsGrid extends StatelessWidget {
const StatsGrid({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 1.2,
padding: const EdgeInsets.all(16),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children: [
LocalizedStatCard(
value: '1,234',
label: l10n.statUsers,
icon: Icons.people,
),
LocalizedStatCard(
value: '56.7K',
label: l10n.statDownloads,
icon: Icons.download,
color: Colors.green,
),
LocalizedStatCard(
value: '98.5%',
label: l10n.statSatisfaction,
icon: Icons.thumb_up,
color: Colors.orange,
),
LocalizedStatCard(
value: '4.8',
label: l10n.statRating,
icon: Icons.star,
color: Colors.amber,
),
],
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"buttonLabel": "Submit",
"@buttonLabel": {
"description": "Generic submit button"
},
"submitButton": "Submit",
"cancelSubscriptionButton": "Cancel Subscription",
"cancelButton": "Cancel",
"confirmButton": "Confirm",
"pageTitle": "Dashboard Overview",
"@pageTitle": {
"description": "Dashboard page title"
},
"pageDescription": "View your analytics and performance metrics here.",
"tabOverview": "Overview",
"tabDetails": "Details",
"tabReviews": "Reviews",
"tabRelated": "Related",
"navHome": "Home",
"navSearch": "Search",
"navFavorites": "Favorites",
"navProfile": "Profile",
"statUsers": "Active Users",
"statDownloads": "Downloads",
"statSatisfaction": "Satisfaction",
"statRating": "Rating"
}
German (app_de.arb)
{
"@@locale": "de",
"buttonLabel": "Absenden",
"submitButton": "Absenden",
"cancelSubscriptionButton": "Abonnement kündigen",
"cancelButton": "Abbrechen",
"confirmButton": "Bestätigen",
"pageTitle": "Dashboard-Übersicht",
"pageDescription": "Sehen Sie hier Ihre Analysen und Leistungskennzahlen.",
"tabOverview": "Übersicht",
"tabDetails": "Details",
"tabReviews": "Bewertungen",
"tabRelated": "Verwandt",
"navHome": "Startseite",
"navSearch": "Suche",
"navFavorites": "Favoriten",
"navProfile": "Profil",
"statUsers": "Aktive Benutzer",
"statDownloads": "Downloads",
"statSatisfaction": "Zufriedenheit",
"statRating": "Bewertung"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"buttonLabel": "إرسال",
"submitButton": "إرسال",
"cancelSubscriptionButton": "إلغاء الاشتراك",
"cancelButton": "إلغاء",
"confirmButton": "تأكيد",
"pageTitle": "نظرة عامة على لوحة التحكم",
"pageDescription": "اعرض تحليلاتك ومقاييس الأداء هنا.",
"tabOverview": "نظرة عامة",
"tabDetails": "التفاصيل",
"tabReviews": "المراجعات",
"tabRelated": "ذات صلة",
"navHome": "الرئيسية",
"navSearch": "بحث",
"navFavorites": "المفضلة",
"navProfile": "الملف الشخصي",
"statUsers": "المستخدمون النشطون",
"statDownloads": "التنزيلات",
"statSatisfaction": "الرضا",
"statRating": "التقييم"
}
Best Practices Summary
Do's
- Use FittedBox with scaleDown for text that might overflow
- Set alignment to maintain text positioning during scaling
- Provide height constraints to control minimum text size
- Test with verbose languages like German and Russian
- Combine with RTL support using AlignmentDirectional
Don'ts
- Don't use FittedBox for body text - it may become unreadable
- Don't scale down excessively - ensure minimum readability
- Don't forget to test edge cases with longest translations
- Don't rely solely on FittedBox - consider layout alternatives
Accessibility Considerations
class AccessibleScalingText extends StatelessWidget {
final String text;
final TextStyle? style;
const AccessibleScalingText({
super.key,
required this.text,
this.style,
});
@override
Widget build(BuildContext context) {
// Ensure minimum readable font size
final minFontSize = 12.0;
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: minFontSize * textScaleFactor * 1.5,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
text,
style: style,
),
),
);
}
}
Conclusion
FittedBox is a powerful tool for handling variable text lengths in multilingual Flutter applications. By scaling content to fit available space, it prevents overflow while maintaining readability. Use it strategically for titles, buttons, and labels where text length varies significantly across languages. Always test with your longest translations to ensure content remains readable at minimum scales.