Drag & Drop with SwiftUI

SwiftUI incorporates some methods and protocols to easily perform Drag and Drop operations. Until iOS 13.4, most of these methods were macOS exclusive. Fortunately, that changed with the latest iOS13.4 beta release.

Drag and Drop with SwiftUI is very simple. However, if you are completely new to drag and drop on macOS/iOS, the challenge will be to understand NSItemProvider. This is the class that carries the information of the dragged data, and it is a class that has been around for many years. If the examples in this post are not sufficient, surf away, and you will find that the Internet is full of resources. They may not be SwiftUI related, but the NSItemProvider part is the same.

Along the way we will also discuss some annoying bugs that still affect Drag and Drop with SwiftUI. We will also look into List views and some special considerations.

The Drag

A drag and drop operation, as you can imagine, consists of two parts: the drag, and the drop. For the Drag, SwiftUI has a single method called onDrag(). By adding this modifier to a view, you are doing two important things: you are telling SwiftUI that the view is draggable, and you also set the data that will be attached to the drag operation (in the form of NSItemProvider objects).

func onDrag(_ data: @escaping () -> NSItemProvider) -> some View

The Drop

For the drop part, SwiftUI has a modifier called onDrop(). With this method, we tell SwiftUI if a view accepts drops, what types of items can be dropped, plus some other useful information. Depending on what you need, there are three versions of the modifier you can choose:


// The simplest form, let you specify a closure to execute when a view is dropped.
func onDrop(of supportedTypes: [String], isTargeted: Binding<Bool>?, perform action: @escaping ([NSItemProvider]) -> Bool) -> some View

// Similar to the first version, but also provides drop location information
func onDrop(of supportedTypes: [String], isTargeted: Binding<Bool>?, perform action: @escaping ([NSItemProvider], CGPoint) -> Bool) -> some View

// The most versatile, will let you provide a DropDelegate to handle all the operation
func onDrop(of supportedTypes: [String], delegate: DropDelegate) -> some View

In this article, we are going to use the most versatile option, the one with DropDelegate. The other two onDrop methods are pretty much straight forward. Very soon I will be releasing some examples in the Companion for SwiftUI app, but if you understand the example below, understanding the other two are a simple trivial exercise.

The video below shows the example in action. As you can appreciate, there is a bug with macOS. When dragging a view, its “preview” image is corrupt or even completely missing. So far I have been unable to workaround that problem. Note that this problem does not affect iOS, where the dragged image is shown properly.

Drag & Drop (macOS)
Drag & Drop (iOS)

The code below works on macOS. But with very few minor modifications, the same code will work on iOS. The required modifications are related to the fact that macOS is using NSImage, while iOS will need UIImage.

Also note that there is, however, another bug. This one only affects iOS. Although DropInfo should provide the drop location in local coordinates, at the moment, it is doing it in global coordinates. Since drag and drop support on iOS has just been released (and as beta), it is to be expected… but if you want to make sure it gets sorted soon, I encourage you to file a bug report! The more, the merrier.

import SwiftUI

struct ContentView: View {
    let img1url = Bundle.main.url(forResource: "Images/grapes", withExtension: "png")
    let img2url = Bundle.main.url(forResource: "Images/banana", withExtension: "png")
    let img3url = Bundle.main.url(forResource: "Images/peach", withExtension: "png")
    let img4url = Bundle.main.url(forResource: "Images/kiwi", withExtension: "png")
    
    var body: some View {
        HStack {
            VStack {
                DragableImage(url: img1url!)
                
                DragableImage(url: img3url!)
            }
            
            VStack {
                DragableImage(url: img2url!)
                
                DragableImage(url: img4url!)
            }
            
            DroppableArea()
        }.padding(40)
    }
    
    struct DragableImage: View {
        let url: URL
        
        var body: some View {
            Image(nsImage: NSImage(byReferencing: url))
                .resizable()
                .frame(width: 150, height: 150)
                .clipShape(Circle())
                .overlay(Circle().stroke(Color.white, lineWidth: 2))
                .padding(2)
                .overlay(Circle().strokeBorder(Color.black.opacity(0.1)))
                .shadow(radius: 3)
                .padding(4)
                .onDrag { return NSItemProvider(object: self.url as NSURL) }
        }
    }
    
    struct DroppableArea: View {
        @State private var imageUrls: [Int: URL] = [:]
        @State private var active = 0
        
