Flutter ReorderableListView Localization: Drag Handles, Instructions, and Accessibility
ReorderableListView allows users to reorganize items through drag-and-drop interactions. Localizing these lists requires attention to drag instructions, accessibility announcements, and confirmation messages across different languages and reading directions. This guide covers everything you need to know about localizing reorderable lists in Flutter.
Understanding ReorderableListView Localization
Reorderable lists require localization for:
- Drag instructions: "Hold and drag to reorder"
- Accessibility labels: Screen reader announcements during drag operations
- Position announcements: "Moved from position 3 to position 1"
- Handle tooltips: Drag handle descriptions
- Confirmation messages: "List reordered successfully"
- RTL support: Proper handle placement and animations
Basic ReorderableListView with Localization
Start with a localized task list:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedReorderableTaskList extends StatefulWidget {
const LocalizedReorderableTaskList({super.key});
@override
State<LocalizedReorderableTaskList> createState() =>
_LocalizedReorderableTaskListState();
}
class _LocalizedReorderableTaskListState
extends State<LocalizedReorderableTaskList> {
late List<Task> _tasks;
@override
void initState() {
super.initState();
_tasks = [
Task(id: '1', titleKey: 'taskBuyGroceries'),
Task(id: '2', titleKey: 'taskPayBills'),
Task(id: '3', titleKey: 'taskCallDoctor'),
Task(id: '4', titleKey: 'taskFinishReport'),
Task(id: '5', titleKey: 'taskExercise'),
];
}
String _getLocalizedTitle(BuildContext context, String key) {
final l10n = AppLocalizations.of(context)!;
switch (key) {
case 'taskBuyGroceries':
return l10n.taskBuyGroceries;
case 'taskPayBills':
return l10n.taskPayBills;
case 'taskCallDoctor':
return l10n.taskCallDoctor;
case 'taskFinishReport':
return l10n.taskFinishReport;
case 'taskExercise':
return l10n.taskExercise;
default:
return key;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.myTasksTitle,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Text(
l10n.reorderInstructions,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
Expanded(
child: ReorderableListView.builder(
itemCount: _tasks.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final task = _tasks.removeAt(oldIndex);
_tasks.insert(newIndex, task);
});
// Show confirmation snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.taskReorderedMessage),
duration: const Duration(seconds: 1),
),
);
},
itemBuilder: (context, index) {
final task = _tasks[index];
return _TaskItem(
key: ValueKey(task.id),
title: _getLocalizedTitle(context, task.titleKey),
position: index + 1,
total: _tasks.length,
);
},
),
),
],
);
}
}
class Task {
final String id;
final String titleKey;
Task({required this.id, required this.titleKey});
}
class _TaskItem extends StatelessWidget {
final String title;
final int position;
final int total;
const _TaskItem({
super.key,
required this.title,
required this.position,
required this.total,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.taskItemAccessibilityLabel(title, position, total),
hint: l10n.dragToReorderHint,
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
child: Text('$position'),
),
title: Text(title),
trailing: ReorderableDragStartListener(
index: position - 1,
child: Tooltip(
message: l10n.dragHandleTooltip,
child: const Icon(Icons.drag_handle),
),
),
),
);
}
}
Custom Drag Handle with Localized Tooltip
Create accessible drag handles:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedDragHandle extends StatelessWidget {
final int index;
const LocalizedDragHandle({
super.key,
required this.index,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ReorderableDragStartListener(
index: index,
child: Semantics(
label: l10n.dragHandleLabel,
hint: l10n.dragHandleHint,
button: true,
child: Tooltip(
message: l10n.dragHandleTooltip,
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.drag_indicator,
color: Colors.grey[600],
),
),
),
),
);
}
}
Accessibility Announcements During Reorder
Announce position changes for screen reader users:
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AccessibleReorderableList extends StatefulWidget {
final List<String> items;
final ValueChanged<List<String>> onReorder;
const AccessibleReorderableList({
super.key,
required this.items,
required this.onReorder,
});
@override
State<AccessibleReorderableList> createState() =>
_AccessibleReorderableListState();
}
class _AccessibleReorderableListState extends State<AccessibleReorderableList> {
late List<String> _items;
int? _draggedFromIndex;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
void _announceReorder(
BuildContext context,
String item,
int fromIndex,
int toIndex,
) {
final l10n = AppLocalizations.of(context)!;
final direction = Directionality.of(context);
final announcement = l10n.itemMovedAnnouncement(
item,
fromIndex + 1,
toIndex + 1,
);
SemanticsService.announce(announcement, direction);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ReorderableListView.builder(
itemCount: _items.length,
onReorderStart: (index) {
_draggedFromIndex = index;
// Announce drag start
SemanticsService.announce(
l10n.dragStartedAnnouncement(_items[index]),
Directionality.of(context),
);
},
onReorderEnd: (index) {
// Announce drag end
SemanticsService.announce(
l10n.dragEndedAnnouncement,
Directionality.of(context),
);
},
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
// Announce the move
_announceReorder(context, _items[newIndex], oldIndex, newIndex);
widget.onReorder(_items);
},
itemBuilder: (context, index) {
return _AccessibleReorderableItem(
key: ValueKey(_items[index]),
item: _items[index],
index: index,
total: _items.length,
);
},
);
}
}
class _AccessibleReorderableItem extends StatelessWidget {
final String item;
final int index;
final int total;
const _AccessibleReorderableItem({
super.key,
required this.item,
required this.index,
required this.total,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: l10n.listItemPosition(item, index + 1, total),
hint: l10n.longPressToReorder,
sortKey: OrdinalSortKey(index.toDouble()),
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(item),
leading: Text(
'${index + 1}',
style: Theme.of(context).textTheme.titleMedium,
),
trailing: LocalizedDragHandle(index: index),
),
),
);
}
}
RTL Support for Reorderable Lists
Handle right-to-left layouts properly:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RTLAwareReorderableList extends StatefulWidget {
final List<String> items;
const RTLAwareReorderableList({
super.key,
required this.items,
});
@override
State<RTLAwareReorderableList> createState() =>
_RTLAwareReorderableListState();
}
class _RTLAwareReorderableListState extends State<RTLAwareReorderableList> {
late List<String> _items;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Position icon based on text direction
if (!isRTL) ...[
Icon(Icons.reorder, color: Colors.grey[600]),
const SizedBox(width: 8),
],
Expanded(
child: Text(
l10n.reorderListTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (isRTL) ...[
const SizedBox(width: 8),
Icon(Icons.reorder, color: Colors.grey[600]),
],
],
),
),
Expanded(
child: ReorderableListView.builder(
itemCount: _items.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
return _RTLAwareListItem(
key: ValueKey(_items[index]),
item: _items[index],
index: index,
isRTL: isRTL,
);
},
),
),
],
);
}
}
class _RTLAwareListItem extends StatelessWidget {
final String item;
final int index;
final bool isRTL;
const _RTLAwareListItem({
super.key,
required this.item,
required this.index,
required this.isRTL,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
// Swap leading/trailing for RTL
leading: isRTL
? _buildDragHandle(context, index, l10n)
: _buildPositionIndicator(index),
title: Text(item),
trailing: isRTL
? _buildPositionIndicator(index)
: _buildDragHandle(context, index, l10n),
),
);
}
Widget _buildPositionIndicator(int index) {
return CircleAvatar(
radius: 16,
child: Text('${index + 1}'),
);
}
Widget _buildDragHandle(
BuildContext context,
int index,
AppLocalizations l10n,
) {
return ReorderableDragStartListener(
index: index,
child: Tooltip(
message: l10n.dragHandleTooltip,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.drag_handle),
),
),
);
}
}
Reorderable List with Sections
Handle localized section headers:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SectionedReorderableList extends StatefulWidget {
const SectionedReorderableList({super.key});
@override
State<SectionedReorderableList> createState() =>
_SectionedReorderableListState();
}
class _SectionedReorderableListState extends State<SectionedReorderableList> {
final Map<String, List<String>> _sections = {
'highPriority': ['task1', 'task2'],
'mediumPriority': ['task3', 'task4'],
'lowPriority': ['task5', 'task6'],
};
String _getSectionTitle(BuildContext context, String key) {
final l10n = AppLocalizations.of(context)!;
switch (key) {
case 'highPriority':
return l10n.highPrioritySection;
case 'mediumPriority':
return l10n.mediumPrioritySection;
case 'lowPriority':
return l10n.lowPrioritySection;
default:
return key;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
children: _sections.entries.map((section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header (not reorderable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Text(
_getSectionTitle(context, section.key),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: _getSectionColor(section.key),
),
),
const Spacer(),
Text(
l10n.itemCount(section.value.length),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Reorderable items within section
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: section.value.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final items = _sections[section.key]!;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
final item = section.value[index];
return ListTile(
key: ValueKey('${section.key}_$item'),
title: Text(item),
trailing: ReorderableDragStartListener(
index: index,
child: Tooltip(
message: l10n.dragHandleTooltip,
child: const Icon(Icons.drag_handle),
),
),
);
},
),
],
);
}).toList(),
);
}
Color _getSectionColor(String key) {
switch (key) {
case 'highPriority':
return Colors.red;
case 'mediumPriority':
return Colors.orange;
case 'lowPriority':
return Colors.green;
default:
return Colors.grey;
}
}
}
Keyboard Navigation Support
Enable keyboard-based reordering with localized instructions:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class KeyboardReorderableList extends StatefulWidget {
final List<String> items;
final ValueChanged<List<String>> onReorder;
const KeyboardReorderableList({
super.key,
required this.items,
required this.onReorder,
});
@override
State<KeyboardReorderableList> createState() =>
_KeyboardReorderableListState();
}
class _KeyboardReorderableListState extends State<KeyboardReorderableList> {
late List<String> _items;
int? _focusedIndex;
bool _isReorderMode = false;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
void _moveItem(int fromIndex, int toIndex) {
if (toIndex < 0 || toIndex >= _items.length) return;
setState(() {
final item = _items.removeAt(fromIndex);
_items.insert(toIndex, item);
_focusedIndex = toIndex;
});
widget.onReorder(_items);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
// Instructions
Container(
padding: const EdgeInsets.all(12),
color: Colors.blue.shade50,
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_isReorderMode
? l10n.keyboardReorderActiveInstructions
: l10n.keyboardReorderInstructions,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final isFocused = _focusedIndex == index;
return Focus(
onFocusChange: (hasFocus) {
if (hasFocus) {
setState(() => _focusedIndex = index);
}
},
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
// Enter to toggle reorder mode
if (event.logicalKey == LogicalKeyboardKey.enter) {
setState(() => _isReorderMode = !_isReorderMode);
return KeyEventResult.handled;
}
if (_isReorderMode) {
// Arrow keys to move item
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_moveItem(index, index - 1);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_moveItem(index, index + 1);
return KeyEventResult.handled;
}
// Escape to exit reorder mode
if (event.logicalKey == LogicalKeyboardKey.escape) {
setState(() => _isReorderMode = false);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: Container(
decoration: BoxDecoration(
border: isFocused && _isReorderMode
? Border.all(color: Colors.blue, width: 2)
: null,
),
child: ListTile(
leading: Text('${index + 1}'),
title: Text(_items[index]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isReorderMode && isFocused) ...[
IconButton(
icon: const Icon(Icons.arrow_upward),
tooltip: l10n.moveUpTooltip,
onPressed: index > 0
? () => _moveItem(index, index - 1)
: null,
),
IconButton(
icon: const Icon(Icons.arrow_downward),
tooltip: l10n.moveDownTooltip,
onPressed: index < _items.length - 1
? () => _moveItem(index, index + 1)
: null,
),
],
ReorderableDragStartListener(
index: index,
child: Tooltip(
message: l10n.dragHandleTooltip,
child: const Icon(Icons.drag_handle),
),
),
],
),
),
),
);
},
),
),
],
);
}
}
ARB Translations for Reorderable Lists
Add these entries to your ARB files:
{
"myTasksTitle": "My Tasks",
"@myTasksTitle": {
"description": "Title for task list"
},
"reorderInstructions": "Hold and drag items to reorder",
"@reorderInstructions": {
"description": "Instructions for reordering items"
},
"taskReorderedMessage": "Task list reordered",
"@taskReorderedMessage": {
"description": "Confirmation after reordering"
},
"taskBuyGroceries": "Buy groceries",
"taskPayBills": "Pay bills",
"taskCallDoctor": "Call doctor",
"taskFinishReport": "Finish report",
"taskExercise": "Exercise",
"taskItemAccessibilityLabel": "{title}, position {position} of {total}",
"@taskItemAccessibilityLabel": {
"placeholders": {
"title": {"type": "String"},
"position": {"type": "int"},
"total": {"type": "int"}
}
},
"dragToReorderHint": "Double tap and hold to drag, then move to reorder",
"@dragToReorderHint": {
"description": "Accessibility hint for dragging"
},
"dragHandleTooltip": "Drag to reorder",
"@dragHandleTooltip": {
"description": "Tooltip for drag handle"
},
"dragHandleLabel": "Drag handle",
"dragHandleHint": "Double tap and hold to drag this item",
"itemMovedAnnouncement": "{item} moved from position {from} to position {to}",
"@itemMovedAnnouncement": {
"placeholders": {
"item": {"type": "String"},
"from": {"type": "int"},
"to": {"type": "int"}
}
},
"dragStartedAnnouncement": "Dragging {item}",
"@dragStartedAnnouncement": {
"placeholders": {
"item": {"type": "String"}
}
},
"dragEndedAnnouncement": "Drag ended",
"listItemPosition": "{item}, item {position} of {total}",
"@listItemPosition": {
"placeholders": {
"item": {"type": "String"},
"position": {"type": "int"},
"total": {"type": "int"}
}
},
"longPressToReorder": "Long press and drag to reorder",
"reorderListTitle": "Reorderable List",
"highPrioritySection": "High Priority",
"mediumPrioritySection": "Medium Priority",
"lowPrioritySection": "Low Priority",
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": {"type": "int"}
}
},
"keyboardReorderInstructions": "Press Enter on an item to enable reorder mode",
"keyboardReorderActiveInstructions": "Use arrow keys to move item, press Escape to exit",
"moveUpTooltip": "Move up",
"moveDownTooltip": "Move down"
}
Testing Reorderable List Localization
Write tests for your localized lists:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
group('LocalizedReorderableList', () {
testWidgets('displays localized instructions in English', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedReorderableTaskList(),
),
),
);
expect(find.text('My Tasks'), findsOneWidget);
expect(find.text('Hold and drag items to reorder'), findsOneWidget);
});
testWidgets('displays localized task names', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedReorderableTaskList(),
),
),
);
expect(find.text('Buy groceries'), findsOneWidget);
expect(find.text('Pay bills'), findsOneWidget);
});
testWidgets('shows localized confirmation on reorder', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedReorderableTaskList(),
),
),
);
// Perform drag and drop
final firstItem = find.text('Buy groceries');
final gesture = await tester.startGesture(tester.getCenter(firstItem));
await tester.pump(const Duration(milliseconds: 500));
await gesture.moveBy(const Offset(0, 100));
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Task list reordered'), findsOneWidget);
});
testWidgets('has accessible drag handles with localized tooltip',
(tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const Scaffold(
body: LocalizedReorderableTaskList(),
),
),
);
final dragHandle = find.byIcon(Icons.drag_handle).first;
expect(dragHandle, findsOneWidget);
// Long press to show tooltip
await tester.longPress(dragHandle);
await tester.pumpAndSettle();
expect(find.text('Drag to reorder'), findsOneWidget);
});
});
}
Summary
Localizing ReorderableListView in Flutter requires:
- Localized instructions for how to drag and reorder items
- Accessible drag handles with translated tooltips and labels
- Screen reader announcements for position changes
- RTL support for proper handle placement
- Keyboard navigation with localized instructions
- Confirmation messages after successful reordering
- Section headers for grouped lists
Proper localization of reorderable lists ensures all users, regardless of language or ability, can effectively organize content in your app.