Hugh Jeremy
Posted on March 18, 2020
SwiftUI made a surprisingly good first impression. Of course, things get more complicated as soon as you want to build something actually useful. Tables are a fundamental part of many macOS applications. How can we build them in SwiftUI?
Apple pushes the SwiftUI List
pretty hard. It's great for iPhone GUIs. It is woefully inadequate for the desktop. Compared to the old AppKit NSTableView
it is a children's toy.
You can certainly build tables out of List
, using rows of HStack
views. My test case was the Draft Rugby Player Stats table. Using draft-sport-swift to source the data, List
gave me something approaching serviceability:
In code, it's not pretty. Lots of manual column-width calculation. Manual row banding. Manual row formulation in a dedicated row view. In the end, it involved writing lots of code to get something that approximated the aesthetic of an NSTableView
, but without all the functionality that NSTableView
includes.
Creating an NSTableView
also involves writing lots of code. However, you get a lot of bang for your buck. Ideally, we could slap an NSTableView
straight into our SwiftUI app. Fortunately, we can!
Step 1: Create NSViewController
/ NSTableView
as normal
import Foundation
import AppKit
class PlayerNSTableView: NSTableView {
// Define your NSTableView subclass as you would in an AppKit app
}
class class PlayerNSTableController: NSViewController {
// Define your NSViewController subclass as you would in an AppKit app
}
Step 2: Create an NSViewControllerRepresentable
wrapper
SwiftUI is all about View
implementations. AppKit is all about NSViewController
, NSView
, the relationships between them, and a bunch of other crap. NSViewControllerRepresentable
is the bridge between the AppKit and SwiftUI worlds. It is itself a View
conforming protocol, and can be passed around in SwiftUI like any other View
struct.
Create a struct conforming to NSViewControllerRepresentable
, and add the required protocol stubs. Observe the tangled mess that results:
import Foundation
import AppKit
import SwiftUI
import DraftSport
struct PlayerNSTable: NSViewControllerRepresentable {
@Binding var players: Array<Player>?
typealias NSViewControllerType = PlayerNSTableController
func makeNSViewController(
context: NSViewControllerRepresentableContext<PlayerNSTable>
) -> PlayerNSTableController {
return PlayerNSTableController()
}
func updateNSViewController(
_ nsViewController: PlayerNSTableController,
context: NSViewControllerRepresentableContext<PlayerNSTable>
) {
if let players = players {
nsViewController.refresh(players)
}
return
}
}
There's a lot to unpack here. Never fear, the only bit you really need to care about is the body of makeNSViewController()
. Inside the body, you will see return PlayerNSTableController()
. That's the key: Initialise the NSViewController
subclass you defined in Step 1.
Step 3: Manipulate the wrapped NSViewController
from SwiftUI
We're obviously going to want to manipulate the AppKit table data from SwiftUI. In the above NSViewControllerRepresentable
implementation, you will notice the line @Binding var players: Array<Player>?
. This is a bridge passing data between the SwiftUI and AppKit worlds.
Like any other struct conforming to View
, PlayerNSTable
may observe changes to its properties and update the GUI in response. The magic sauce is the @Binding
decorator. Now, when .players
is changed, the .updateNSViewController()
method of our NSViewControllerRepresentable
is called.
Inside .updateNSViewController()
we can do whatever we like. I happen to expose a .refresh(:Array<Player>)
method on my PlayerNSTableController
, which ultimately calls .reloadData()
on the underlying NSTableView
. It's up to you how you want to feed new data into your NSViewController
/ NSTableView
pair.
Step 4: Add your wrapped AppKit view to a SwiftUI view
You can now add your NSViewControllerRepresentable
implementation to any SwiftUI View
. Here's my implementation, PlayerNSTable
, being added to an enclosing View
:
import SwiftUI
import DraftSport
struct PlayerTable: View {
@State var players: Array<Player>? = nil
var body: some View {
PlayerNSTable(
players: self.$players
)
.frame(alignment: .topLeading)
.onAppear(perform: retrievePlayers)
}
func retrievePlayers() -> Void {
self.players = nil
Player.retrieveMany(
season: Season(publicId: "2020")
) { (error, players) in
guard let players = players else {
fatalError("No players")
}
self.players = players
}
}
}
Note that when PlayerNSTable
appears, Player
data are retrieved via the retrievePlayers()
method. As with any other SwiftUI View
, changes to @State
decorated properties are observed. When Player.retrieveMany()
executes its callback closure, the .players
property is updated. This update ultimately triggers the updateNSViewController()
method we discussed in Step 2.
The end result:
A fully functional NSTableView
inside a SwiftUI View
hierarchy. Phew! That was intense. If you have any questions, find me @hugh_jeremy on Twitter.
Posted on March 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.