        var body: some View {
            let dropDelegate = MyDropDelegate(imageUrls: $imageUrls, active: $active)
            
            return VStack {
                HStack {
                    GridCell(active: self.active == 1, url: imageUrls[1])
                    
                    GridCell(active: self.active == 3, url: imageUrls[3])
                }
                
                HStack {
                    GridCell(active: self.active == 2, url: imageUrls[2])

                    GridCell(active: self.active == 4, url: imageUrls[4])
                }
                
            }
            .background(Rectangle().fill(Color.gray))
            .frame(width: 300, height: 300)
            .onDrop(of: ["public.file-url"], delegate: dropDelegate)
            
        }
    }
    
    struct GridCell: View {
        let active: Bool
        let url: URL?
        
        var body: some View {
            let img = Image(nsImage: url != nil ? NSImage(byReferencing: url!) : NSImage())
                .resizable()
                .frame(width: 150, height: 150)
            
            return Rectangle()
                .fill(self.active ? Color.green : Color.clear)
                .frame(width: 150, height: 150)
                .overlay(img)
        }
    }
    
    struct MyDropDelegate: DropDelegate {
        @Binding var imageUrls: [Int: URL]
        @Binding var active: Int
        
        func validateDrop(info: DropInfo) -> Bool {
            return info.hasItemsConforming(to: ["public.file-url"])
        }
        
        func dropEntered(info: DropInfo) {
            NSSound(named: "Morse")?.play()
        }
        
        func performDrop(info: DropInfo) -> Bool {
            NSSound(named: "Submarine")?.play()
            
            let gridPosition = getGridPosition(location: info.location)
            self.active = gridPosition
            
            if let item = info.itemProviders(for: ["public.file-url"]).first {
                item.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (urlData, error) in
                    DispatchQueue.main.async {
                        if let urlData = urlData as? Data {
                            self.imageUrls[gridPosition] = NSURL(absoluteURLWithDataRepresentation: urlData, relativeTo: nil) as URL
                        }
                    }
                }
                
                return true
                
            } else {
                return false
            }

        }
        
        func dropUpdated(info: DropInfo) -> DropProposal? {
            self.active = getGridPosition(location: info.location)
                        
            return nil
        }
        
        func dropExited(info: DropInfo) {
            self.active = 0
        }
        
        func getGridPosition(location: CGPoint) -> Int {
            if location.x > 150 && location.y > 150 {
                return 4
            } else if location.x > 150 && location.y < 150 {
                return 3
            } else if location.x < 150 && location.y > 150 {
                return 2
            } else if location.x < 150 && location.y < 150 {
                return 1
            } else {
                return 0
            }
        }
    }
}

Making Sense of the Code

First we mark our views as draggable, by calling onDrag. There, we simply sets the data that will be dragged with the view. In the example we are using a URL, but any type can be used. Some can be used directly with NSItemProvider. For other types, you may need to work a little harder. However, NSItemProvider is way out of the scope of this article. Fortunately, there’s plenty of resources already available… starting by Apple’s documentation

On the other side, you need to define how to handle the drop. We will do it by specifying a DropDelegate. This is a protocol that we need to implement. Although the protocol has many methods, only one is required: performDrop. The rest are optional.

In the example above, MyDropDelegate uses all methods in the protocol, to demonstrate how they work.

Drag and Drop with List Views

Important: As some readers pointed out, drag and drop on List views seems to work only on iPad, not iPhone.

We will now discuss some special considerations about Drag and Drop on List views. In the following example, we use the onDrag method on a List view. As you can see in the video, you can select multiple draggable rows.

You can also observe how views are draggable even outside the app. In this case we are dragging them to the Files app. Note that this behavior is not exclusive to List views.

import SwiftUI

struct Fruit: Identifiable {
    let id = UUID()
    let name: String
    let image: String
}

struct ContentView: View {
    @State var selection: Set<UUID> = []
    
    @State private var fruitsTop = [
        Fruit(name: "Apple", image: "apple"),
        Fruit(name: "Banana", image: "banana"),
        Fruit(name: "Grapes", image: "grapes"),
        Fruit(name: "Peach", image: "peach"),
        Fruit(name: "Kiwi", image: "kiwi"),
    ]

    @State private var fruitsBottom = [
        Fruit(name: "Peach", image: "peach"),
        Fruit(name: "Kiwi", image: "kiwi"),
    ]
    
