← Back to Blog

Flutter GestureDetector Localization: Touch Interactions for Multilingual Apps

fluttergesturedetectortouchgestureslocalizationrtl

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

  1. Adapt swipe directions for RTL using Directionality
  2. Provide visual feedback for gesture interactions
  3. Use haptic feedback for long press actions
  4. Test gestures on actual devices in different locales
  5. Combine multiple gestures for rich interactions

Don'ts

  1. Don't assume swipe direction - always check text direction
  2. Don't rely only on gestures - provide alternative actions
  3. Don't forget accessibility - support screen readers
  4. 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.

Further Reading