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 Reconnect file browser window

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 expect completion 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, so nil 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 Task2. 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.


  1. 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. 

  2. 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

  3. Do let me know if I’ve missed something. I’d love to be able to use Transferable instead.