Flutter Dismissible Widget Localization: Swipe Actions, Undo Messages, and Confirmation Dialogs
The Dismissible widget enables swipe-to-delete and swipe-to-archive functionality that users expect in modern apps. Proper localization ensures all feedback messages, undo prompts, and confirmation dialogs work seamlessly across languages. This guide covers comprehensive strategies for localizing Dismissible widgets in Flutter.
Understanding Dismissible Localization
Dismissible widgets require localization for:
- Background labels: Delete, archive, or action text shown while swiping
- Snackbar messages: Feedback after item is dismissed
- Undo buttons: Allow users to reverse the action
- Confirmation dialogs: Optional prompts before permanent deletion
- Accessibility announcements: Screen reader feedback for swipe actions
- Direction hints: Visual cues for swipe direction in RTL layouts
Basic Dismissible with Localized Feedback
Start with a simple dismissible list with localized messages:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedDismissibleList extends StatefulWidget {
const LocalizedDismissibleList({super.key});
@override
State<LocalizedDismissibleList> createState() => _LocalizedDismissibleListState();
}
class _LocalizedDismissibleListState extends State<LocalizedDismissibleList> {
late List<String> _items;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final l10n = AppLocalizations.of(context)!;
_items = List.generate(10, (index) => l10n.taskItem(index + 1));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.tasksTitle),
),
body: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return Dismissible(
key: Key(item),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
l10n.deleteAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.white),
],
),
),
onDismissed: (direction) {
setState(() {
_items.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.itemDeleted(item)),
action: SnackBarAction(
label: l10n.undoAction,
onPressed: () {
setState(() {
_items.insert(index, item);
});
},
),
),
);
},
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(item),
subtitle: Text(l10n.swipeToDelete),
),
);
},
),
);
}
}
Bidirectional Dismissible with Multiple Actions
Support different actions for left and right swipes:
class BidirectionalDismissibleList extends StatefulWidget {
const BidirectionalDismissibleList({super.key});
@override
State<BidirectionalDismissibleList> createState() => _BidirectionalDismissibleListState();
}
class _BidirectionalDismissibleListState extends State<BidirectionalDismissibleList> {
final List<EmailItem> _emails = [];
final List<EmailItem> _archivedEmails = [];
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_emails.isEmpty) {
final l10n = AppLocalizations.of(context)!;
_emails.addAll(List.generate(
15,
(index) => EmailItem(
id: index,
sender: l10n.emailSender(index + 1),
subject: l10n.emailSubject(index + 1),
preview: l10n.emailPreview,
),
));
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
appBar: AppBar(
title: Text(l10n.inboxTitle),
actions: [
if (_archivedEmails.isNotEmpty)
Badge(
label: Text('${_archivedEmails.length}'),
child: IconButton(
icon: const Icon(Icons.archive),
tooltip: l10n.viewArchive,
onPressed: () => _showArchivedEmails(context),
),
),
],
),
body: _emails.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.inbox, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(l10n.emptyInbox),
],
),
)
: ListView.builder(
itemCount: _emails.length,
itemBuilder: (context, index) {
final email = _emails[index];
return Dismissible(
key: Key('email-${email.id}'),
// Swap directions for RTL
background: Container(
color: Colors.blue,
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: [
const Icon(Icons.archive, color: Colors.white),
const SizedBox(width: 8),
Text(
l10n.archiveAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
l10n.deleteAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.white),
],
),
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.endToStart) {
// Delete action - show confirmation
return await _showDeleteConfirmation(context, email);
}
// Archive action - no confirmation needed
return true;
},
onDismissed: (direction) {
setState(() {
_emails.removeAt(index);
});
if (direction == DismissDirection.startToEnd) {
// Archived
_archivedEmails.add(email);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.emailArchived(email.sender)),
action: SnackBarAction(
label: l10n.undoAction,
onPressed: () {
setState(() {
_archivedEmails.remove(email);
_emails.insert(index, email);
});
},
),
),
);
} else {
// Deleted
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.emailDeleted(email.sender)),
action: SnackBarAction(
label: l10n.undoAction,
onPressed: () {
setState(() {
_emails.insert(index, email);
});
},
),
),
);
}
},
child: ListTile(
leading: CircleAvatar(
child: Text(email.sender[0].toUpperCase()),
),
title: Text(email.sender),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email.subject,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Text(
email.preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
isThreeLine: true,
),
);
},
),
);
}
Future<bool> _showDeleteConfirmation(BuildContext context, EmailItem email) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.confirmDeleteTitle),
content: Text(l10n.confirmDeleteMessage(email.sender)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.deleteAction),
),
],
),
) ?? false;
}
void _showArchivedEmails(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
builder: (context) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.archivedEmailsTitle(_archivedEmails.length),
style: Theme.of(context).textTheme.titleLarge,
),
),
Expanded(
child: ListView.builder(
itemCount: _archivedEmails.length,
itemBuilder: (context, index) {
final email = _archivedEmails[index];
return ListTile(
title: Text(email.sender),
subtitle: Text(email.subject),
trailing: IconButton(
icon: const Icon(Icons.unarchive),
tooltip: l10n.unarchiveAction,
onPressed: () {
setState(() {
_archivedEmails.removeAt(index);
_emails.add(email);
});
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.emailUnarchived(email.sender))),
);
},
),
);
},
),
),
],
),
);
}
}
class EmailItem {
final int id;
final String sender;
final String subject;
final String preview;
EmailItem({
required this.id,
required this.sender,
required this.subject,
required this.preview,
});
}
Dismissible with Threshold-Based Actions
Show progressive feedback based on swipe distance:
class ThresholdDismissible extends StatelessWidget {
final Widget child;
final String itemName;
final VoidCallback onDelete;
final VoidCallback onArchive;
const ThresholdDismissible({
super.key,
required this.child,
required this.itemName,
required this.onDelete,
required this.onArchive,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Dismissible(
key: Key(itemName),
dismissThresholds: const {
DismissDirection.endToStart: 0.4,
DismissDirection.startToEnd: 0.4,
},
movementDuration: const Duration(milliseconds: 200),
background: _buildBackground(
context,
l10n.archiveAction,
Icons.archive,
Colors.blue,
AlignmentDirectional.centerStart,
),
secondaryBackground: _buildBackground(
context,
l10n.deleteAction,
Icons.delete,
Colors.red,
AlignmentDirectional.centerEnd,
),
onDismissed: (direction) {
if (direction == DismissDirection.startToEnd) {
onArchive();
} else {
onDelete();
}
},
child: child,
);
}
Widget _buildBackground(
BuildContext context,
String label,
IconData icon,
Color color,
AlignmentDirectional alignment,
) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
color: color,
alignment: alignment,
padding: const EdgeInsetsDirectional.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: alignment == AlignmentDirectional.centerStart
? [
Icon(icon, color: Colors.white, size: 28),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
]
: [
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
Icon(icon, color: Colors.white, size: 28),
],
),
);
}
}
Accessible Dismissible with Semantic Actions
Provide full accessibility support with custom semantic actions:
class AccessibleDismissible extends StatelessWidget {
final String itemId;
final String itemTitle;
final String itemSubtitle;
final VoidCallback onDelete;
final VoidCallback onArchive;
const AccessibleDismissible({
super.key,
required this.itemId,
required this.itemTitle,
required this.itemSubtitle,
required this.onDelete,
required this.onArchive,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.itemSemanticLabel(itemTitle),
hint: l10n.swipeActionsHint,
customSemanticsActions: {
CustomSemanticsAction(label: l10n.deleteAction): onDelete,
CustomSemanticsAction(label: l10n.archiveAction): onArchive,
},
child: Dismissible(
key: Key(itemId),
background: Container(
color: Colors.blue,
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Semantics(
excludeSemantics: true,
child: Row(
children: [
const Icon(Icons.archive, color: Colors.white),
const SizedBox(width: 8),
Text(
l10n.archiveAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 16),
child: Semantics(
excludeSemantics: true,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
l10n.deleteAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.white),
],
),
),
),
onDismissed: (direction) {
if (direction == DismissDirection.startToEnd) {
onArchive();
} else {
onDelete();
}
// Announce the action to screen readers
SemanticsService.announce(
direction == DismissDirection.startToEnd
? l10n.itemArchivedAnnouncement(itemTitle)
: l10n.itemDeletedAnnouncement(itemTitle),
Directionality.of(context),
);
},
child: ListTile(
title: Text(itemTitle),
subtitle: Text(itemSubtitle),
),
),
);
}
}
Dismissible with Custom Animation
Create smooth transitions with localized content:
class AnimatedDismissibleItem extends StatefulWidget {
final String itemKey;
final Widget child;
final Future<bool> Function()? confirmDismiss;
final VoidCallback onDismissed;
const AnimatedDismissibleItem({
super.key,
required this.itemKey,
required this.child,
this.confirmDismiss,
required this.onDismissed,
});
@override
State<AnimatedDismissibleItem> createState() => _AnimatedDismissibleItemState();
}
class _AnimatedDismissibleItemState extends State<AnimatedDismissibleItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Dismissible(
key: Key(widget.itemKey),
background: _buildAnimatedBackground(
context,
l10n.releaseToDelete,
Colors.red.withOpacity(0.8 + (_controller.value * 0.2)),
),
confirmDismiss: widget.confirmDismiss,
onDismissed: (_) {
_controller.forward().then((_) {
widget.onDismissed();
});
},
child: widget.child,
),
),
);
},
);
}
Widget _buildAnimatedBackground(BuildContext context, String label, Color color) {
return Container(
color: color,
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 20),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 200),
builder: (context, value, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Transform.scale(
scale: 0.8 + (value * 0.2),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
const SizedBox(width: 12),
Transform.rotate(
angle: value * 0.1,
child: const Icon(Icons.delete_forever, color: Colors.white, size: 28),
),
],
);
},
),
);
}
}
Grouped Dismissible with Section Headers
Handle dismissible items in grouped lists:
class GroupedDismissibleList extends StatefulWidget {
const GroupedDismissibleList({super.key});
@override
State<GroupedDismissibleList> createState() => _GroupedDismissibleListState();
}
class _GroupedDismissibleListState extends State<GroupedDismissibleList> {
late Map<String, List<TodoItem>> _groupedItems;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_initializeItems();
}
void _initializeItems() {
final l10n = AppLocalizations.of(context)!;
_groupedItems = {
l10n.todaySection: [
TodoItem(id: '1', title: l10n.taskBuyGroceries, priority: 'high'),
TodoItem(id: '2', title: l10n.taskCallDoctor, priority: 'medium'),
],
l10n.tomorrowSection: [
TodoItem(id: '3', title: l10n.taskFinishReport, priority: 'high'),
TodoItem(id: '4', title: l10n.taskTeamMeeting, priority: 'low'),
],
l10n.upcomingSection: [
TodoItem(id: '5', title: l10n.taskPlanVacation, priority: 'low'),
TodoItem(id: '6', title: l10n.taskRenewLicense, priority: 'medium'),
],
};
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.todoListTitle)),
body: ListView.builder(
itemCount: _groupedItems.length,
itemBuilder: (context, sectionIndex) {
final section = _groupedItems.keys.elementAt(sectionIndex);
final items = _groupedItems[section]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
section,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
...items.map((item) => _buildDismissibleItem(
context,
item,
section,
items.indexOf(item),
)),
if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noTasksInSection,
style: TextStyle(color: Colors.grey[600]),
),
),
],
);
},
),
);
}
Widget _buildDismissibleItem(
BuildContext context,
TodoItem item,
String section,
int index,
) {
final l10n = AppLocalizations.of(context)!;
return Dismissible(
key: Key(item.id),
background: Container(
color: Colors.green,
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsetsDirectional.only(start: 16),
child: Row(
children: [
const Icon(Icons.check, color: Colors.white),
const SizedBox(width: 8),
Text(
l10n.completeAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
secondaryBackground: Container(
color: Colors.red,
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
l10n.deleteAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.white),
],
),
),
onDismissed: (direction) {
setState(() {
_groupedItems[section]!.removeAt(index);
});
final message = direction == DismissDirection.startToEnd
? l10n.taskCompleted(item.title)
: l10n.taskDeleted(item.title);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: SnackBarAction(
label: l10n.undoAction,
onPressed: () {
setState(() {
_groupedItems[section]!.insert(index, item);
});
},
),
),
);
},
child: ListTile(
leading: _getPriorityIcon(item.priority),
title: Text(item.title),
subtitle: Text(l10n.priorityLabel(item.priority)),
),
);
}
Widget _getPriorityIcon(String priority) {
final color = switch (priority) {
'high' => Colors.red,
'medium' => Colors.orange,
_ => Colors.green,
};
return Icon(Icons.flag, color: color);
}
}
class TodoItem {
final String id;
final String title;
final String priority;
TodoItem({required this.id, required this.title, required this.priority});
}
ARB File Structure
Define all dismissible-related translations:
{
"@@locale": "en",
"tasksTitle": "Tasks",
"@tasksTitle": {
"description": "Title for tasks screen"
},
"taskItem": "Task {number}",
"@taskItem": {
"description": "Task item label",
"placeholders": {
"number": {
"type": "int"
}
}
},
"deleteAction": "Delete",
"@deleteAction": {
"description": "Delete action label"
},
"archiveAction": "Archive",
"@archiveAction": {
"description": "Archive action label"
},
"completeAction": "Complete",
"@completeAction": {
"description": "Complete action label"
},
"undoAction": "Undo",
"@undoAction": {
"description": "Undo action button label"
},
"cancelAction": "Cancel",
"@cancelAction": {
"description": "Cancel action button label"
},
"itemDeleted": "{item} deleted",
"@itemDeleted": {
"description": "Message shown when item is deleted",
"placeholders": {
"item": {
"type": "String"
}
}
},
"swipeToDelete": "Swipe left to delete",
"@swipeToDelete": {
"description": "Hint text for swipe action"
},
"inboxTitle": "Inbox",
"@inboxTitle": {
"description": "Title for inbox screen"
},
"emailSender": "Sender {number}",
"@emailSender": {
"description": "Email sender name",
"placeholders": {
"number": {
"type": "int"
}
}
},
"emailSubject": "Subject {number}",
"@emailSubject": {
"description": "Email subject",
"placeholders": {
"number": {
"type": "int"
}
}
},
"emailPreview": "This is a preview of the email content...",
"@emailPreview": {
"description": "Email preview text"
},
"emailArchived": "Email from {sender} archived",
"@emailArchived": {
"description": "Message when email is archived",
"placeholders": {
"sender": {
"type": "String"
}
}
},
"emailDeleted": "Email from {sender} deleted",
"@emailDeleted": {
"description": "Message when email is deleted",
"placeholders": {
"sender": {
"type": "String"
}
}
},
"emailUnarchived": "Email from {sender} restored",
"@emailUnarchived": {
"description": "Message when email is unarchived",
"placeholders": {
"sender": {
"type": "String"
}
}
},
"confirmDeleteTitle": "Delete email?",
"@confirmDeleteTitle": {
"description": "Delete confirmation dialog title"
},
"confirmDeleteMessage": "Are you sure you want to delete the email from {sender}? This action cannot be undone.",
"@confirmDeleteMessage": {
"description": "Delete confirmation dialog message",
"placeholders": {
"sender": {
"type": "String"
}
}
},
"viewArchive": "View archive",
"@viewArchive": {
"description": "View archive button tooltip"
},
"unarchiveAction": "Unarchive",
"@unarchiveAction": {
"description": "Unarchive action label"
},
"archivedEmailsTitle": "{count, plural, =0{No archived emails} =1{1 archived email} other{{count} archived emails}}",
"@archivedEmailsTitle": {
"description": "Archived emails section title",
"placeholders": {
"count": {
"type": "int"
}
}
},
"emptyInbox": "Your inbox is empty",
"@emptyInbox": {
"description": "Empty inbox message"
},
"releaseToDelete": "Release to delete",
"@releaseToDelete": {
"description": "Text shown when item is ready to be deleted"
},
"itemSemanticLabel": "Item: {title}",
"@itemSemanticLabel": {
"description": "Semantic label for list item",
"placeholders": {
"title": {
"type": "String"
}
}
},
"swipeActionsHint": "Swipe left to delete, right to archive",
"@swipeActionsHint": {
"description": "Accessibility hint for swipe actions"
},
"itemArchivedAnnouncement": "{title} archived",
"@itemArchivedAnnouncement": {
"description": "Screen reader announcement when item archived",
"placeholders": {
"title": {
"type": "String"
}
}
},
"itemDeletedAnnouncement": "{title} deleted",
"@itemDeletedAnnouncement": {
"description": "Screen reader announcement when item deleted",
"placeholders": {
"title": {
"type": "String"
}
}
},
"todoListTitle": "To-Do List",
"@todoListTitle": {
"description": "Title for todo list screen"
},
"todaySection": "Today",
"@todaySection": {
"description": "Today section header"
},
"tomorrowSection": "Tomorrow",
"@tomorrowSection": {
"description": "Tomorrow section header"
},
"upcomingSection": "Upcoming",
"@upcomingSection": {
"description": "Upcoming section header"
},
"taskBuyGroceries": "Buy groceries",
"@taskBuyGroceries": {
"description": "Sample task"
},
"taskCallDoctor": "Call the doctor",
"@taskCallDoctor": {
"description": "Sample task"
},
"taskFinishReport": "Finish quarterly report",
"@taskFinishReport": {
"description": "Sample task"
},
"taskTeamMeeting": "Team meeting",
"@taskTeamMeeting": {
"description": "Sample task"
},
"taskPlanVacation": "Plan vacation",
"@taskPlanVacation": {
"description": "Sample task"
},
"taskRenewLicense": "Renew driver's license",
"@taskRenewLicense": {
"description": "Sample task"
},
"noTasksInSection": "No tasks in this section",
"@noTasksInSection": {
"description": "Empty section message"
},
"taskCompleted": "{task} completed",
"@taskCompleted": {
"description": "Task completed message",
"placeholders": {
"task": {
"type": "String"
}
}
},
"taskDeleted": "{task} deleted",
"@taskDeleted": {
"description": "Task deleted message",
"placeholders": {
"task": {
"type": "String"
}
}
},
"priorityLabel": "Priority: {level}",
"@priorityLabel": {
"description": "Priority level label",
"placeholders": {
"level": {
"type": "String"
}
}
}
}
RTL Support for Dismissible
Handle right-to-left layouts properly:
class RtlAwareDismissible extends StatelessWidget {
final Key itemKey;
final Widget child;
final VoidCallback onDismissed;
const RtlAwareDismissible({
super.key,
required this.itemKey,
required this.child,
required this.onDismissed,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRtl = Directionality.of(context) == TextDirection.rtl;
// In RTL, primary action (delete) should still be on the trailing side
// But visually, that's now the left side
return Dismissible(
key: itemKey,
// endToStart is always the "trailing" direction regardless of RTL
direction: DismissDirection.endToStart,
background: Container(
// Use Directional alignment to handle RTL automatically
alignment: AlignmentDirectional.centerEnd,
padding: const EdgeInsetsDirectional.only(end: 20),
color: Colors.red,
child: Row(
mainAxisSize: MainAxisSize.min,
// Row direction is automatically handled by Directionality
children: [
Text(
l10n.deleteAction,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.white),
],
),
),
onDismissed: (_) => onDismissed(),
child: child,
);
}
}
Testing Dismissible Localization
Comprehensive tests for dismissible widgets:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Dismissible Localization Tests', () {
testWidgets('displays localized delete action text', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const LocalizedDismissibleList(),
),
);
// Start swipe gesture
await tester.drag(find.byType(Dismissible).first, const Offset(-200, 0));
await tester.pump();
// Verify Spanish delete text
expect(find.text('Eliminar'), findsOneWidget);
});
testWidgets('shows localized undo snackbar after dismissal', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('fr'),
home: const LocalizedDismissibleList(),
),
);
// Complete dismiss action
await tester.drag(find.byType(Dismissible).first, const Offset(-500, 0));
await tester.pumpAndSettle();
// Verify French undo button
expect(find.text('Annuler'), findsOneWidget);
});
testWidgets('confirmation dialog shows localized text', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: const BidirectionalDismissibleList(),
),
);
// Swipe to trigger delete
await tester.drag(find.byType(Dismissible).first, const Offset(-500, 0));
await tester.pumpAndSettle();
// Verify German confirmation dialog
expect(find.text('E-Mail loschen?'), findsOneWidget);
expect(find.text('Abbrechen'), findsOneWidget);
expect(find.text('Loschen'), findsOneWidget);
});
testWidgets('handles RTL layout correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: const Directionality(
textDirection: TextDirection.rtl,
child: LocalizedDismissibleList(),
),
),
);
// Swipe should still work end-to-start (which is left in RTL)
await tester.drag(find.byType(Dismissible).first, const Offset(200, 0));
await tester.pump();
// Background should show with Arabic text
expect(find.byType(Container), findsWidgets);
});
testWidgets('accessibility actions are properly labeled', (tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: AccessibleDismissible(
itemId: 'test-1',
itemTitle: 'Test Item',
itemSubtitle: 'Test subtitle',
onDelete: () {},
onArchive: () {},
),
),
);
// Verify semantic actions exist
final semantics = tester.getSemantics(find.byType(Semantics).first);
expect(semantics.hint, contains('Swipe'));
handle.dispose();
});
});
}
Best Practices Summary
Use directional alignments: Always use
AlignmentDirectionalandEdgeInsetsDirectionalfor proper RTL supportProvide undo actions: Always give users a way to reverse accidental dismissals with localized undo buttons
Confirm destructive actions: Use localized confirmation dialogs for permanent deletions
Announce to screen readers: Use
SemanticsService.announceto inform assistive technology users of completed actionsKeep feedback concise: Snackbar messages should be short and clear in all languages
Test swipe thresholds: Ensure dismiss thresholds work well with different text lengths
Handle empty states: Show localized messages when all items are dismissed
Support keyboard navigation: Provide alternative actions for users who cannot swipe
By following these patterns, your Dismissible widgets will provide a consistent and accessible experience for users across all languages and reading directions.