December Adventure Day 15
Drag-and-Drop Discoveries
Day 15 of my Psion December Adventure brought a continued focus on Reconnect, my Psion connectivity software for macOS. Unfortunately I spent much of the day wrestling with the platform, so these notes focus more on figuring out how the APIs work than they do on presenting a complete feature. That’s just how it goes sometimes.
Reconnect
Eager to continue to improve the file transfer experience, I’ve been looking at adding drag-and-drop support to the file browser. This should have been easy, but a bunch of rough edges sent me down many rabbit holes—Reconnect is implemented in SwiftUI which continues to fall short on macOS, either failing to provide support for native macOS behavior, deviating significantly from the documentation, or being so buggy that it’s simply unusable. I hit up against many of these issues but I think I figured it out by the end of the day, so I’ll try to walk through the solution in detail here in the hope that it helps others.
The main browser window of Reconnect uses a SwiftUI Table
. The code looks something like this:
Table(browserModel.files, selection: $browserModel.fileSelection) {
TableColumn("") { file in
Image(file.fileType.image)
}
.width(16.0)
TableColumn("Name", value: \.name)
TableColumn("Date Modified") { file in
Text(file.modificationDate.formatted(date: .long, time: .shortened))
.foregroundStyle(.secondary)
}
TableColumn("Size") { file in
if file.isDirectory {
Text("--")
.foregroundStyle(.secondary)
} else {
Text(file.size.formatted(.byteCount(style: .file)))
.foregroundStyle(.secondary)
}
}
TableColumn("Type") { file in
FileTypePopover(file: file)
.foregroundStyle(.secondary)
}
}
In order to support drag-and-drop of these rows, SwiftUI requires us to use a slightly different Table
constructor which allows each row to be customized:
Table(of: FileServer.DirectoryEntry.self, selection: $browserModel.fileSelection) {
...
} rows: {
ForEach(browserModel.files) { file in
TableRow(file)
}
}
This alternative constructor pushes the file/row iteration down into a ForEach
(in the rows
view builder) which lets us apply view modifiers to each TableRow
. For example, if file
conformed to the Transferable
protocol, making these rows draggable should be as simple as:
Table(of: FileServer.DirectoryEntry.self, selection: $browserModel.fileSelection) {
...
} rows: {
ForEach(browserModel.files) { file in
TableRow(file)
.draggable(file)
}
}
But this is where things start to go awry. SwiftUI actually provides two different mechanisms for making things draggable: .draggable
, and .itemProvider.
.draggable
is the new hotness which, of course, means it’s not actually flexible enough to do what we need in Reconnect.
In order to see why .draggable
doesn’t work, I’ll first show some pseudo-code for a complete solution using .itemProvider
(where I got to by the end of the day) and then break down how it works:
Table(of: FileServer.DirectoryEntry.self, selection: $browserModel.fileSelection) {
...
} rows: {
ForEach(browserModel.files) { file in
TableRow(file)
.itemProvider {
let provider = NSItemProvider()
provider.suggestedName = file.name
provider.registerFileRepresentation(for: .data) { completion in
Task {
let data = await self.browserModel.download([file.id])
completion(file, false, nil)
}
return nil
}
return provider
}
}
}
There’s a few things going on in this code:
.suggestedName
lets drag destinations like Finder know what the dragged file should be called.registerFileRepresentation
provides a completion block (load handler) that can initiate a file transfer from the Psion to fetch the file data and, crucially, specifies a content type of.data
which tells macOS tells macOS to expectcompletion
to be called asynchronously (I wasted a lot of time figuring that out)1- the load handler can optionally return a
Progress
instance as a way to communicate load progress, but I’ve not found it makes any difference to the user experience, sonil
seems to be sufficient
So why isn’t .draggable
good enough? The equivalent implementation certainly seems cleaner:
Table(of: FileServer.DirectoryEntry.self, selection: $browserModel.fileSelection) {
...
} rows: {
ForEach(browserModel.files) { file in
TableRow(file)
.draggable(file)
}
}
And, the corresponding Transferable
conformance:
extension FileServer.DirectoryEntry: Transferable {
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .data) { item in
return SendTransferredFile(await self.browserModel.download([item.id]))
}
}
}
This is much more modern and supports an async block by default, avoiding the need to wrap code in a Task
2. Unfortunately, there’s one key aspect missing—there’s no way to specify the desired filename, without which, Finder will always create a file named ‘data’ for every drop operation. Assuming that .draggable
and .itemProvider
offered identical functionality, I spent a lot of time going down dead-ends trying to work around this until I finally chanced upon the .itemProvider
-based solution3. 🤦🏻♂️
Having spent altogether too long poking at .draggable
and Transferable
before settling on .itemProvider
and NSItemProvider
, I wasn’t able to fully implement drag-and-drop support, but hopefully I’m well set up to get something working on day 16.
-
Without this, Finder will beachball until
completion
is called. Unfortunately, I’ve not been able to find any official documentation stating this, only a discussion on StackOverflow that put me on the right path. ↩ -
Though ironically, even with explicit async support, this method still has the same absurd synchronous behavior of
NSItemProvider.registerFileRepresentation
for any data type excepting.data
and.folder
. ↩ -
Do let me know if I’ve missed something. I’d love to be able to use
Transferable
instead. ↩