Transferable Protocol in SwiftUI - Transferring Alternative Content With ProxyRepresentation

In this previous post we had a first hands-on experience with the Transferable protocol; an API that was introduced in WWDC 2022, and reduced dramatically the amount of work in order to copy-paste, or drag and drop data within the same, or different applications in SwiftUI.

In that first post I demonstrated how to drag and drop objects of custom types that conform to Codable protocol, with several interesting concepts covered along the way:

  • content types (UTIs) and how to declare custom ones,
  • proper conformance to Transferable, so objects of a custom type being capable of dragging,
  • how to start a drag operation in SwiftUI,
  • how to handle dropping in SwiftUI.

This post is a follow-up, as it focuses on another feature of Transferable; how to specify additional content to transfer, on top of the primary transferable content representation.

Tip

I strongly recommend to read the previous post about Transferable, if you have not done so already. Not only there are important concepts to understand, but we will also continue building here on the sample project of that post.

Note

Download the sample project.

Before getting into the essential part, let me summarize what the demo app is all about. With a collection of color items presented on the one side of a view, the goal is to drag a color item to the other side onto a VStack that acts as the drop destination.

In this post we are going to extend that functionality by adding a text field as an additional drop area. Ultimately, we'll make it possible to drag a color item to the TextField, and drop the color's name on it; a String value only, and not the entire color item object.

Here is a demonstration of all that:

The ProxyRepresentation

It was made clear in the previous post that any custom type that conforming to Transferable protocol must mandatorily implement the following static property:

static var transferRepresentation: some TransferRepresentation {
    
}

That's the place to specify what the transferable item is going to be. In the sample project demonstrated here, we have a custom type that describes color items programmatically and conforms to Codable protocol. We need to make explicit that objects of this type are going to be the transferable items. Here is how we did that last time in the sample ColorItem custom type:

struct ColorItem: Identifiable, Codable, Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(for: ColorItem.self, contentType: .color)
    }
    
    // rest of the implementation...
}

With the first argument above (ColorItem.self) we explicitly specify the type of the objects that we are planning to transfer. And although this is an optional value that we can avoid to supply, the next one is required; it's the content type, or in other words, the Uniform Type Identifier (UTI) of the data that will be transferred.

In the above example we have a custom content type, and most of the times that you'll implement Transferable in custom types you'll also need to define your own UTIs as well. Have a look to the previous post to find out more about all that.

With the above, an entire color item can be dragged from one place to another, with the actual instance being serialized when dragging starts and deserialized on drop. Now, we are going to add something more to that:

We'll allow to transfer just the name of a color item and not the entire object, or, to speak in code, we'll specify an additional transferable representation for the color's name only, with a ProxyRepresentation instance:

static var transferRepresentation: some TransferRepresentation {
    CodableRepresentation(for: ColorItem.self, contentType: .color)
    
    // The ProxyRepresentation is the new addition here:
    ProxyRepresentation(exporting: \.name)
}

A few important observations:

  • The provided argument to ProxyRepresentation is the key path to the name property, as that's what we want to transfer.
  • The ProxyRepresentaton(exporting:) must be always called after the primary representation. For instance, it would be wrong to call ProxyRepresentation(exporting:) before CodableRepresentation(for:contentType:).
  • The proxy representation actually uses the main representation of another type, as if it was its own. Here, that other type is String, because the name property is a String. Notice that, in contrast to CodableRepresentation(for:contentType:), we don't have to specify a content type here. It's taken from the String type, which is plain text.

That single line is all we need in order to make the ColorItem type capable of transferring a color item's name as well. The next step is to add a TextField to the SwiftUI view, and enable dropping on it, so we can actually receive that name when an item is dragged.

Adding a TextField to the view

In the following code segment you can see the SwiftUI view implementation, taken directly from the original project implemented in the previous post:

struct ContentView: View {
    @StateObject private var colors = Colors()
    @State private var draggedColorItem: ColorItem?
    @State private var borderColor: Color = .black
    @State private var borderWidth: CGFloat = 1.0
 
