Flutter Positioned Localization: Absolute Positioning for Multilingual Apps
Positioned is Flutter's widget for absolute positioning within a Stack. In multilingual applications, proper use of Positioned is essential for creating overlays, badges, tooltips, and floating elements that adapt correctly to different languages and text directions.
Understanding Positioned in Localization Context
Positioned places its child at specific coordinates within a Stack. For multilingual apps, this requires careful consideration because:
- RTL languages flip the meaning of left/right positioning
- Badge and indicator positions must adapt to text direction
- Overlay content needs directional awareness
- Floating elements should maintain contextual positioning
Why Positioned Matters for Multilingual Apps
Proper positioning ensures:
- RTL compatibility: Elements appear in contextually correct locations
- Badge placement: Notification badges position correctly in all directions
- Overlay alignment: Tooltips and popups appear near their triggers
- Visual consistency: Floating elements maintain proper relationships
Basic Positioned Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedPositionedExample extends StatelessWidget {
const LocalizedPositionedExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.cardTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(l10n.cardDescription),
],
),
),
),
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
l10n.newBadge,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
Directional Positioning for RTL Support
Using PositionedDirectional
class DirectionalPositionedExample extends StatelessWidget {
const DirectionalPositionedExample({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
children: [
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(l10n.mainContent),
),
),
// Use PositionedDirectional for RTL support
PositionedDirectional(
top: 8,
end: 8, // Right in LTR, Left in RTL
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {},
tooltip: l10n.closeTooltip,
),
),
PositionedDirectional(
bottom: 8,
start: 8, // Left in LTR, Right in RTL
child: Text(
l10n.footerNote,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
}
RTL-Aware Positioned Widget
class RtlAwarePositioned extends StatelessWidget {
final double? top;
final double? bottom;
final double? start;
final double? end;
final double? width;
final double? height;
final Widget child;
const RtlAwarePositioned({
super.key,
this.top,
this.bottom,
this.start,
this.end,
this.width,
this.height,
required this.child,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Positioned(
top: top,
bottom: bottom,
left: isRtl ? end : start,
right: isRtl ? start : end,
width: width,
height: height,
child: child,
);
}
}
// Usage
class BadgeOverlay extends StatelessWidget {
const BadgeOverlay({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications, size: 32),
RtlAwarePositioned(
top: -4,
end: -4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'5',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
Badge Positioning Patterns
Notification Badge Component
class LocalizedBadge extends StatelessWidget {
final Widget child;
final String? badgeText;
final int? count;
final Color? backgroundColor;
const LocalizedBadge({
super.key,
required this.child,
this.badgeText,
this.count,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
final showBadge = badgeText != null || (count != null && count! > 0);
if (!showBadge) return child;
final displayText = badgeText ??
(count! > 99 ? '99+' : count.toString());
return Stack(
clipBehavior: Clip.none,
children: [
child,
PositionedDirectional(
top: -8,
end: -8,
child: Container(
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: backgroundColor ?? Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
displayText,
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
}
// Usage
class NotificationButton extends StatelessWidget {
final int unreadCount;
const NotificationButton({
super.key,
required this.unreadCount,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return LocalizedBadge(
count: unreadCount,
child: IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {},
tooltip: l10n.notificationsTooltip,
),
);
}
}
Overlay Positioning
Localized Tooltip Overlay
class LocalizedTooltip extends StatefulWidget {
final Widget child;
final String message;
const LocalizedTooltip({
super.key,
required this.child,
required this.message,
});
@override
State<LocalizedTooltip> createState() => _LocalizedTooltipState();
}
class _LocalizedTooltipState extends State<LocalizedTooltip> {
bool _showTooltip = false;
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return GestureDetector(
onLongPress: () => setState(() => _showTooltip = true),
onLongPressEnd: (_) => setState(() => _showTooltip = false),
child: Stack(
clipBehavior: Clip.none,
children: [
widget.child,
if (_showTooltip)
Positioned(
bottom: 48,
left: isRtl ? null : 0,
right: isRtl ? 0 : null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
widget.message,
style: TextStyle(
color: Theme.of(context).colorScheme.onInverseSurface,
fontSize: 14,
),
),
),
),
],
),
);
}
}
Floating Action Positioning
Positioned FAB Menu
class LocalizedFabMenu extends StatefulWidget {
const LocalizedFabMenu({super.key});
@override
State<LocalizedFabMenu> createState() => _LocalizedFabMenuState();
}
class _LocalizedFabMenuState extends State<LocalizedFabMenu>
with SingleTickerProviderStateMixin {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
width: 200,
height: 300,
child: Stack(
children: [
// Secondary actions
if (_isExpanded) ...[
PositionedDirectional(
bottom: 140,
end: 0,
child: _FabMenuItem(
icon: Icons.photo,
label: l10n.addPhotoAction,
onPressed: () {},
),
),
PositionedDirectional(
bottom: 80,
end: 0,
child: _FabMenuItem(
icon: Icons.note_add,
label: l10n.addNoteAction,
onPressed: () {},
),
),
],
// Main FAB
PositionedDirectional(
bottom: 16,
end: 0,
child: FloatingActionButton(
onPressed: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedRotation(
turns: _isExpanded ? 0.125 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.add),
),
),
),
],
),
);
}
}
class _FabMenuItem extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _FabMenuItem({
required this.icon,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Text(label),
),
const SizedBox(width: 8),
FloatingActionButton.small(
heroTag: label,
onPressed: onPressed,
child: Icon(icon),
),
],
);
}
}
Image Overlay Positioning
Localized Image Caption
class LocalizedImageCard extends StatelessWidget {
final String imageUrl;
final String title;
final String? badge;
const LocalizedImageCard({
super.key,
required this.imageUrl,
required this.title,
this.badge,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
// Image
AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stack) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.image, size: 48),
),
),
),
// Gradient overlay
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
stops: const [0.5, 1.0],
),
),
),
),
// Title at bottom
PositionedDirectional(
bottom: 12,
start: 12,
end: 12,
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Badge at top corner
if (badge != null)
PositionedDirectional(
top: 12,
end: 12,
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,
),
),
),
),
],
),
);
}
}
Status Indicator Positioning
User Avatar with Status
class LocalizedUserAvatar extends StatelessWidget {
final String? imageUrl;
final String initials;
final bool isOnline;
final double size;
const LocalizedUserAvatar({
super.key,
this.imageUrl,
required this.initials,
this.isOnline = false,
this.size = 48,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
// Avatar
CircleAvatar(
radius: size / 2,
backgroundImage: imageUrl != null ? NetworkImage(imageUrl!) : null,
child: imageUrl == null ? Text(initials) : null,
),
// Online indicator
if (isOnline)
PositionedDirectional(
bottom: 0,
end: 0,
child: Container(
width: size * 0.3,
height: size * 0.3,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.surface,
width: 2,
),
),
),
),
],
),
);
}
}
Card Corner Actions
Positioned Action Menu
class LocalizedActionCard extends StatelessWidget {
final String title;
final String description;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const LocalizedActionCard({
super.key,
required this.title,
required this.description,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 40),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
PositionedDirectional(
top: 4,
end: 4,
child: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: l10n.moreOptionsTooltip,
onSelected: (value) {
if (value == 'edit') onEdit?.call();
if (value == 'delete') onDelete?.call();
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 20),
const SizedBox(width: 12),
Text(l10n.editAction),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(
Icons.delete,
size: 20,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 12),
Text(
l10n.deleteAction,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
),
),
],
),
),
],
),
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"cardTitle": "Featured Item",
"@cardTitle": {
"description": "Title for featured card"
},
"cardDescription": "This is a special item with exclusive features and benefits.",
"@cardDescription": {
"description": "Description for featured card"
},
"newBadge": "NEW",
"@newBadge": {
"description": "Badge for new items"
},
"mainContent": "Main Content Area",
"closeTooltip": "Close",
"footerNote": "Last updated today",
"notificationsTooltip": "Notifications",
"addPhotoAction": "Add Photo",
"addNoteAction": "Add Note",
"moreOptionsTooltip": "More options",
"editAction": "Edit",
"deleteAction": "Delete"
}
German (app_de.arb)
{
"@@locale": "de",
"cardTitle": "Hervorgehobener Artikel",
"cardDescription": "Dies ist ein besonderer Artikel mit exklusiven Funktionen und Vorteilen.",
"newBadge": "NEU",
"mainContent": "Hauptinhaltsbereich",
"closeTooltip": "Schließen",
"footerNote": "Zuletzt aktualisiert heute",
"notificationsTooltip": "Benachrichtigungen",
"addPhotoAction": "Foto hinzufügen",
"addNoteAction": "Notiz hinzufügen",
"moreOptionsTooltip": "Weitere Optionen",
"editAction": "Bearbeiten",
"deleteAction": "Löschen"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"cardTitle": "عنصر مميز",
"cardDescription": "هذا عنصر خاص بميزات وفوائد حصرية.",
"newBadge": "جديد",
"mainContent": "منطقة المحتوى الرئيسية",
"closeTooltip": "إغلاق",
"footerNote": "آخر تحديث اليوم",
"notificationsTooltip": "الإشعارات",
"addPhotoAction": "إضافة صورة",
"addNoteAction": "إضافة ملاحظة",
"moreOptionsTooltip": "خيارات أخرى",
"editAction": "تعديل",
"deleteAction": "حذف"
}
Best Practices Summary
Do's
- Use PositionedDirectional for RTL support
- Test badge positions in both LTR and RTL layouts
- Use clipBehavior: Clip.none when elements overflow Stack
- Combine with Stack for layered positioning
- Consider touch targets when positioning interactive elements
Don'ts
- Don't use Positioned(left/right) in RTL-supporting apps
- Don't position elements off-screen without checking direction
- Don't forget to test with long translations
- Don't hardcode positions that should adapt to content
Accessibility Considerations
class AccessiblePositionedContent extends StatelessWidget {
const AccessiblePositionedContent({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
container: true,
child: Stack(
children: [
// Main content
Semantics(
label: l10n.mainContentLabel,
child: Container(
padding: const EdgeInsets.all(16),
child: Text(l10n.contentText),
),
),
// Close button with proper semantics
PositionedDirectional(
top: 8,
end: 8,
child: Semantics(
button: true,
label: l10n.closeButtonLabel,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {},
),
),
),
],
),
);
}
}
Conclusion
Positioned and PositionedDirectional are essential for creating polished multilingual Flutter applications with overlays, badges, and floating elements. By using PositionedDirectional instead of Positioned with left/right, you ensure your layouts work correctly in both LTR and RTL languages. Always test positioned elements with actual translations and in RTL mode to verify correct placement.