Solving Drag-and-Drop in Flutter macOS: A Journey

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:

  1. 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
  2. Gesture conflicts – The package’s gesture recognizers conflicted with Flutter’s built-in scrolling and selection mechanisms
  3. 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

  1. MacOSDraggable – A Flutter widget for dragging files out of the app
  2. MacOSDroppable – A Flutter widget for accepting file drops from Finder
  3. DraggableNSView – Native Swift view implementing NSDraggingSource and NSDraggingDestination

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 Listener with HitTestBehavior – 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:

  1. Drag events work at the NSView level and query all registered views directly, completely bypassing Flutter’s pointer system and hit testing
  2. 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

  1. macOS drag events bypass Flutter’s pointer system – They query all registered views directly, so IgnorePointer and similar widgets don’t affect them
  2. hitTest controls pointer events, not drag events – Returning nil from hitTest allows pointer events to pass through while drag events still work
  3. View registration is separate from hit testing – Views registered with registerForDraggedTypes receive drag events regardless of hitTest results
  4. Mouse event handlers must return early – For drop zones, don’t call super in 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.

Leave a Comment