    var body: some View {
        HStack {
            VStack {
                ForEach(colors.items, id: .id) { colorItem in
                    ColorView(colorItem: colorItem)
                }
            }
            .frame(width: 250)
            .frame(maxHeight: 750)
            .padding(.leading)
  
            Divider().padding(.horizontal, 75)
 
            VStack {
                if draggedColorItem != nil {
                    ColorView(colorItem: draggedColorItem!)
                } else {
                    Text("Drag and Drop a color here!")
                        .foregroundColor(.secondary)
                }
            }
            .frame(width: 280, height: 220)
            .background(Color.gray.opacity(0.25))
            .border(borderColor, width: borderWidth)
            .padding(.trailing)
            .dropDestination(for: ColorItem.self) { items, location in
                draggedColorItem = items.first
                print(location)
                return true
            } isTargeted: { inDropArea in
                print("In drop area", inDropArea)
                borderColor = inDropArea ? .accentColor : .black
                borderWidth = inDropArea ? 10.0 : 1.0
            }
        }
    }
}

The main container view is an HStack, so visual elements to be laid out horizontally on the view. All sample color items are listed one after the other in a ForEach container, on the left side of the view.

On the right side there is a VStack that acts as the drop destination of a dragged color item. That VStack contains either a dropped color item, or a prompt in case an item has not been dropped yet.

We want to add a TextField, so let's modify the above view.

We are going to embed the VStack on the right side into another VStack, while we'll keep everything else as it is:

HStack {
    ...
    VStack {
        VStack {
            if draggedColorItem != nil {
                ColorView(colorItem: draggedColorItem!)
            } else {
                Text("Drag and Drop a color here!")
                    .foregroundColor(.secondary)
            }
        }
        // ... view modifiers ...
        
        // Add the TextField here...
    }
}

We can now implement the TextField right where the "Add the TextField here.." comment is:

HStack {
    ...
    VStack {
        VStack {
            // Original VStack content...
        }
        // ... view modifiers ...
        
        // The implementation of the new TextField.
        TextField("", text: $colorName)
            .multilineTextAlignment(.center)
            .frame(width: 180, height: 60)
            .border(Color.gray, width: 2)
            .padding(.trailing, 20)
            .padding(.top, 50)
    }
}

There are a few view modifiers that specify the frame, border and padding of the TextField. The colorName binding value is passed as argument to the TextField, but this property does not currently exist; we have to declare it in the view before going any further:

struct ContentView: View {
    @State private var colorName: String = ""
    ...    
}

Setting the TextField as a drop destination for the color name

In order to make the text field a drop destination, it's necessary to use a view modifier that we already met in the previous post; the dropDestination(for:action:isTargeted:) modifier.

Going through the parameters:

  • The first one is the content type of the transferable item. It's optional and we can omit it.
  • The second should be a closure with two parameter values; an array with transferred items, and the location of the dragged item(s) within the drop area. We have to return true or false from this closure, depending on whether we accept the drop or not. The argument for this parameter is required.
  • The argument for the last parameter is another closure, which is optional too. That closure has its own parameter value that informs us whether a dragged item is inside or outside the drop destination; an information that can be often useful.

Here, and for the sake of demonstration, we are going to provide all arguments:

TextField("", text: $colorName)
    // ... other view modifiers ...
    .dropDestination(for: String.self) { items, location in
        
    } isTargeted: { inDropArea in
        
    }

See the first argument above (String.self), where String is set as the type for the dragged items that the text field will accept. Next, let's add the missing content in the isTargeted closure:

TextField("", text: $colorName)
    // ... other view modifiers ...
    .dropDestination(for: String.self) { items, location in
       
    } isTargeted: { inDropArea in
        if inDropArea {
            colorName = ""
        }
    }

When a color item is dragged over the text field, the inDropArea above becomes true. In this case, we clear the colorName value, so the new color name is set in the first closure.

Speaking of that:

TextField("", text: $colorName)
    // ... other view modifiers ...
    .dropDestination(for: String.self) { items, location in
        colorName = items.first ?? ""
        return items.first != nil
    } isTargeted: { inDropArea in
        if inDropArea {
            colorName = ""
        }
    }

Notice here that the colorName gets either the name of the first dragged item, or an empty string value if no items exist in the items collection. The return value of the closure also depends on the existence of elements in the items array; if the first property is not nil, then it's returned true, otherwise false. Lastly, we don't care about the location of the dragged item in this particular case, so we simply ignore it.

With this few lines of code only, we managed to make the TextField a drop destination for the name of dragged color items:

Conclusion

Even though the post became a bit lengthy because we went through each step, the fact is that the actual required implementation was not too much. Starting from the project built in the previous tutorial on the Transferable protocol, we continued here by adding another representation type, and ultimately enabling transferring alternative content on top of the original one. I hope you found this post useful. Thanks for reading, take care!

Resources

Download the sample project