Skip to main content

Command Palette

Search for a command to run...

Implementing Drag and Drop in Flutter with super_drag_and_drop

Updated
8 min read
E

I’m Emmanuel David Tuksa, a Software Engineer passionate about building impactful digital experiences and solving real-world problems through technology. With a strong foundation in software engineering and a focus on mobile and web solutions, I’m driven to craft user-centric products that scale and delight. I continuously seek opportunities to learn, contribute to open-source, and push the boundaries of what technology can achieve. Whether it’s leading teams, optimizing workflows, or building something from scratch, my goal is simple: create meaningful software that empowers users and teams alike. Let’s build something great together.

Drag and drop is a fundamental interaction pattern in modern applications, allowing users to intuitively move content between different areas of your app or even between applications. While Flutter provides basic drag and drop capabilities, the super_drag_and_drop package offers a more robust, cross-platform solution that handles the complexities of different operating systems and web browsers.

Why super_drag_and_drop?

The super_drag_and_drop package provides several advantages over Flutter's built-in drag and drop widgets:

  • Cross-platform consistency: Works seamlessly across iOS, Android, macOS, Windows, Linux, and web

  • Native integration: Supports dragging files from outside your app (file managers, other applications)

  • Rich format support: Handles various data formats including files, images, text, and custom formats

  • Modern API: Clean, intuitive API that follows Flutter's widget patterns

Getting Started

First, add the package to your pubspec.yaml:

dependencies:
  super_drag_and_drop: ^0.9.1
  cross_file: ^0.3.4+2
  mime: ^2.0.0

Core Concepts

DropRegion

The DropRegion widget is the foundation of the drop functionality. It wraps any widget and makes it capable of receiving dropped content:

DropRegion(
  formats: [Formats.fileUri, Formats.png, Formats.jpeg],
  onDropOver: (event) => DropOperation.copy,
  onPerformDrop: (event) async {
    // Handle the dropped content
  },
  child: YourWidget(),
)

Formats

The package provides built-in format definitions for common types:

  • Formats.fileUri - File URIs from the file system

  • Formats.png, Formats.jpeg - Image formats

  • Formats.plainText - Text content

  • And other formats like Formats.epub, Formats.md

Drop Events

The package provides several callbacks to handle different stages of the drop operation:

  • onDropOver: Called when dragged content hovers over the region

  • onPerformDrop: Called when content is actually dropped

  • onDropEnter: Called when drag enters the region

  • onDropExit: Called when drag leaves the region

Practical Implementation: File Drop Handler

Let's build a practical drag and drop handler for file attachments, similar to what you might find in a chat application:

class DragAndDropHandler {
  const DragAndDropHandler({
    required this.onAttachments,
    this.onDragEnter,
    this.onDragExit,
  });

  final void Function(List<FileData> attachments) onAttachments;
  final VoidCallback? onDragEnter;
  final VoidCallback? onDragExit;

  Widget buildDropRegion({required Widget child}) {
    return DropRegion(
      formats: [
        Formats.fileUri,
        Formats.png,
        Formats.jpeg,
        Formats.pdf,
      ],
      hitTestBehavior: HitTestBehavior.deferToChild,
      onDropOver: (event) => DropOperation.copy,
      onPerformDrop: (event) async {
        await _handleDrop(event);
      },
      onDropEnter: (_) => onDragEnter?.call(),
      onDropLeave: (_) => onDragExit?.call(),
      child: child,
    );
  }

  Future<void> _handleDrop(PerformDropEvent event) async {
    final items = event.session.items;

    for (final item in items) {
      if (item.dataReader != null) {
        // Handle file URI (desktop platforms)
        item.dataReader!.getValue(Formats.fileUri, (uri) async {
          if (uri != null) {
            final file = await _processFile(uri);
            if (file != null) {
              onAttachments([file]);
            }
          }
        });

        // Handle direct file data (web platform)
        if (item.dataReader!.canProvide(Formats.png)) {
          item.dataReader!.getFile(Formats.png, (file) async {
            await _processWebFile(file);
          });
        }
      }
    }
  }

  Future<FileData?> _processFile(Uri uri) async {
    try {
      final path = uri.toFilePath();
      final file = XFile(path);
      final bytes = await file.readAsBytes();
      final mimeType = lookupMimeType(path, headerBytes: bytes);

      return FileData(
        bytes: bytes,
        name: file.name,
        mimeType: mimeType ?? 'application/octet-stream',
      );
    } catch (e) {
      debugPrint('Error processing file: $e');
      return null;
    }
  }

