Flutter GestureDetector Localization: Touch Interactions for Multilingual Apps
GestureDetector is a Flutter widget that detects gestures like taps, drags, and scales. In multilingual applications, GestureDetector enables touch-based interactions that work intuitively across all languages, providing direction-aware gestures for RTL layouts.
Understanding GestureDetector in Localization Context
GestureDetector wraps widgets to detect various touch gestures and trigger callbacks. For multilingual apps, this enables:
- Direction-aware swipe gestures for RTL languages
- Universal touch interactions that don't rely on text
- Consistent gesture behavior across all locales
- Accessible touch targets for different scripts
Why GestureDetector Matters for Multilingual Apps
GestureDetector provides:
- Universal interactions: Gestures work without translation
- Directional awareness: Swipes can adapt to RTL/LTR
- Consistent feedback: Same gesture behavior everywhere
- Flexible touch targets: Accommodate varying text sizes
Basic GestureDetector Implementation
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedGestureExample extends StatefulWidget {
const LocalizedGestureExample({super.key});
@override
State<LocalizedGestureExample> createState() =>
_LocalizedGestureExampleState();
}
class _LocalizedGestureExampleState extends State<LocalizedGestureExample> {
String _lastGesture = '';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () => setState(() => _lastGesture = l10n.tapDetected),
onDoubleTap: () =>
setState(() => _lastGesture = l10n.doubleTapDetected),
onLongPress: () =>
setState(() => _lastGesture = l10n.longPressDetected),
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
l10n.gestureAreaHint,
textAlign: TextAlign.center,
),
),
),
),
const SizedBox(height: 24),
Text(
_lastGesture.isEmpty ? l10n.noGestureYet : _lastGesture,
style: Theme.of(context).textTheme.titleMedium,
),
],
);
}
}
Direction-Aware Swipe Gestures
RTL-Aware Horizontal Swipe
class DirectionalSwipeDetector extends StatelessWidget {
final Widget child;
final VoidCallback? onSwipeForward;
final VoidCallback? onSwipeBack;
const DirectionalSwipeDetector({
super.key,
required this.child,
this.onSwipeForward,
this.onSwipeBack,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity == null) return;
final isSwipeRight = details.primaryVelocity! > 0;
final isSwipeLeft = details.primaryVelocity! < 0;
if (isRtl) {
if (isSwipeLeft) {
onSwipeForward?.call();
} else if (isSwipeRight) {
onSwipeBack?.call();
}
} else {
if (isSwipeRight) {
onSwipeBack?.call();
} else if (isSwipeLeft) {
onSwipeForward?.call();
}
}
},
child: child,
);
}
}
class LocalizedSwipeableCard extends StatefulWidget {
const LocalizedSwipeableCard({super.key});
@override
State<LocalizedSwipeableCard> createState() => _LocalizedSwipeableCardState();
}
class _LocalizedSwipeableCardState extends State<LocalizedSwipeableCard> {
int _currentIndex = 0;
final List<String> _items = ['Item 1', 'Item 2', 'Item 3'];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return DirectionalSwipeDetector(
onSwipeForward: () {
if (_currentIndex < _items.length - 1) {
setState(() => _currentIndex++);
}
},
onSwipeBack: () {
if (_currentIndex > 0) {
setState(() => _currentIndex--);
}
},
child: Card(
child: Container(
width: double.infinity,
height: 200,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_items[_currentIndex],
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
l10n.swipeHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_items.length,
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == _currentIndex
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
),
),
),
],
),
),
),
);
}
}
Swipe to Dismiss with Direction Support
class LocalizedSwipeToDismiss extends StatefulWidget {
final Widget child;
final VoidCallback onDismissed;
final String dismissLabel;
const LocalizedSwipeToDismiss({
super.key,
required this.child,
required this.onDismissed,
required this.dismissLabel,
});
@override
State<LocalizedSwipeToDismiss> createState() =>
_LocalizedSwipeToDismissState();
}
class _LocalizedSwipeToDismissState extends State<LocalizedSwipeToDismiss> {
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
final dismissThreshold = MediaQuery.of(context).size.width * 0.3;
return GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() {
_dragOffset += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
if (_dragOffset.abs() > dismissThreshold) {
widget.onDismissed();
}
setState(() => _dragOffset = 0);
},
child: Stack(
children: [
Positioned.fill(
child: Container(
alignment: _dragOffset > 0
? AlignmentDirectional.centerStart
: AlignmentDirectional.centerEnd,
padding: const EdgeInsets.symmetric(horizontal: 24),
color: Colors.red,
child: Text(
widget.dismissLabel,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
Transform.translate(
offset: Offset(_dragOffset, 0),
child: widget.child,
),
],
),
);
}
}
class LocalizedDismissibleList extends StatefulWidget {
const LocalizedDismissibleList({super.key});
@override
State<LocalizedDismissibleList> createState() =>
_LocalizedDismissibleListState();
}
class _LocalizedDismissibleListState extends State<LocalizedDismissibleList> {
final List<String> _items = List.generate(5, (i) => 'Item ${i + 1}');
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return LocalizedSwipeToDismiss(
dismissLabel: l10n.deleteLabel,
onDismissed: () {
setState(() => _items.removeAt(index));
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text(_items[index]),
subtitle: Text(l10n.swipeToDelete),
),
),
);
},
);
}
}
Interactive List Items
Tap and Long Press Actions
class LocalizedInteractiveListItem extends StatelessWidget {
final String title;
final String subtitle;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const LocalizedInteractiveListItem({
super.key,
required this.title,
required this.subtitle,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onLongPress: () {
HapticFeedback.mediumImpact();
onLongPress?.call();
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
);
}
}
class LocalizedContactList extends StatelessWidget {
const LocalizedContactList({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
children: [
LocalizedInteractiveListItem(
title: l10n.contactName1,
subtitle: l10n.contactEmail1,
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.contactTapped)),
);
},
onLongPress: () {
showModalBottomSheet(
context: context,
builder: (context) => LocalizedContactActions(l10n: l10n),
);
},
),
LocalizedInteractiveListItem(
title: l10n.contactName2,
subtitle: l10n.contactEmail2,
onTap: () {},
onLongPress: () {},
),
],
);
}
}
class LocalizedContactActions extends StatelessWidget {
final AppLocalizations l10n;
const LocalizedContactActions({super.key, required this.l10n});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text(l10n.editContact),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.delete),
title: Text(l10n.deleteContact),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.share),
title: Text(l10n.shareContact),
onTap: () => Navigator.pop(context),
),
],
);
}
}
Custom Button with Gestures
Localized Gesture Button
class LocalizedGestureButton extends StatefulWidget {
final String label;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
const LocalizedGestureButton({
super.key,
required this.label,
this.onPressed,
this.onLongPress,
});
@override
State<LocalizedGestureButton> createState() => _LocalizedGestureButtonState();
}
class _LocalizedGestureButtonState extends State<LocalizedGestureButton> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
onTap: widget.onPressed,
onLongPress: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: _isPressed
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
boxShadow: _isPressed
? []
: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
transform: _isPressed
? Matrix4.translationValues(0, 2, 0)
: Matrix4.identity(),
child: Text(
widget.label,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
class LocalizedButtonDemo extends StatelessWidget {
const LocalizedButtonDemo({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: LocalizedGestureButton(
label: l10n.pressAndHold,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.buttonPressed)),
);
},
onLongPress: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.buttonLongPressed)),
);
},
),
);
}
}
Scale and Pan Gestures
Zoomable Image with Localized Controls
class LocalizedZoomableImage extends StatefulWidget {
final ImageProvider image;
const LocalizedZoomableImage({
super.key,
required this.image,
});
@override
State<LocalizedZoomableImage> createState() => _LocalizedZoomableImageState();
}
class _LocalizedZoomableImageState extends State<LocalizedZoomableImage> {
double _scale = 1.0;
double _previousScale = 1.0;
Offset _offset = Offset.zero;
Offset _previousOffset = Offset.zero;
void _resetZoom() {
setState(() {
_scale = 1.0;
_offset = Offset.zero;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Expanded(
child: GestureDetector(
onScaleStart: (details) {
_previousScale = _scale;
_previousOffset = details.focalPoint - _offset;
},
onScaleUpdate: (details) {
setState(() {
_scale = (_previousScale * details.scale).clamp(0.5, 4.0);
_offset = details.focalPoint - _previousOffset;
});
},
onDoubleTap: _resetZoom,
child: ClipRect(
child: Transform(
transform: Matrix4.identity()
..translate(_offset.dx, _offset.dy)
..scale(_scale),
alignment: Alignment.center,
child: Image(
image: widget.image,
fit: BoxFit.contain,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.zoomLevel(_scale.toStringAsFixed(1)),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 16),
TextButton(
onPressed: _resetZoom,
child: Text(l10n.resetZoom),
),
],
),
),
],
);
}
}
ARB File Structure
English (app_en.arb)
{
"@@locale": "en",
"gestureAreaHint": "Tap, double tap, or long press",
"tapDetected": "Tap detected!",
"doubleTapDetected": "Double tap detected!",
"longPressDetected": "Long press detected!",
"noGestureYet": "Try a gesture",
"swipeHint": "Swipe left or right to navigate",
"swipeToDelete": "Swipe to delete",
"deleteLabel": "Delete",
"contactName1": "John Smith",
"contactEmail1": "john@example.com",
"contactName2": "Jane Doe",
"contactEmail2": "jane@example.com",
"contactTapped": "Contact selected",
"editContact": "Edit",
"deleteContact": "Delete",
"shareContact": "Share",
"pressAndHold": "Press or hold",
"buttonPressed": "Button pressed!",
"buttonLongPressed": "Button long pressed!",
"zoomLevel": "Zoom: {level}x",
"@zoomLevel": {
"placeholders": {
"level": {"type": "String"}
}
},
"resetZoom": "Reset"
}
German (app_de.arb)
{
"@@locale": "de",
"gestureAreaHint": "Tippen, doppelt tippen oder lange drücken",
"tapDetected": "Tippen erkannt!",
"doubleTapDetected": "Doppeltippen erkannt!",
"longPressDetected": "Langes Drücken erkannt!",
"noGestureYet": "Probieren Sie eine Geste",
"swipeHint": "Zum Navigieren nach links oder rechts wischen",
"swipeToDelete": "Zum Löschen wischen",
"deleteLabel": "Löschen",
"contactName1": "Max Mustermann",
"contactEmail1": "max@example.com",
"contactName2": "Erika Musterfrau",
"contactEmail2": "erika@example.com",
"contactTapped": "Kontakt ausgewählt",
"editContact": "Bearbeiten",
"deleteContact": "Löschen",
"shareContact": "Teilen",
"pressAndHold": "Drücken oder halten",
"buttonPressed": "Taste gedrückt!",
"buttonLongPressed": "Taste lange gedrückt!",
"zoomLevel": "Zoom: {level}x",
"resetZoom": "Zurücksetzen"
}
Arabic (app_ar.arb)
{
"@@locale": "ar",
"gestureAreaHint": "انقر أو انقر مرتين أو اضغط مطولاً",
"tapDetected": "تم اكتشاف النقر!",
"doubleTapDetected": "تم اكتشاف النقر المزدوج!",
"longPressDetected": "تم اكتشاف الضغط المطول!",
"noGestureYet": "جرب إيماءة",
"swipeHint": "مرر لليسار أو اليمين للتنقل",
"swipeToDelete": "مرر للحذف",
"deleteLabel": "حذف",
"contactName1": "أحمد محمد",
"contactEmail1": "ahmed@example.com",
"contactName2": "سارة علي",
"contactEmail2": "sara@example.com",
"contactTapped": "تم تحديد جهة الاتصال",
"editContact": "تعديل",
"deleteContact": "حذف",
"shareContact": "مشاركة",
"pressAndHold": "اضغط أو اضغط مطولاً",
"buttonPressed": "تم الضغط على الزر!",
"buttonLongPressed": "تم الضغط المطول على الزر!",
"zoomLevel": "التكبير: {level}×",
"resetZoom": "إعادة تعيين"
}
Best Practices Summary
Do's
- Adapt swipe directions for RTL using Directionality
- Provide visual feedback for gesture interactions
- Use haptic feedback for long press actions
- Test gestures on actual devices in different locales
- Combine multiple gestures for rich interactions
Don'ts
- Don't assume swipe direction - always check text direction
- Don't rely only on gestures - provide alternative actions
- Don't forget accessibility - support screen readers
- Don't make touch targets too small for different scripts
Conclusion
GestureDetector is fundamental for creating touch-based interactions in multilingual Flutter applications. By adapting gesture directions for RTL languages and providing consistent feedback, you create intuitive interfaces that work naturally in any locale. Use GestureDetector to build swipeable cards, interactive lists, and custom buttons that enhance the user experience across all languages.