Flutter SizeTransition Localization: Expandable Content and Collapsible Sections for Multilingual Apps
SizeTransition provides explicit control over size-based animations using an Animation controller. Unlike AnimatedSize which responds automatically to child size changes, SizeTransition clips and reveals content along a single axis. This guide covers comprehensive strategies for localizing SizeTransition widgets in Flutter multilingual applications.
Understanding SizeTransition Localization
SizeTransition widgets require localization for:
- Expandable sections: FAQ accordions and collapsible content
- Dropdown menus: Animated menu reveals
- Search results: Expanding result containers
- Form sections: Progressive disclosure of form fields
- Notification panels: Sliding notification drawers
- Bottom sheets: Custom animated bottom panels
Basic SizeTransition with Localized Content
Start with a simple expandable section:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedExpandableSection extends StatefulWidget {
const LocalizedExpandableSection({super.key});
@override
State<LocalizedExpandableSection> createState() => _LocalizedExpandableSectionState();
}
class _LocalizedExpandableSectionState extends State<LocalizedExpandableSection>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _sizeAnimation;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_sizeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleExpansion() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.expandableSectionTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Semantics(
button: true,
expanded: _isExpanded,
label: l10n.sectionHeaderAccessibility(
l10n.productDetailsHeader,
_isExpanded ? l10n.expanded : l10n.collapsed,
),
child: InkWell(
onTap: _toggleExpansion,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.productDetailsHeader,
style: Theme.of(context).textTheme.titleMedium,
),
),
RotationTransition(
turns: Tween(begin: 0.0, end: 0.5).animate(_sizeAnimation),
child: const Icon(Icons.expand_more),
),
],
),
),
),
),
SizeTransition(
sizeFactor: _sizeAnimation,
axisAlignment: -1.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.productDescription,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_DetailRow(
label: l10n.productSku,
value: 'PRD-2024-001',
),
_DetailRow(
label: l10n.productCategory,
value: l10n.categoryElectronics,
),
_DetailRow(
label: l10n.productWeight,
value: l10n.weightValue(1.5),
),
_DetailRow(
label: l10n.productDimensions,
value: l10n.dimensionsValue(15, 10, 5),
),
],
),
),
],
),
),
],
),
),
),
);
}
}
class _DetailRow extends StatelessWidget {
final String label;
final String value;
const _DetailRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
}
ARB File Structure for SizeTransition
{
"expandableSectionTitle": "Product Details",
"@expandableSectionTitle": {
"description": "Title for expandable section demo"
},
"productDetailsHeader": "Product Details",
"@productDetailsHeader": {
"description": "Header for product details section"
},
"sectionHeaderAccessibility": "{title}, {state}",
"@sectionHeaderAccessibility": {
"description": "Accessibility label for expandable section header",
"placeholders": {
"title": {"type": "String"},
"state": {"type": "String"}
}
},
"expanded": "expanded",
"collapsed": "collapsed",
"productDescription": "High-quality wireless headphones with active noise cancellation, 30-hour battery life, and premium comfort design.",
"productSku": "SKU:",
"productCategory": "Category:",
"categoryElectronics": "Electronics",
"productWeight": "Weight:",
"weightValue": "{weight} kg",
"@weightValue": {
"placeholders": {
"weight": {"type": "double", "format": "decimalPattern"}
}
},
"productDimensions": "Dimensions:",
"dimensionsValue": "{length} x {width} x {height} cm",
"@dimensionsValue": {
"placeholders": {
"length": {"type": "int"},
"width": {"type": "int"},
"height": {"type": "int"}
}
}
}
FAQ Accordion with Multiple Sections
Create an FAQ list with expandable answers:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedFaqAccordion extends StatefulWidget {
const LocalizedFaqAccordion({super.key});
@override
State<LocalizedFaqAccordion> createState() => _LocalizedFaqAccordionState();
}
class _LocalizedFaqAccordionState extends State<LocalizedFaqAccordion>
with TickerProviderStateMixin {
late List<AnimationController> _controllers;
late List<Animation<double>> _animations;
late List<bool> _expandedStates;
int _faqCount = 5;
@override
void initState() {
super.initState();
_initializeControllers();
}
void _initializeControllers() {
_controllers = List.generate(
_faqCount,
(index) => AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
),
);
_animations = _controllers.map((controller) {
return CurvedAnimation(parent: controller, curve: Curves.easeInOut);
}).toList();
_expandedStates = List.filled(_faqCount, false);
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
void _toggleFaq(int index) {
setState(() {
_expandedStates[index] = !_expandedStates[index];
if (_expandedStates[index]) {
_controllers[index].forward();
} else {
_controllers[index].reverse();
}
});
}
void _expandAll() {
setState(() {
for (int i = 0; i < _faqCount; i++) {
_expandedStates[i] = true;
_controllers[i].forward();
}
});
}
void _collapseAll() {
setState(() {
for (int i = 0; i < _faqCount; i++) {
_expandedStates[i] = false;
_controllers[i].reverse();
}
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final faqs = [
_FaqItem(
question: l10n.faqQuestion1,
answer: l10n.faqAnswer1,
),
_FaqItem(
question: l10n.faqQuestion2,
answer: l10n.faqAnswer2,
),
_FaqItem(
question: l10n.faqQuestion3,
answer: l10n.faqAnswer3,
),
_FaqItem(
question: l10n.faqQuestion4,
answer: l10n.faqAnswer4,
),
_FaqItem(
question: l10n.faqQuestion5,
answer: l10n.faqAnswer5,
),
];
return Scaffold(
appBar: AppBar(
title: Text(l10n.faqTitle),
actions: [
TextButton(
onPressed: _expandAll,
child: Text(l10n.expandAll),
),
TextButton(
onPressed: _collapseAll,
child: Text(l10n.collapseAll),
),
],
),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: faqs.length,
itemBuilder: (context, index) {
final faq = faqs[index];
final isExpanded = _expandedStates[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Semantics(
button: true,
expanded: isExpanded,
label: l10n.faqItemAccessibility(
index + 1,
faq.question,
isExpanded ? l10n.expanded : l10n.collapsed,
),
child: InkWell(
onTap: () => _toggleFaq(index),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
faq.question,
style: Theme.of(context).textTheme.titleSmall,
),
),
RotationTransition(
turns: Tween(begin: 0.0, end: 0.5).animate(_animations[index]),
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
),
SizeTransition(
sizeFactor: _animations[index],
axisAlignment: -1.0,
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
faq.answer,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
],
),
);
},
),
);
}
}
class _FaqItem {
final String question;
final String answer;
_FaqItem({required this.question, required this.answer});
}
Animated Dropdown Menu
Create a custom dropdown with size animation:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedAnimatedDropdown extends StatefulWidget {
const LocalizedAnimatedDropdown({super.key});
@override
State<LocalizedAnimatedDropdown> createState() => _LocalizedAnimatedDropdownState();
}
class _LocalizedAnimatedDropdownState extends State<LocalizedAnimatedDropdown>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _sizeAnimation;
late Animation<double> _fadeAnimation;
bool _isOpen = false;
String? _selectedValue;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_sizeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.8),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleDropdown() {
setState(() {
_isOpen = !_isOpen;
if (_isOpen) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
void _selectItem(String value, String displayText) {
setState(() {
_selectedValue = displayText;
_isOpen = false;
_controller.reverse();
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final countries = [
_CountryOption('us', l10n.countryUnitedStates, 'πΊπΈ'),
_CountryOption('uk', l10n.countryUnitedKingdom, 'π¬π§'),
_CountryOption('de', l10n.countryGermany, 'π©πͺ'),
_CountryOption('fr', l10n.countryFrance, 'π«π·'),
_CountryOption('jp', l10n.countryJapan, 'π―π΅'),
_CountryOption('br', l10n.countryBrazil, 'π§π·'),
];
return Scaffold(
appBar: AppBar(title: Text(l10n.selectCountryTitle)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.shippingCountryLabel,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
border: Border.all(
color: _isOpen
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: _isOpen ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Semantics(
button: true,
expanded: _isOpen,
label: l10n.dropdownAccessibility(
l10n.shippingCountryLabel,
_selectedValue ?? l10n.selectOption,
),
child: InkWell(
onTap: _toggleDropdown,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
_selectedValue ?? l10n.selectCountryPlaceholder,
style: TextStyle(
color: _selectedValue != null
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
RotationTransition(
turns: Tween(begin: 0.0, end: 0.5).animate(_sizeAnimation),
child: Icon(
Icons.expand_more,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
SizeTransition(
sizeFactor: _sizeAnimation,
axisAlignment: -1.0,
child: FadeTransition(
opacity: _fadeAnimation,
child: Column(
children: [
const Divider(height: 1),
...countries.map((country) => Semantics(
button: true,
label: l10n.countryOptionAccessibility(country.name),
child: InkWell(
onTap: () => _selectItem(
country.code,
'${country.flag} ${country.name}',
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Text(
country.flag,
style: const TextStyle(fontSize: 20),
),
const SizedBox(width: 12),
Expanded(child: Text(country.name)),
if (_selectedValue == '${country.flag} ${country.name}')
Icon(
Icons.check,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
],
),
),
),
)),
],
),
),
),
],
),
),
const SizedBox(height: 24),
if (_selectedValue != null)
Text(
l10n.selectedCountryConfirmation(_selectedValue!),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
class _CountryOption {
final String code;
final String name;
final String flag;
_CountryOption(this.code, this.name, this.flag);
}
Notification Panel with Size Animation
Create a sliding notification panel:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedNotificationPanel extends StatefulWidget {
const LocalizedNotificationPanel({super.key});
@override
State<LocalizedNotificationPanel> createState() => _LocalizedNotificationPanelState();
}
class _LocalizedNotificationPanelState extends State<LocalizedNotificationPanel>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _sizeAnimation;
bool _isPanelOpen = false;
final List<_Notification> _notifications = [];
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_sizeAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _togglePanel() {
setState(() {
_isPanelOpen = !_isPanelOpen;
if (_isPanelOpen) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
void _addNotification(AppLocalizations l10n) {
final types = [
_NotificationType(
Icons.message,
Colors.blue,
l10n.notificationNewMessage,
l10n.notificationMessagePreview,
),
_NotificationType(
Icons.shopping_cart,
Colors.green,
l10n.notificationOrderUpdate,
l10n.notificationOrderPreview,
),
_NotificationType(
Icons.warning,
Colors.orange,
l10n.notificationSystemAlert,
l10n.notificationAlertPreview,
),
];
final type = types[_notifications.length % types.length];
setState(() {
_notifications.insert(
0,
_Notification(
id: DateTime.now().millisecondsSinceEpoch.toString(),
icon: type.icon,
color: type.color,
title: type.title,
message: type.message,
time: l10n.justNow,
),
);
});
}
void _clearNotifications() {
setState(() {
_notifications.clear();
});
}
void _removeNotification(String id) {
setState(() {
_notifications.removeWhere((n) => n.id == id);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.notificationPanelTitle),
actions: [
Stack(
children: [
IconButton(
onPressed: _togglePanel,
icon: const Icon(Icons.notifications),
tooltip: l10n.toggleNotifications,
),
if (_notifications.isNotEmpty)
Positioned(
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: Center(
child: Text(
_notifications.length.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
],
),
body: Column(
children: [
SizeTransition(
sizeFactor: _sizeAnimation,
axisAlignment: -1.0,
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Text(
l10n.notificationsHeader(_notifications.length),
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
if (_notifications.isNotEmpty)
TextButton(
onPressed: _clearNotifications,
child: Text(l10n.clearAll),
),
],
),
),
const Divider(height: 1),
if (_notifications.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.notifications_none,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
l10n.noNotifications,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _notifications.length,
itemBuilder: (context, index) {
final notification = _notifications[index];
return Dismissible(
key: Key(notification.id),
direction: DismissDirection.endToStart,
onDismissed: (_) => _removeNotification(notification.id),
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.onError,
),
),
child: Semantics(
label: l10n.notificationAccessibility(
notification.title,
notification.message,
notification.time,
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: notification.color.withOpacity(0.2),
child: Icon(
notification.icon,
color: notification.color,
size: 20,
),
),
title: Text(notification.title),
subtitle: Text(
notification.message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
notification.time,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
);
},
),
),
],
),
),
),
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () => _addNotification(l10n),
icon: const Icon(Icons.add),
label: Text(l10n.addNotificationButton),
),
const SizedBox(height: 16),
Text(
l10n.notificationInstructions,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
),
);
}
}
class _Notification {
final String id;
final IconData icon;
final Color color;
final String title;
final String message;
final String time;
_Notification({
required this.id,
required this.icon,
required this.color,
required this.title,
required this.message,
required this.time,
});
}
class _NotificationType {
final IconData icon;
final Color color;
final String title;
final String message;
_NotificationType(this.icon, this.color, this.title, this.message);
}
Progressive Form Disclosure
Create a form that reveals sections progressively:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedProgressiveForm extends StatefulWidget {
const LocalizedProgressiveForm({super.key});
@override
State<LocalizedProgressiveForm> createState() => _LocalizedProgressiveFormState();
}
class _LocalizedProgressiveFormState extends State<LocalizedProgressiveForm>
with TickerProviderStateMixin {
late AnimationController _addressController;
late AnimationController _paymentController;
late Animation<double> _addressAnimation;
late Animation<double> _paymentAnimation;
final _formKey = GlobalKey<FormState>();
bool _showAddressSection = false;
bool _showPaymentSection = false;
String? _selectedAccountType;
@override
void initState() {
super.initState();
_addressController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_paymentController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_addressAnimation = CurvedAnimation(
parent: _addressController,
curve: Curves.easeOutCubic,
);
_paymentAnimation = CurvedAnimation(
parent: _paymentController,
curve: Curves.easeOutCubic,
);
}
@override
void dispose() {
_addressController.dispose();
_paymentController.dispose();
super.dispose();
}
void _onAccountTypeSelected(String? value) {
setState(() {
_selectedAccountType = value;
if (value == 'business') {
_showAddressSection = true;
_addressController.forward();
} else {
_showAddressSection = false;
_addressController.reverse();
}
});
}
void _showPayment() {
setState(() {
_showPaymentSection = true;
_paymentController.forward();
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.registrationFormTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Info Section
Text(
l10n.basicInfoSection,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.fullNameLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.emailLabel,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
// Account Type Selection
Text(
l10n.accountTypeLabel,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: Text(l10n.personalAccount),
value: 'personal',
groupValue: _selectedAccountType,
onChanged: _onAccountTypeSelected,
),
),
Expanded(
child: RadioListTile<String>(
title: Text(l10n.businessAccount),
value: 'business',
groupValue: _selectedAccountType,
onChanged: _onAccountTypeSelected,
),
),
],
),
// Business Address Section (conditionally shown)
SizeTransition(
sizeFactor: _addressAnimation,
axisAlignment: -1.0,
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.business,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
l10n.businessDetailsSection,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.companyNameLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.taxIdLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.businessAddressLabel,
border: const OutlineInputBorder(),
),
maxLines: 2,
),
],
),
),
),
const SizedBox(height: 24),
// Continue Button
if (!_showPaymentSection)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _showPayment,
child: Text(l10n.continueToPayment),
),
),
// Payment Section (progressively disclosed)
SizeTransition(
sizeFactor: _paymentAnimation,
axisAlignment: -1.0,
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.payment,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
l10n.paymentDetailsSection,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
TextFormField(
decoration: InputDecoration(
labelText: l10n.cardNumberLabel,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.credit_card),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: l10n.expiryDateLabel,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: InputDecoration(
labelText: l10n.cvvLabel,
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
obscureText: true,
),
),
],
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.formSubmittedMessage)),
);
},
child: Text(l10n.submitButton),
),
),
],
),
),
),
],
),
),
),
);
}
}
Complete ARB File for SizeTransition
{
"@@locale": "en",
"expandableSectionTitle": "Expandable Section",
"productDetailsHeader": "Product Details",
"sectionHeaderAccessibility": "{title}, {state}",
"@sectionHeaderAccessibility": {
"placeholders": {
"title": {"type": "String"},
"state": {"type": "String"}
}
},
"expanded": "expanded",
"collapsed": "collapsed",
"productDescription": "High-quality wireless headphones with active noise cancellation, 30-hour battery life, and premium comfort design.",
"productSku": "SKU:",
"productCategory": "Category:",
"categoryElectronics": "Electronics",
"productWeight": "Weight:",
"weightValue": "{weight} kg",
"@weightValue": {
"placeholders": {
"weight": {"type": "double", "format": "decimalPattern"}
}
},
"productDimensions": "Dimensions:",
"dimensionsValue": "{length} x {width} x {height} cm",
"@dimensionsValue": {
"placeholders": {
"length": {"type": "int"},
"width": {"type": "int"},
"height": {"type": "int"}
}
},
"faqTitle": "Frequently Asked Questions",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"faqItemAccessibility": "Question {number}: {question}, {state}",
"@faqItemAccessibility": {
"placeholders": {
"number": {"type": "int"},
"question": {"type": "String"},
"state": {"type": "String"}
}
},
"faqQuestion1": "How do I reset my password?",
"faqAnswer1": "Go to Settings > Account > Security, then tap 'Reset Password'. You'll receive an email with instructions to create a new password.",
"faqQuestion2": "Can I use the app offline?",
"faqAnswer2": "Yes! Most features work offline. Your data syncs automatically when you reconnect to the internet.",
"faqQuestion3": "How do I contact support?",
"faqAnswer3": "You can reach our support team via email at support@example.com or through the in-app chat available 24/7.",
"faqQuestion4": "Is my data secure?",
"faqAnswer4": "Absolutely. We use industry-standard encryption and never share your personal information with third parties.",
"faqQuestion5": "How do I cancel my subscription?",
"faqAnswer5": "Go to Settings > Subscription > Manage, then tap 'Cancel Subscription'. You'll retain access until the end of your billing period.",
"selectCountryTitle": "Select Country",
"shippingCountryLabel": "Shipping Country",
"selectOption": "Select an option",
"selectCountryPlaceholder": "Choose your country",
"dropdownAccessibility": "{label}, current value: {value}",
"@dropdownAccessibility": {
"placeholders": {
"label": {"type": "String"},
"value": {"type": "String"}
}
},
"countryOptionAccessibility": "Select {country}",
"@countryOptionAccessibility": {
"placeholders": {
"country": {"type": "String"}
}
},
"countryUnitedStates": "United States",
"countryUnitedKingdom": "United Kingdom",
"countryGermany": "Germany",
"countryFrance": "France",
"countryJapan": "Japan",
"countryBrazil": "Brazil",
"selectedCountryConfirmation": "Shipping to: {country}",
"@selectedCountryConfirmation": {
"placeholders": {
"country": {"type": "String"}
}
},
"notificationPanelTitle": "Notifications",
"toggleNotifications": "Toggle notifications panel",
"notificationsHeader": "{count, plural, =0{No notifications} =1{1 notification} other{{count} notifications}}",
"@notificationsHeader": {
"placeholders": {
"count": {"type": "int"}
}
},
"clearAll": "Clear All",
"noNotifications": "No notifications yet",
"addNotificationButton": "Add Notification",
"notificationInstructions": "Tap the bell icon to toggle the notification panel",
"notificationNewMessage": "New Message",
"notificationMessagePreview": "You have a new message from Sarah",
"notificationOrderUpdate": "Order Update",
"notificationOrderPreview": "Your order #1234 has been shipped",
"notificationSystemAlert": "System Alert",
"notificationAlertPreview": "Please update your app for new features",
"justNow": "Just now",
"notificationAccessibility": "{title}: {message}, received {time}",
"@notificationAccessibility": {
"placeholders": {
"title": {"type": "String"},
"message": {"type": "String"},
"time": {"type": "String"}
}
},
"registrationFormTitle": "Create Account",
"basicInfoSection": "Basic Information",
"fullNameLabel": "Full Name",
"emailLabel": "Email Address",
"accountTypeLabel": "Account Type",
"personalAccount": "Personal",
"businessAccount": "Business",
"businessDetailsSection": "Business Details",
"companyNameLabel": "Company Name",
"taxIdLabel": "Tax ID / VAT Number",
"businessAddressLabel": "Business Address",
"continueToPayment": "Continue to Payment",
"paymentDetailsSection": "Payment Information",
"cardNumberLabel": "Card Number",
"expiryDateLabel": "MM/YY",
"cvvLabel": "CVV",
"submitButton": "Complete Registration",
"formSubmittedMessage": "Registration submitted successfully!"
}
Best Practices Summary
- Set axisAlignment correctly: Use -1.0 to expand from top, 1.0 from bottom
- Combine with other animations: SizeTransition pairs well with FadeTransition for smoother effects
- Use Clip.antiAlias: Apply to parent containers to prevent content overflow during animation
- Dispose controllers properly: Always dispose AnimationControllers in the dispose method
- Add accessibility labels: Use Semantics with expanded state information
- Handle keyboard properly: Ensure forms remain visible when keyboard appears
- Consider performance: Avoid animating very large content sections
- Use curves appropriately: easeOutCubic works well for expand, easeInCubic for collapse
- Maintain scroll position: Be mindful of how expansion affects scroll views
- Test RTL layouts: Ensure axisAlignment works correctly in RTL languages
Conclusion
SizeTransition provides precise control over reveal animations in Flutter apps. By using explicit AnimationControllers, you can create polished expandable sections, dropdown menus, notification panels, and progressive forms that enhance the user experience across all languages. The key is combining proper accessibility announcements with smooth, well-timed animations that feel natural and responsive.
Remember to always dispose of your AnimationControllers and test your size animations with various content lengths to ensure they work correctly with different text sizes across languages.