    var body: some View {

        VStack {
            NavigationView {
                List(selection: $selection) {
                    ForEach(fruitsTop) { fruit in
                        HStack {
                            Image(fruit.image)
                                .resizable()
                                .frame(width: 30, height: 30)

                            Text(fruit.name)
                        }
                        .onDrag {
                            let provider = NSItemProvider(object: UIImage(named: fruit.image) ?? UIImage())
                            provider.suggestedName = fruit.name
                            return provider
                        }
                    }.onInsert(of: ["public.image"]) { self.insertFruit(position: $0, itemProviders: $1, top: true) }
                }
                .navigationBarItems(trailing: EditButton())
                .navigationBarTitle("Fruits (Top)")
            }
            
            NavigationView {
                List(selection: $selection) {
                    ForEach(fruitsBottom) { fruit in
                        HStack {
                            Image(fruit.image)
                                .resizable()
                                .frame(width: 30, height: 30)

                            Text(fruit.name)
                        }
                        .onDrag {
                            let provider = NSItemProvider(object: UIImage(named: fruit.image) ?? UIImage())
                            provider.suggestedName = fruit.name
                            return provider
                        }

                    }.onInsert(of: ["public.image"]) { self.insertFruit(position: $0, itemProviders: $1, top: false) }
                }
                .navigationBarItems(trailing: EditButton())
                .navigationBarTitle("Fruits (Bottom)")
            }
        }
    }
    
    func insertFruit(position: Int, itemProviders: [NSItemProvider], top: Bool) {
        for item in itemProviders.reversed() {

            item.loadObject(ofClass: UIImage.self) { image, error in
                if let _ = image as? UIImage {

                    DispatchQueue.main.async {
                        let f = Fruit(name: item.suggestedName ?? "Unknown",
                                      image: item.suggestedName?.lowercased() ?? "unknown")
                        
                        if top {
                            self.fruitsTop.insert(f, at: position)
                        } else {
                            self.fruitsBottom.insert(f, at: position)
                        }
                    }
                }
            }
        }
    }
}

As you can observe in the video demonstration, the full row is dragged. We mentioned that onDrag only became available on iOS 13.4 beta. However, before that, we already had the itemProvider method. This method only worked with List view rows, but it is present since iOS 13.0. These two lines have the same effect:

      .onDrag { return NSItemProvider(object: UIImage(named: fruit.image) ?? UIImage()) }
.itemProvider { return NSItemProvider(object: UIImage(named: fruit.image) ?? UIImage()) }

At the moment, it is unclear to me what’s the difference between these two. By experimenting, itemProvider only work with Lists, while onDrag works everywhere. These two methods are available to both macOS and iOS. However, onDrag (as already mentioned), is only available since iOS13.4 beta. Beyond that, I did not find any other differences. If you did, please drop me an email or leave a comment!

Also important to note is the fact that drag and drop for Lists on iOS, only seems to work with iPad, not iOS. So technically, it works with iPadOS, not iOS.

Drag and Drop Bug with Lists

Unfortunately, there are some very annoying bugs. When dragging a row to an empty List, a crash occurs. Also, in some instances, drag and drop into the last position of a List may also generate a crash. Thanks to the readers that pointed that out.

Summary

In this article, we have seen how easy it is to implement drag and drop (aside from those ugly bugs). The main challenge is defining the NSItemProvider. For example, depending on your app needs, an image can be dragged as an Image, a URL, or raw data. Text can be dragged as simple text, rich text, etc. You may even need to drag your own custom type. Usually, most of the time implementing drag and drop, will be the time spent on that task, and not the UI part of the problem.

Here we discussed the SwiftUI methods that are specifically intended to handle drag and drop. In the very unlikely reason these methods do not serve your needs, you can always build your own drag and drop system, using Gestures. Most likely though, that would be an overkill.

Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles come out. Until next time!

