Flutter RepaintBoundary Localization: Performance Optimization for Multilingual Apps
RepaintBoundary isolates portions of the widget tree for independent rendering, enabling performance optimization by preventing unnecessary repaints. When combined with localization, RepaintBoundary helps manage complex multilingual UIs, language-switching animations, and dynamic content updates efficiently. This guide covers comprehensive strategies for using RepaintBoundary in Flutter multilingual applications.
Understanding RepaintBoundary in Localization
RepaintBoundary widgets benefit localization for:
- Language switch performance: Isolating static content during locale changes
- Dynamic translations: Containing repaints to translated sections
- Complex layouts: Optimizing RTL/LTR layout recalculations
- Animated content: Separating animated elements from static text
- Screenshot capture: Capturing localized content for sharing
- Memory optimization: Managing resources in translation-heavy apps
Basic RepaintBoundary with Localized Content
Start with isolating frequently updating content:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedRepaintBoundaryDemo extends StatefulWidget {
const LocalizedRepaintBoundaryDemo({super.key});
@override
State<LocalizedRepaintBoundaryDemo> createState() =>
_LocalizedRepaintBoundaryDemoState();
}
class _LocalizedRepaintBoundaryDemoState
extends State<LocalizedRepaintBoundaryDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
int _counter = 0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.repaintBoundaryTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Static localized content - isolated
RepaintBoundary(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.staticSectionTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(l10n.staticSectionDescription),
],
),
),
),
),
const SizedBox(height: 16),
// Animated content - isolated from static
RepaintBoundary(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
l10n.animatedSectionTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 6.28,
child: child,
);
},
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimary,
size: 32,
),
),
),
],
),
),
),
),
const SizedBox(height: 16),
// Counter section - updates independently
RepaintBoundary(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.counterLabel(_counter),
style: Theme.of(context).textTheme.titleMedium,
),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: Text(l10n.incrementButton),
),
],
),
),
),
),
],
),
),
);
}
}
ARB File Structure for RepaintBoundary
{
"repaintBoundaryTitle": "Performance Demo",
"@repaintBoundaryTitle": {
"description": "Title for repaint boundary demo page"
},
"staticSectionTitle": "Static Content",
"staticSectionDescription": "This section is isolated and won't repaint when other sections update.",
"animatedSectionTitle": "Animated Section",
"counterLabel": "Counter: {count}",
"@counterLabel": {
"placeholders": {
"count": {"type": "int"}
}
},
"incrementButton": "Increment"
}
Language Switcher with Performance Optimization
Optimize language switching with RepaintBoundary:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedLanguageSwitcher extends StatelessWidget {
final ValueChanged<Locale> onLocaleChanged;
final Locale currentLocale;
const LocalizedLanguageSwitcher({
super.key,
required this.onLocaleChanged,
required this.currentLocale,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locales = [
(const Locale('en'), l10n.languageEnglish, 'πΊπΈ'),
(const Locale('ar'), l10n.languageArabic, 'πΈπ¦'),
(const Locale('zh'), l10n.languageChinese, 'π¨π³'),
(const Locale('es'), l10n.languageSpanish, 'πͺπΈ'),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.languageSettingsTitle)),
body: Column(
children: [
// Language options - will repaint on selection
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: locales.length,
itemBuilder: (context, index) {
final (locale, name, flag) = locales[index];
final isSelected = currentLocale == locale;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Text(flag, style: const TextStyle(fontSize: 24)),
title: Text(name),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
selected: isSelected,
onTap: () => onLocaleChanged(locale),
),
);
},
),
),
// Preview section - isolated to prevent repaint during switching
RepaintBoundary(
child: Container(
padding: const EdgeInsets.all(24),
color: Theme.of(context).colorScheme.surfaceVariant,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.previewTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(l10n.previewSampleText),
const SizedBox(height: 8),
Text(
l10n.previewDirectionLabel(
Directionality.of(context) == TextDirection.rtl
? l10n.directionRtl
: l10n.directionLtr,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
);
}
}
Complex Dashboard with Isolated Sections
Create a dashboard with optimized sections:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedDashboard extends StatefulWidget {
const LocalizedDashboard({super.key});
@override
State<LocalizedDashboard> createState() => _LocalizedDashboardState();
}
class _LocalizedDashboardState extends State<LocalizedDashboard> {
final Map<String, int> _stats = {
'users': 1234,
'orders': 567,
'revenue': 89012,
'products': 345,
};
void _refreshStat(String key) {
setState(() {
_stats[key] = _stats[key]! + (DateTime.now().millisecond % 100);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
appBar: AppBar(title: Text(l10n.dashboardTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header section - static, isolated
RepaintBoundary(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.dashboardWelcome,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 4),
Text(
l10n.dashboardLastUpdated(DateTime.now().toString()),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 24),
// Stats grid - each stat isolated
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
children: [
_buildStatCard(
context,
l10n,
'users',
l10n.statUsers,
Icons.people,
Colors.blue,
),
_buildStatCard(
context,
l10n,
'orders',
l10n.statOrders,
Icons.shopping_cart,
Colors.green,
),
_buildStatCard(
context,
l10n,
'revenue',
l10n.statRevenue,
Icons.attach_money,
Colors.orange,
),
_buildStatCard(
context,
l10n,
'products',
l10n.statProducts,
Icons.inventory,
Colors.purple,
),
],
),
const SizedBox(height: 24),
// Chart section - isolated heavy rendering
RepaintBoundary(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.chartSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: Center(
child: Text(l10n.chartPlaceholder),
),
),
],
),
),
),
),
],
),
),
);
}
Widget _buildStatCard(
BuildContext context,
AppLocalizations l10n,
String key,
String title,
IconData icon,
Color color,
) {
// Each stat card is isolated for independent updates
return RepaintBoundary(
child: Card(
child: InkWell(
onTap: () => _refreshStat(key),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, color: color),
Icon(
Icons.refresh,
size: 16,
color: Theme.of(context).colorScheme.outline,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatNumber(_stats[key]!),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
),
);
}
String _formatNumber(int number) {
if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}K';
}
return number.toString();
}
}
Screenshot Capture with Localized Content
Capture localized widgets as images:
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedScreenshotCapture extends StatefulWidget {
const LocalizedScreenshotCapture({super.key});
@override
State<LocalizedScreenshotCapture> createState() =>
_LocalizedScreenshotCaptureState();
}
class _LocalizedScreenshotCaptureState
extends State<LocalizedScreenshotCapture> {
final GlobalKey _captureKey = GlobalKey();
Uint8List? _capturedImage;
bool _isCapturing = false;
Future<void> _captureScreenshot() async {
setState(() => _isCapturing = true);
try {
final boundary = _captureKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary != null) {
final image = await boundary.toImage(pixelRatio: 2.0);
final byteData = await image.toByteData(
format: ui.ImageByteFormat.png,
);
if (byteData != null) {
setState(() {
_capturedImage = byteData.buffer.asUint8List();
});
}
}
} catch (e) {
debugPrint('Screenshot capture failed: $e');
}
setState(() => _isCapturing = false);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
appBar: AppBar(title: Text(l10n.screenshotDemoTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.captureInstructions,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 24),
// Content to capture - wrapped in RepaintBoundary
RepaintBoundary(
key: _captureKey,
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.card_giftcard,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
l10n.certificateTitle,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.certificateRecipient,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
l10n.certificateDescription,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.certificateDate),
Text(l10n.certificateSignature),
],
),
],
),
),
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _isCapturing ? null : _captureScreenshot,
icon: _isCapturing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.camera_alt),
label: Text(
_isCapturing
? l10n.capturingButton
: l10n.captureButton,
),
),
if (_capturedImage != null) ...[
const SizedBox(height: 24),
Text(
l10n.capturedImageLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
_capturedImage!,
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.share),
label: Text(l10n.shareButton),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.save),
label: Text(l10n.saveButton),
),
),
],
),
],
],
),
),
);
}
}
List Performance with RepaintBoundary
Optimize list rendering with isolated items:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedOptimizedList extends StatefulWidget {
const LocalizedOptimizedList({super.key});
@override
State<LocalizedOptimizedList> createState() => _LocalizedOptimizedListState();
}
class _LocalizedOptimizedListState extends State<LocalizedOptimizedList> {
final Set<int> _selectedItems = {};
final List<Map<String, dynamic>> _items = List.generate(
100,
(i) => {
'id': i,
'title': 'Item ${i + 1}',
'subtitle': 'Description for item ${i + 1}',
},
);
void _toggleSelection(int id) {
setState(() {
if (_selectedItems.contains(id)) {
_selectedItems.remove(id);
} else {
_selectedItems.add(id);
}
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.optimizedListTitle),
actions: [
Center(
child: Padding(
padding: const EdgeInsets.only(right: 16),
child: Text(
l10n.selectedCount(_selectedItems.length),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
),
body: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
final isSelected = _selectedItems.contains(item['id']);
// Each list item is isolated for selection updates
return RepaintBoundary(
child: _ListItem(
title: l10n.listItemTitle(item['id'] + 1),
subtitle: l10n.listItemSubtitle(item['id'] + 1),
isSelected: isSelected,
onTap: () => _toggleSelection(item['id']),
),
);
},
),
floatingActionButton: _selectedItems.isNotEmpty
? FloatingActionButton.extended(
onPressed: () {
setState(() => _selectedItems.clear());
},
icon: const Icon(Icons.clear_all),
label: Text(l10n.clearSelectionButton),
)
: null,
);
}
}
class _ListItem extends StatelessWidget {
final String title;
final String subtitle;
final bool isSelected;
final VoidCallback onTap;
const _ListItem({
required this.title,
required this.subtitle,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: null,
child: ListTile(
leading: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: const Icon(Icons.circle_outlined),
title: Text(title),
subtitle: Text(subtitle),
onTap: onTap,
),
);
}
}
Complete ARB File for RepaintBoundary
{
"@@locale": "en",
"repaintBoundaryTitle": "Performance Demo",
"staticSectionTitle": "Static Content",
"staticSectionDescription": "This section is isolated and won't repaint when other sections update.",
"animatedSectionTitle": "Animated Section",
"counterLabel": "Counter: {count}",
"@counterLabel": {
"placeholders": {"count": {"type": "int"}}
},
"incrementButton": "Increment",
"languageSettingsTitle": "Language Settings",
"languageEnglish": "English",
"languageArabic": "Arabic",
"languageChinese": "Chinese",
"languageSpanish": "Spanish",
"previewTitle": "Preview",
"previewSampleText": "This is how text appears in the selected language.",
"previewDirectionLabel": "Text direction: {direction}",
"@previewDirectionLabel": {
"placeholders": {"direction": {"type": "String"}}
},
"directionLtr": "Left-to-Right",
"directionRtl": "Right-to-Left",
"dashboardTitle": "Dashboard",
"dashboardWelcome": "Welcome Back",
"dashboardLastUpdated": "Last updated: {timestamp}",
"@dashboardLastUpdated": {
"placeholders": {"timestamp": {"type": "String"}}
},
"statUsers": "Users",
"statOrders": "Orders",
"statRevenue": "Revenue",
"statProducts": "Products",
"chartSectionTitle": "Activity Overview",
"chartPlaceholder": "Chart placeholder",
"screenshotDemoTitle": "Screenshot Capture",
"captureInstructions": "The card below can be captured as an image",
"certificateTitle": "Certificate of Completion",
"certificateRecipient": "John Doe",
"certificateDescription": "Has successfully completed the Flutter Localization course with excellence.",
"certificateDate": "January 27, 2026",
"certificateSignature": "Course Director",
"captureButton": "Capture Screenshot",
"capturingButton": "Capturing...",
"capturedImageLabel": "Captured Image",
"shareButton": "Share",
"saveButton": "Save",
"optimizedListTitle": "Optimized List",
"selectedCount": "{count} selected",
"@selectedCount": {
"placeholders": {"count": {"type": "int"}}
},
"listItemTitle": "Item {number}",
"@listItemTitle": {
"placeholders": {"number": {"type": "int"}}
},
"listItemSubtitle": "Description for item {number}",
"@listItemSubtitle": {
"placeholders": {"number": {"type": "int"}}
},
"clearSelectionButton": "Clear Selection"
}
Best Practices Summary
- Isolate animated content: Wrap animations in RepaintBoundary to prevent repainting static text
- Separate independent sections: Each updateable section should have its own boundary
- Use for complex widgets: Custom painters and heavy widgets benefit from isolation
- Enable screenshot capture: RepaintBoundary is required for toImage() functionality
- Don't overuse: Too many boundaries can actually hurt performance
- Profile before optimizing: Use Flutter DevTools to identify actual repaint issues
- Combine with const widgets: Use const for truly static content
- Consider list items: Long lists with selectable items benefit from item isolation
- Test across devices: Performance benefits vary by device capability
- Document optimization: Comment why RepaintBoundary is used for future maintainers
Conclusion
RepaintBoundary is a powerful tool for optimizing Flutter applications, especially when dealing with multilingual content that may trigger layout recalculations during language switches. By strategically isolating content sections, you can ensure smooth performance during locale changes, efficient list rendering, and enable features like screenshot capture of localized content.
Remember that RepaintBoundary is an optimization technique that should be applied after profiling identifies actual performance issues. Overusing it can lead to increased memory consumption without significant benefits. Use Flutter DevTools to identify repaint issues before adding boundaries.