  Future<void> _processWebFile(DataReaderFile file) async {
    final stream = file.getStream();
    final chunks = await stream.toList();
    final bytes = Uint8List.fromList(
      chunks.expand((e) => e).toList(),
    );

    final mimeType = lookupMimeType(
      file.fileName ?? '',
      headerBytes: bytes,
    ) ?? 'application/octet-stream';

    final fileData = FileData(
      bytes: bytes,
      name: file.fileName ?? 'dropped_file',
      mimeType: mimeType,
    );

    onAttachments([fileData]);
  }
}

class FileData {
  final Uint8List bytes;
  final String name;
  final String mimeType;

  FileData({
    required this.bytes,
    required this.name,
    required this.mimeType,
  });
}

Platform-Specific Considerations

Web vs Desktop

The package handles platform differences internally, but you need to be aware of how files are accessed:

Desktop platforms (Windows, macOS, Linux):

  • Use Formats.fileUri to get file paths

  • Access files directly from the file system

  • More straightforward file handling

Web platform:

  • Cannot access file paths directly due to security restrictions

  • Must use getFile() method and stream the content

  • Process files as byte streams

if (!UniversalPlatform.isWeb) {
  // Desktop: Use file URI
  item.dataReader!.getValue(Formats.fileUri, (uri) async {
    final path = uri.toFilePath();
    // Process file from path
  });
} else {
  // Web: Stream file content
  item.dataReader!.getFile(format, (file) async {
    final stream = file.getStream();
    final bytes = await stream
      .expand((chunk) => chunk)
      .toList();
    // Process bytes
  });
}

Visual Feedback

Visual feedback is crucial for creating an intuitive drag and drop experience. Users need clear indicators that show when content can be dropped, when they’re hovering over a valid drop zone, and when the drop operation completes successfully or fails.

The simplest form of feedbck is changing the appearance of your drop zone when users drag content over it. This can be achieved using the onDropEnter and onDropExit callbacks:

class DropZone extends StatefulWidget {
  @override
  State<DropZone> createState() => _DropZoneState();
}

class _DropZoneState extends State<DropZone> {
  bool _isDragging = false;

  @override
  Widget build(BuildContext context) {
    return DragAndDropHandler(
      onDragEnter: () => setState(() => _isDragging = true),
      onDragExit: () => setState(() => _isDragging = false),
      onAttachments: (files) {
        setState(() => _isDragging = false);
        // Handle files
      },
    ).buildDropRegion(
      child: AnimatedContainer(
        duration: Duration(milliseconds: 200),
        decoration: BoxDecoration(
          color: _isDragging 
            ? Colors.blue.withOpacity(0.1)
            : Colors.transparent,
          border: Border.all(
            color: _isDragging 
              ? Colors.blue 
              : Colors.grey,
            width: _isDragging ? 2 : 1,
          ),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Center(
          child: Text(
            _isDragging 
              ? 'Drop files here' 
              : 'Drag files here',
          ),
        ),
      ),
    );
  }
}

MIME Type Detection

Accurate MIME type detection is essential for properly handling different file types in your application. The MIME type determines how files should be processed, displayed, and stored. Flutter provides the mime packages for this purpose, but you’ll often need additional logic to handle edge cases.

Why MIME Types Matter

MIME types serve several critical purposes:

  • File validation: Ensures users can only upload supported file types

  • Icon selection: Display appropriate icons for different file types

  • Processing logic: Route files to the correct handlers (images to image processors, PDFs to document viewers, etc.)

  • Security: Prevent execution of potentially dangerous file types

  • API communication: Many backend APIs require accurate MIME types for file uploads

Basic MIME Type Detection

The mime package's lookupMimeType function uses both file extensions and file content (magic numbers) to determine the MIME type:

import 'package:mime/mime.dart';

String getMimeType(String filePath, Uint8List bytes) {
  // First attempt: use both filename and content
  final mimeType = lookupMimeType(
    filePath,
    headerBytes: bytes,
  );

  return mimeType ?? 'application/octet-stream';
}

Handling Edge Cases

Many files don't have standard extensions, or their content doesn't match typical patterns. You need custom logic for these cases:

String detectMimeType(String path, Uint8List bytes) {
  // Try to detect from file extension and content
  String? mimeType = lookupMimeType(path, headerBytes: bytes);

  // Fallback for markdown files
  if (mimeType == null || mimeType == 'application/octet-stream') {
    final extension = path.split('.').last.toLowerCase();
    if (extension == 'md' || extension == 'markdown') {
      return 'text/markdown';
    }
  }

  return mimeType ?? 'application/octet-stream';
}

Error Handling

Robust error handling is critical for drag and drop operations because they involve file system access, network operations, and user interactions that can fail in numerous ways. A well-designed error handling strategy improves user experience and makes debugging easier.

Common Error Scenarios

File operations can fail for many reasons:

  • Permission errors: User lacks permission to read the file

  • File not found: File was deleted or moved during the operation

  • Network errors: File is on a network drive that became unavailable

  • Memory errors: File is too large to load into memory

  • Format errors: File content doesn't match its extension

  • Encoding errors: File uses an unsupported character encoding

  • Concurrent access: Another process is using the file

  • Corruption: File data is corrupted or incomplete

Always wrap file operations in try-catch blocks:

Future<void> handleDrop(PerformDropEvent event) async {
  try {
    final items = event.session.items;

    for (final item in items) {
      if (item.dataReader != null) {
        await processItem(item);
      }
    }
  } catch (e, stackTrace) {
    debugPrint('Error handling drop: $e');
    debugPrint('Stack trace: $stackTrace');
    // Show error to user
  }
}

Testing

The package allows you to expose drop handling logic for testing:

class DragAndDropHandler {
  @visibleForTesting
  Future<FileData?> handleDroppedFile(Uri data) => _handleDroppedFile(data);

  Future<FileData?> _handleDroppedFile(Uri data) async {
    // Implementation
  }
}

// In tests
test('handles dropped file correctly', () async {
  final handler = DragAndDropHandler(
    onAttachments: (files) => receivedFiles = files,
  );

  final uri = Uri.file('/path/to/test.png');
  final result = await handler.handleDroppedFile(uri);

  expect(result, isNotNull);
  expect(result!.mimeType, 'image/png');
});

Complete Example

Here's a complete example of a file upload area with drag and drop:

class FileUploadArea extends StatefulWidget {
  final Function(List<FileData>) onFilesAdded;

  const FileUploadArea({required this.onFilesAdded});

  @override
  State<FileUploadArea> createState() => _FileUploadAreaState();
}

class _FileUploadAreaState extends State<FileUploadArea> {
  bool _isDragging = false;
  final List<FileData> _files = [];

  @override
  Widget build(BuildContext context) {
    final handler = DragAndDropHandler(
      onAttachments: (files) {
        setState(() {
          _files.addAll(files);
          _isDragging = false;
        });
        widget.onFilesAdded(files);
      },
      onDragEnter: () => setState(() => _isDragging = true),
      onDragExit: () => setState(() => _isDragging = false),
    );

    return handler.buildDropRegion(
      child: Container(
        padding: EdgeInsets.all(32),
        decoration: BoxDecoration(
          border: Border.all(
            color: _isDragging ? Colors.blue : Colors.grey,
            width: 2,
            style: BorderStyle.solid,
          ),
          borderRadius: BorderRadius.circular(12),
          color: _isDragging 
            ? Colors.blue.withOpacity(0.05)
            : null,
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              Icons.cloud_upload,
              size: 64,
              color: _isDragging ? Colors.blue : Colors.grey,
            ),
            SizedBox(height: 16),
            Text(
              _isDragging 
                ? 'Drop files here'
                : 'Drag and drop files here',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.w500,
              ),
            ),
            if (_files.isNotEmpty) ...[
              SizedBox(height: 24),
              ..._files.map((file) => ListTile(
                leading: Icon(Icons.insert_drive_file),
                title: Text(file.name),
                subtitle: Text(file.mimeType),
              )),
            ],
          ],
        ),
      ),
    );
  }
}

Best Practices

  1. Support multiple formats: Include all relevant formats your app can handle

  2. Provide clear visual feedback: Let users know when they're dragging over a valid drop zone

  3. Handle errors gracefully: File operations can fail for many reasons

  4. Consider platform differences: Web and desktop handle files differently

  5. Test thoroughly: Test with various file types and sizes

  6. Optimize for performance: Don't block the UI while processing large files

  7. Validate file types: Check MIME types and extensions before processing

  8. Set size limits: Prevent users from dropping excessively large files

Conclusion

The super_drag_and_drop package provides a robust, cross-platform solution for implementing drag and drop in Flutter applications. By understanding the platform differences and following best practices, you can create intuitive file handling experiences that work seamlessly across all platforms. The key is to handle both the desktop file URI approach and the web streaming approach, provide clear visual feedback, and always handle errors gracefully.

More from this blog

Emmanuel David Tuksa

5 posts

I'm Emmanuel Tuksa, a software engineer sharing lessons from building user-focused apps. I write about what I learn, problems I solve, and tools that help create better products. Tips here. Stay tuned