26 thoughts on “Drag & Drop with SwiftUI”

  1. Thank you for the article, very helpful.

    Is it my imagination, but is onDrop only working on a handful of objects? (VStack, Button, Text

    I was trying to do what I’d asked on Twitter (drag item from a DetailView onto a Specific row (custom view inside a NavigationItem) in a List in a NavigationView) and none of the onDrops are triggered.

    Even dropping onto a List inside a DetailView doesn’t work, but dropping an item onto a VStack (Text, Text, List) works as long as I drop it onto either of the Text views, but not the list view (green + disappears on the List, even in I add onDrop to the list)

    Basically, It seems that you can’t drop onto any View inside a List.

    Getting a little aggrieved by it.

    Reply
  2. > that changed with the latest iOS13.4 beta release

    Does this mean any change to SwiftUI require new OS update? There hasn’t been any new features such as new primitive views added to SwiftUI since its release. Does this mean Apple is waiting for the next major OS release to add anything significant to SwiftUI? I kind wish they just add more stuff to SwiftUI without waiting for major OS release.

    Reply
    • I was surprised to see onDrag support to be added in iOS13.4. By now, I though they weren’t adding any new stuff until iOS14. I’m glad they did, but I don’t expect any major changes until iOS14 arrives. Just my guess, I have no knowledge of Apple’s plans.

      Reply
  3. This was enlightening. A lot easier to implement drag ‘n drop in SwiftUI than with UIKit. I have one problem though: if I try to drop anything into an empty List/ForEach the app crashes immediately. Am I missing something, or is this just a temporary bug ?

    Reply
    • Just add .onMove( _, _ in } and you will be able to reorder without the button. No need to implement drag and drop.

      Reply
        • Upon closer inspection, some aspects of drag and drop seem to only work on iPad. That is indeed very frustrating. Specifically, dragging List items. When using .onDrag() on other views I found it working fine on iPhone. Haven’t you?

          Reply
          • Yes indeed, everything with drag & drop works on the iPhone, except List reordering. That is the one thing I want the most 😀

      • Could you elaborate on this, please? Isn’t there only one method .onMove(perform:), which requires an IndexSet as a source and an Int as Destination? I have this method attached to my list, but it still requires that I toggle into edit mode…

        Reply
          • Mm, that’s weird (and unfortunate) 😉 Do you think this is going to be changed in the future? And what would your guess be as to into what? Should I count on it or rather think of a different solution? 😉

          • I can only hope it will be included on iPhone! Should you count on it or think a different solution? That depends… can you wait two months and work on other stuff while we wait for the next WWDC? If not, then you should probably find a different solution.

  4. Using the second example, drag an item from the top list to the bottom list, and then drag another item from the top past the end of the bottom list. You will always experience a EXC_BAD_INSTRUCTION.

    Reply
    • Hi Patrick, I’m not sure if that problem was there when I originally wrote the article, or if it is something introduced in the latest Xcode versions. In any case, it is very unfortunate. With a little luck, the new SwiftUI that will be release in the upcoming WWDC will solve this. Fingers crossed. Thanks for letting me know, I updated the article to include a warning.

      Reply
    • Getting the same error on macOS when I drop any item to the top of the list. Even though the source array is updated like expected – it crashes with EXC_BAD_INSTRUCTION in “OutlineListUpdater”. Xcode 12.3
      Can’t find a solution…

      Reply
  5. I don’t think the bug with the dropInfo coordinates is as simple as a local/global coordinate transformation. For instance, if you drag an object in a clockwise rectangle and back to the same spot, you will get a different distance between the starting and ending coordinates than if you do it along the same rectangle in a counter-clockwise direction.

    I was trying to use drag/drop to move squares around in a LazyVGrid because I was trying to implement a Piano Roll sort of sequencer editor. The results I was getting by trying to use the dropInfo vector to determine how I was stretching or moving a note on the grid was very confusing. I think I need to figure out a way, instead, to determine the grid square on which the object is dropped. Probably I should have done that in the first place!

    Thanks for your useful articles and app.

    Reply
    • Did you get anywhere doing it the other way you mentioned – Determining the grid square an object was dropped? I was trying to use DropInfo.location but it’s returning bizarre results that I know are not the global coordinates where the object was dropped.

      Reply
  6. Great examples, clarified a lot for me.
    Unfortunately, even with iOS 15 beta 2 and Version 13.0 beta (13A5155e) the “drag-into-empty-list-bug” still exists. Reported it to Apple.

    Reply
  7. I’m trying to get dragging and dropping items between 2 or more lists working and currently struggling.

    I’ve seen other examples where that works but the rows do not adjust position via drag and drop, nor do you get the nice effect of the rows moving to create space for dropping the new object.

    Then your example the result of the drop is always a copy event whereas I’m trying to create a situation where you have say, 4 lists and can move items freely between them without copying.

    Each item would need to be a custom type which means also delving into custom UTTypes I think.

    Do you have any tips or examples you are aware of?

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close