The Problem
Implementing native drag-and-drop functionality in a Flutter macOS application that works seamlessly with scrolling and file selection proved to be one of the most challenging features we’ve built. The goal was simple: allow users to drag files into and out of CodeFrog, supporting both local and network (SSH) projects, while maintaining smooth scrolling and file selection.
Initial Attempt: super_drag_and_drop
We started with the super_drag_and_drop package, which provides cross-platform drag-and-drop support. While it worked for basic scenarios, we encountered several issues:
- Unreliable drag-out for remote files – Files on network/SSH connections needed to be downloaded before dragging, and the async nature of this operation caused frequent failures
- Gesture conflicts – The package’s gesture recognizers conflicted with Flutter’s built-in scrolling and selection mechanisms
- Limited control – We needed more fine-grained control over the drag-and-drop behavior, especially for handling remote file downloads
After many hours of debugging and attempting workarounds (pre-downloading files, using virtual files, adjusting gesture recognizers, etc.), we decided to build a custom solution using native macOS APIs.
The Custom Solution: flutter_macos_drag
We built a custom Flutter plugin (flutter_macos_drag) that uses native macOS NSDraggingSource and NSDraggingDestination protocols directly. This gave us complete control over the drag-and-drop behavior.
Key Components
- MacOSDraggable – A Flutter widget for dragging files out of the app
- MacOSDroppable – A Flutter widget for accepting file drops from Finder
- DraggableNSView – Native Swift view implementing
NSDraggingSourceandNSDraggingDestination
The Challenge: Scrolling vs Drag-and-Drop
The biggest challenge was making drag-and-drop work while preserving scrolling functionality. The native AppKitView needed to be in the widget hierarchy to receive drag events, but it was blocking pointer events needed for scrolling.
Failed Approaches:
- Using
IgnorePointer– Blocked drag events - Using
AbsorbPointer– No effect - Using
ListenerwithHitTestBehavior– Still blocked events - Reversing Stack order – Drag events didn’t reach the native view
The Solution: hitTest Override
The breakthrough came from understanding how macOS handles drag events vs pointer events:
- Drag events work at the
NSViewlevel and query all registered views directly, completely bypassing Flutter’s pointer system and hit testing - Pointer events (scrolling, clicking) go through normal hit testing
By overriding hitTest in the native view to return nil for drop zones, we allow pointer events to pass through to Flutter widgets below, while drag events still work because they query registered views directly.
override func hitTest(_ point: NSPoint) -> NSView? {
// For drop zones, return nil to let pointer events pass through to Flutter
// Drag events don't use hitTest - they query all registered views directly
if acceptDrops && filePath == nil {
return nil
}
return super.hitTest(point)
}
Additionally, we made mouse event handlers return early for drop zones:
override func mouseDown(with event: NSEvent) {
// For drop zones, don't handle mouse events - let them pass through for scrolling
if acceptDrops && filePath == nil {
return // Don't call super - allows events to pass through
}
// ... handle drag-out logic
}
Widget Structure
The final widget structure uses a Stack with the native view on top:
Stack(
children: [
// Native view on top - configured to not block pointer events
Positioned.fill(
child: Opacity(
opacity: 0.01,
child: AppKitView(...),
),
),
// Flutter widgets below - receive pointer events for scrolling
widget.child,
],
)
Features Achieved
✅ Drag files out – Works for both local and network files
✅ Drag files in – Accepts drops from Finder into any directory
✅ Scrolling – File tree pane scrolls smoothly
✅ File selection – Click to select files works normally
✅ Remote file handling – Downloads remote files on-demand during drag
✅ Root directory support – Can drop files at project root (empty path)
Key Technical Insights
- macOS drag events bypass Flutter’s pointer system – They query all registered views directly, so
IgnorePointerand similar widgets don’t affect them - hitTest controls pointer events, not drag events – Returning
nilfromhitTestallows pointer events to pass through while drag events still work - View registration is separate from hit testing – Views registered with
registerForDraggedTypesreceive drag events regardless ofhitTestresults - Mouse event handlers must return early – For drop zones, don’t call
superin mouse event handlers to allow events to pass through
Lessons Learned
- Sometimes a custom native solution is necessary when cross-platform packages don’t meet specific requirements
- Understanding the underlying platform APIs (NSDraggingSource/NSDraggingDestination) is crucial
- The interaction between Flutter’s pointer system and native platform views requires careful consideration
- Persistence pays off – this took many hours but resulted in a robust, maintainable solution
This solution was developed over many hours of debugging and research. The key was understanding that macOS drag events operate at a different level than pointer events, allowing us to let pointer events pass through while still receiving drag events.