4.
Using Tables & Custom Views
Written by Sarah Reichelt
In the last chapter, you did a lot of work to make your app look and feel like a real Mac app. Now, you’re going to head off in a different direction and look at alternative ways to display the data and interact with your users.
First, you’ll learn how to use SwiftUI’s new Table
view, which is only available for macOS. You’ll add to your toolbar and learn how to store window-specific settings.
Then, you’ll dive into date pickers and create a custom view that allows your users to select different dates. Along the way, you’ll customize the sidebar to allow swapping between dates.
Why Use a Table?
So far in this app, you’ve displayed the events in a grid of cards, and there’s nothing wrong with that. But many apps offer alternative ways of viewing the data to allow for personal preferences, varying screen sizes or different use cases. Think of a Finder window: It has four different view options, all of which are useful at different times.
At WWDC 2021, Apple announced a new Table
view for macOS only, so now you’re going to offer that as a view option in your app.
A lot of data sets are tabular in nature and are very effectively displayed in a table. Your first thought might be of spreadsheets, but what about lists of files in Finder or playlists in Music? SwiftUI has always offered lists, which are like single column tables. You can fake a multi-column look by adding more than one view into each row, but that doesn’t offer all the facilities a real table does.
Now, you can add a real table to your macOS SwiftUI app.
Adding a Table
Open your project from the previous chapter or download the materials for this chapter and open the starter project.
Start by selecting the Views group in the Project navigator and adding a new SwiftUI View file called TableView.swift.
To construct a table, you need to define the rows and the columns. The rows are the events that ContentView
will pass to TableView
. Add this declaration at the top of TableView
:
var tableData: [Event]
This gives an error in TableView_Previews
, so change the contents of its previews
to:
TableView(tableData: [Event.sampleEvent])
A table with a single row doesn’t make a lot of sense, but you want to minimize the use of live data here. If you provided preview data from an instance of AppState
, the frequent view updates could exceed the data usage limits for the API.
Now that you have access to the data that defines the rows, you can set up the columns. Replace the default Text
in body
with:
// 1
Table(tableData) {
// 2
TableColumn("Year") {
// 3
Text($0.year)
}
// 4
TableColumn("Title") {
Text($0.text)
}
}
Creating a table doesn’t take much code:
- Initialize a
Table
view with its data. This iterates over all the events, like aList
does, with one event per row. - Create a
TableColumn
with the label Year. - Inside the cell for each row in this column, use a
Text
view to display theyear
for the event. - Make a second column called Title for for the
text
.
Resume the preview, turn on Live Preview, and click Bring Forward to see your one row table:
Straightaway it looks like a real Mac table with alternating row colors, adjustable column widths and clickable titles, but there are more features to add.
Sizing and Displaying Your Table
Drag the column divider around to adjust the column widths, and you’ll see that you can make the year column too small or too big. Fix that by adding a width modifier to the first column:
.width(min: 50, ideal: 60, max: 100)
The height of each row is set automatically, but you can set limits for the width of any column. There’s no need to set any width limits for the last column, as it will take up all the remaining space.
You’re about to add a way to switch between grid and table views, but for now, set the app to use the table all the time so you can see it in operation.
Switch to ContentView.swift and replace the GridView
line inside the NavigationView
with this:
TableView(tableData: events)
Note: If you’re getting preview errors, or reports of the preview app crashing, delete
ContentView_Previews
. It’s causing problems because it does not have itsEnvironmentObject
, but you don’t want the preview to use this object because it will hit the API usage limit. So delete the entire preview structure.
Build and run the app now to see the live data appearing in your table. You can switch between the event types and search the table without any further coding.
Switching Views
There’s more work to do on the table but, now that you’ve proved it works, you’re going to add a new control to the toolbar. This will allow you to switch between the grid and the table.
First, add this enumeration to ContentView.swift, outside ContentView
:
enum ViewMode: Int {
case grid
case table
}
This defines the two possible view modes. Next you need a property to hold the current setting, so add this line to the top of ContentView
:
@State private var viewMode: ViewMode = .grid
This sets the view mode to grid
by default and gives you a value that you can pass to Toolbar
.
In Controls/Toolbar.swift, add this declaration to the structure:
@Binding var viewMode: ViewMode
This binding allows Toolbar
to read the value passed to it and send back any changes to the parent view.
You already have one ToolbarItem
, so add this new one after it:
// 1
ToolbarItem(id: "viewMode") {
// 2
Picker("View Mode", selection: $viewMode) {
// 3
Label("Grid", systemImage: "square.grid.3x2")
.tag(ViewMode.grid)
Label("Table", systemImage: "tablecells")
.tag(ViewMode.table)
}
// 4
.pickerStyle(.segmented)
// 5
.help("Switch between Grid and Table")
}
This is what you’re doing here:
- You create a
ToolbarItem
with anid
property for customization. By default,placement
isautomatic
andshowByDefault
istrue
, so there’s no need to specify them. - Inside the
ToolbarItem
, you add aPicker
with a title and with its selection bound to theviewMode
property. - You add two options to the
Picker
, each one configured with a label using text and an SF Symbol. The tags are set to the respective enumeration cases. - You set the
pickerStyle
tosegmented
. - And you add a tooltip and accessibility description.
ContentView
has to supply the viewMode
property, so go back to Views/ContentView.swift and replace the call to Toolbar
with:
Toolbar(viewMode: $viewMode)
Just one thing left to do now, and that’s to implement the choice in the display.
Inside NavigationView
, replace the TableView
line with this code:
if viewMode == .table {
TableView(tableData: events)
} else {
GridView(gridData: events)
}
This checks the setting of viewMode
and displays either TableView
or GridView
as required.
Build and run the app to test your new control:
Storing Window Settings
Try this experiment. Run the app and open a second window. Set one window to show Births in a grid view. Set the other window to show Deaths in a table view. Enter some search text in one window. Now quit and restart the app. The windows re-appear in the same locations and with their previous sizes, but they both show Events in a grid, with no search text.
Note: When you quit the app with more than one window open, and then run it again from Xcode, sometimes only one window will come to the front. Click the app in the Dock to bring all its windows into view.
In the last chapter, you used @AppStorage
to save app-wide settings. That won’t work here because you want to save different settings for each window. Fortunately, there is another property wrapper that is almost identical to @AppStorage
, but designed specifically for this need. @SceneStorage
is a wrapper around UserDefaults
just like @AppStorage
, but it saves settings for each window.
Still in ContentView.swift, replace the three @State
properties at the top of ContentView
with these:
@SceneStorage("eventType") var eventType: EventType?
@SceneStorage("searchText") var searchText = ""
@SceneStorage("viewMode") var viewMode: ViewMode = .grid
The syntax for declaring @SceneStorage
properties is the same as you used for @AppStorage
with a storage key and a property type. For searchText
and viewMode
, you’re able to set a default value, but eventType
is an optional and you can’t initialize an optional @SceneStorage
property with a default value.
You do want to have a default value for eventType
, so you’re going to set it as the view appears. Add this modifier to NavigationView
after searchable
:
.onAppear {
if eventType == nil {
eventType = .events
}
}
The onAppear
action runs when the view appears and sets eventType
to events
if it hasn’t been set already.
Repeat the experiment now. You’ll have to set up the windows once more, but on the next start, the app will restore and apply all your window settings. Open a new window, and it’ll use all the defaults, including the one for eventType
.
Note: If the app doesn’t restore your window settings, open System Preferences ▸ General and uncheck Close windows when quitting an app
Sorting the Table
Now, it’s time to get back to the table and implement sorting. To add sorting to a table, you need to create an array of sort descriptors and bind that array to the Table
. A sort descriptor is an object that describes a comparison, using a key and a direction — ascending or descending.
First, create your array. In TableView.swift, add this property to the structure:
@State private var sortOrder = [KeyPathComparator(\Event.year)]
This creates an array with a single sort descriptor using the keyPath
to the year
property on Event
as its default sort key.
Next, you have to bind this sort descriptor to the table. Change the Table
initialization line to this:
Table(tableData, sortOrder: $sortOrder) {
This allows the table to store the keyPath
to the last selected column as well as whether it’s sorting ascending or descending.
To configure a TableColumn
for sorting, you have to give it a value
property — the keyPath
to use as the sort key for this column. Change the first TableColumn
to this:
TableColumn("Year", value: \.year) {
Text($0.year)
}
There’s nothing wrong with this, but Apple engineers realized that most columns would use the same property for the value keyPath
and for the text contents of the table cell, so they built in a shortcut.
Replace the second TableColumn {...}
with this:
TableColumn("Title", value: \.text)
Here, you don’t specify any cell contents, so the property indicated by the keyPath
is automatically used in a Text
view. To show something else like a checkbox or a button, or to style the text differently, you’d have to use the longer format. To display standard text, this is a very convenient feature.
Now you have the sorting interface and storage set up, but that doesn’t do the actual sort. Add this computed property to TableView
:
var sortedTableData: [Event] {
return tableData.sorted(using: sortOrder)
}
This takes tableData
as supplied by ContentView
and sorts it using the sort descriptor. The sort descriptor changes whenever you click a column header. When you click the same header again, the sort key stays the same but the sort direction changes.
To get the table to use the sorted data, change the Table
initialization line to this:
Table(sortedTableData, sortOrder: $sortOrder) {
Build and run the app now, switch to table view and click the headers. Notice the bold header text and the caret at the right of one column header showing that it’s the actively sorted column and indicating the sort direction:
Selecting Events
Your table is looking great, and it displays the data in a much more compact form, but it doesn’t show the links for each event, and it doesn’t show the complete title if there is a lot of text. So now you’re going to add the ability to select rows in the table. Then, you’ll reuse EventView
to display the selected event at the side.
Making a table selectable is a similar process to making it sortable: You create a property to record the selected row or rows and then bind this to the table.
In TableView.swift, add this property:
@State private var selectedEventID: UUID?
Each Event
has an id
property that is a UUID
. The table uses this UUID
to identify each row, so the property that records the selection must also be a UUID
. And since there may be no selected row, selectedEventID
is an optional.
Then, replace the table declaration line again (this really will be the last time) with this:
Table(
sortedTableData,
selection: $selectedEventID,
sortOrder: $sortOrder) {
The new parameter here is selection
, which you’re binding to your new selectedEventID
property.
Build and run now, and you can click on any row to highlight it:
Selecting Multiple Rows
You can only select one row at a time. Shift-clicking or Command-clicking deselects the current row and selects the new one. That’s perfect for this app, but you may have other apps where you need to select multiple rows, so here’s how you set that up.
Replace the selectedEventID
property with this:
@State private var selectedEventID: Set<UUID> = []
Instead of storing a single event ID, now you’re storing a Set
of IDs. Build and run now, and test the multiple selections:
Notice how the table maintains the selection through sorts and searches.
Now that you know how to set up a table for multiple selections, set it back to using a single selection with:
@State private var selectedEventID: UUID?
Displaying the Full Event Data
Clicking a row in the table sets selectedEventID
, which is a UUID
but, to display an EventView
, you need an Event
. To find the Event
matching the chosen UUID
, add this computed property to TableView
:
var selectedEvent: Event? {
// 1
guard let selectedEventID = selectedEventID else {
return nil
}
// 2
let event = tableData.first {
$0.id == selectedEventID
}
// 3
return event
}
What does this property do?
- It checks to see if there is a
selectedEventID
and if not, returnsnil
. - It uses
first(where:)
to find the first event intableData
with a matching ID. - Then, it returns the event, which will be
nil
if no event had that ID.
With this property ready for use, you can add the user interface, not forgetting to allow for when there is no selected row.
Still in TableView.swift, Command-click Table
and select Embed in HStack. After the Table
, just before the closing brace of the HStack
, add this conditional code:
// 1
if let selectedEvent = selectedEvent {
// 2
EventView(event: selectedEvent)
// 3
.frame(width: 250)
} else {
// 4
Text("Select an event for more details…")
.font(.title3)
.padding()
.frame(width: 250)
}
What’s this code doing?
- Check to see if there is an event to display.
- If there is, use
EventView
to show it. - Set a fixed width so the display doesn’t jump around as you select different events with different amounts of text.
- If there is no event, it shows some text using the same fixed width.
Build and run the app, make sure you’re in table view, and then click any event:
The EventView
that you used for the grid displays all the information about the selected event, complete with active links and hover cursors. Now you can see why some of the styling for the grid is in GridView
and not in EventView
. You don’t want a border or shadows in this view.
Note: Sometimes you can Command-click on a view and not see all the expected options, like Embed in HStack. In this case, open the canvas preview. It does not have to be active, but it has to be open to show all the options.
Custom Views
So far in this app, every view has been a standard view. This is almost always the best way to go — unless you’re writing a game — as it makes your app look familiar. This makes it easy to learn and easy to use. It also future-proofs your app. If Apple changes the system font or alters the look and feel of a standard button, your app will adopt the new look because it uses standard fonts and UI elements.
But, there are always a few cases where the standard user interface view doesn’t quite do what you want…
The next feature you’re going to add to the app is the ability to select a different date. Showing notable events for today is fun, but don’t you want to know how many other famous people were born on your birthday?
Looking at Date Pickers
When you’re thinking about date selections, your first instinct should be to reach for a DatePicker
.
To test this, open the downloaded assets folder and drag DatePickerViews.swift into your project. Open the file and resume the preview. Click Live Preview and then Bring Forward and try selecting some dates:
This shows the two main styles of macOS date picker. There are some variations for the field
style, but this is basically it. You can choose a date easily enough, but can you see why this isn’t great for this app? Try selecting February 29th.
So the problem here is that there’s no way to take the year out of the selection, while this app only needs month and day. And the day has to include every possible day for each month, regardless of leap years. So the time has come to create a custom view.
Creating a Custom Date Picker
Delete DatePickerViews.swift from your project. It was just there as a demonstration.
Create a new SwiftUI View file in the Views group and call it DayPicker.swift.
This view will have two Picker
views: one for selecting the month and the other to select the day.
Add these properties to DayPicker
:
// 1
@EnvironmentObject var appState: AppState
// 2
@State private var month = "January"
@State private var day = 1
What are they for?
- When you select a day,
appState
will load the events for that day. It also provides a list of month names. - Each
Picker
needs a property to hold the selected value.
You’re probably wondering why month
is using January instead of asking Calendar
for the localized month name. This is to suit the API, which uses English month names in its date
property. You’re going to ignore the system language and use English month names.
When the user selects a month, the day Picker
should show the correct number of available days. Since you don’t care about leap years, you can derive this manually by adding this computed property to DayPicker
:
var maxDays: Int {
switch month {
case "February":
return 29
case "April", "June", "September", "November":
return 30
default:
return 31
}
}
This checks the selected month and returns the maximum number of days there can ever be in that month.
Setting up the UI
Now that you have the required properties, replace the default Text
with this:
// 1
VStack {
Text("Select a Date")
// 2
HStack {
// 3
Picker("", selection: $month) {
// 4
ForEach(appState.englishMonthNames, id: \.self) {
Text($0)
}
}
// 5
.pickerStyle(.menu)
// 6
Picker("", selection: $day) {
ForEach(1 ... maxDays, id: \.self) {
Text("\($0)")
}
}
.pickerStyle(.menu)
// 7
.frame(maxWidth: 60)
.padding(.trailing, 10)
}
// button goes here
}
// 8
.padding()
This is standard SwiftUI with nothing macOS-specific, but what does it do?
- Starts with a
VStack
to show a header before the two pickers. - Uses an
HStack
to display the pickers side by side. - Sets up a
Picker
bound to themonth
property. - Loops through the English month names, provided by
appState
, to create the picker items. - Sets the picker style to
menu
so it appears as a popup menu. - Does the same for the day picker, using the
maxDays
computed property. - Sets a small width for the day picker and pads it out from the trailing edge.
- Adds some padding around the
VStack
.
Resume the preview now, and it will fail because you’ve declared an @EnvironmentObject
, but not supplied it to the preview.
In previews
, add the following modifiers to DayPicker()
:
.environmentObject(AppState())
.frame(width: 200)
This provides the necessary environment object and sets a narrow width that will be appropriate when you add this view to the sidebar.
Note: This calls the API every time the preview refreshes, so don’t preview this file often.
Switch on Live Preview and click Bring Forward to see the pickers in action, including setting the maximum number of days:
The last component for your custom day picker is a method to request the new data and a button to trigger it.
Add the method first, by inserting this into DayPicker
:
// 1
func getNewEvents() async {
// 2
let monthIndex = appState.englishMonthNames
.firstIndex(of: month) ?? 0
// 3
let monthNumber = monthIndex + 1
// 4
await appState.getDataFor(month: monthNumber, day: day)
}
What does this method do?
- It’s calling an
async
method usingawait
, so must beasync
itself. - Gets the index number for the selected month, using zero as the default.
- Adds one to the zero-based month index to get the month number.
- Calls
appState.getDataFor()
to query the API for the selected date.
Now for a Button
to use this method; add this in place of // button goes here
:
if appState.isLoading {
// 1
ProgressView()
.frame(height: 28)
} else {
// 2
Button("Get Events") {
// 3
Task {
await getNewEvents()
}
}
// 4
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
OK, so it’s more than just a button!
- If
appState
is already loading from the API, show aProgressView
instead of a button. The standardProgressView
is a spinner. To stop this view resizing vertically, it’s set to the same height as the button will be. - If
appState
is not loading, show aButton
with a title. - The action for the button is an asynchronous
Task
that calls the method you just added. - Style the
Button
to make it look big and important. :]
Now it’s time to put this custom view into place.
Adding to the Sidebar
You created a custom date picker view, but you built it by combining standard views, so although it’s not the usual interface for selecting a date, the components are all familiar. Now, you’ll display your new DayPicker
and put it to work downloading new data.
Open SidebarView.swift, then Command-click List
and select Embed in VStack.
Underneath the line that sets the listStyle
, add:
Spacer()
DayPicker()
This code inserts your new view into the sidebar with a Spacer
to push it to the bottom of the window.
Build and run now to see the new picker. When the app starts, you’ll see the spinner as it loads today’s events, and then you’ll see the button instead:
Pick a month and a day, then click the button. A progress spinner twirls for a few seconds and then the button reappears, but nothing changes in the rest of the display.
Also, you can see that the minimum width for the sidebar is too narrow, at least for the months with longer names.
In SidebarView.swift, add this modifier to the VStack
:
.frame(minWidth: 220)
Now to make the Get Events button work…
Using the Selected Date
In ContentView.swift, you’ve been getting the data from appState
without supplying a date. This makes appState
use today’s date, which has been fine so far. Now you want to use the date selected in the DayPicker
, if there is one. And this setting needs to be for the window, not for the entire app.
Start in ContentView.swift and add this property to the other @SceneStorage
properties:
@SceneStorage("selectedDate") var selectedDate: String?
Next, change the events
computed property to this:
var events: [Event] {
appState.dataFor(
eventType: eventType,
date: selectedDate,
searchText: searchText)
}
You’re supplying all the optional parameters to appState.dataFor(
), allowing for eventType
, searchText
and, now, date
.
Finally, you need to link up the date chosen in DayPicker
to this @SceneStorage
property.
Open DayPicker.swift and add the @SceneStorage
property declaration at the top:
@SceneStorage("selectedDate") var selectedDate: String?
Scroll down to getNewEvents()
and add this line at the end of the method:
selectedDate = "\(month) \(day)"
This sets the @SceneStorage
property after the new data downloads.
Build and run now, select a different date, click Get Events and this time, you’ll see the data change:
Listing Downloaded Dates
Now you know your custom day picker is working and new events are downloading. But, you don’t have any way to swap between downloaded sets of data. Time to expand the sidebar even more…
In SidebarView.swift, you have a List
view with a single Section
.
After that Section
, but still inside the List
, add this code:
// 1
Section("AVAILABLE DATES") {
// 2
ForEach(appState.sortedDates, id: \.self) { date in
// 3
Button {
selectedDate = date
} label: {
// 4
HStack {
Text(date)
Spacer()
}
}
// 5
.controlSize(.large)
}
}
What’s all this doing?
- Add a new
Section
with a title. - Loop through the dates
appState
has events for. There’s a computed property inappState
that returns the dates sorted by month and day instead of alphabetically. - Show a
Button
for each date that sets the@SceneStorage
property. - Inside each button, show the date, pushed to the left by a
Spacer
. - Set the
controlSize
to large, which makes the buttons similar in size to the entries in the list at the top of the sidebar.
To get rid of the error this has caused, add the selectedDate
property to the top:
@SceneStorage("selectedDate") var selectedDate: String?
Build and run the app now. When it starts, the app downloads events for today’s date as well as any dates that were in use when the app shut down. These dates show up in the new section, in ascending order.
Use the DayPicker
to select a new day and click Get Events. Once the new events download, the new date appears in this second section. Click any of the buttons to swap between the dates.
This is all working well, but the buttons don’t show you which is the selected date. Adding some conditional styling will fix this. But there’s a problem: You can’t wrap a modifier in an if
statement. Normally, you’d use a ternary operator to switch between two styles, but for some reason, this doesn’t work for ButtonStyle
. So you’re going to use a ViewModifier
.
At the end of SidebarView.swift, outside any structure, add this:
// 1
struct DateButtonViewModifier: ViewModifier {
// 2
var selected: Bool
// 3
func body(content: Content) -> some View {
if selected {
// 4
content
// 5
.buttonStyle(.borderedProminent)
} else {
// 6
content
}
}
}
In case you haven’t used a ViewModifier
before, here’s what this code does:
- Create a new structure conforming to
ViewModifier
. - Declare a single property that indicates whether this is a selected button.
- Define the
body
method required byViewModifier
. Itscontent
parameter is the original unmodified (button)View
. - Apply a modifier to
content
ifselected
istrue
. - Set the style of the button to
borderedProminent
. This causes SwiftUI to fill the button with the accent color. - Return
content
unmodified, keeping the style the same, if this is not a selected button.
With this ViewModifier
in place, apply it by adding this modifier to the Button
in the AVAILABLE DATES section, after the controlSize
modifier:
.modifier(DateButtonViewModifier(selected: date == selectedDate))
This applies the view modifier, passing it the result of comparing the row’s date with the window’s selected date.
Build and run now to see all this come together:
You might have expected to use a custom ButtonStyle
instead of a ViewModifier
to adjust the buttons. That would work, but a custom ButtonStyle
requires you to define the different appearances for when the user presses or releases the button. In this case, it’s simpler to use a ViewModifier
to apply a standard ButtonStyle
, which keeps the predefined pressed styles.
Updating the Top Section
Everything is looking good so far, but the app has a flaw. When you select a date, the top of the sidebar still shows TODAY, and the badges show the counts for today’s events. Clicking the event types in that top section shows the correct events for the selected day, but the header and badge counts don’t match.
Open SidebarView.swift and change the first Section
line to:
Section(selectedDate?.uppercased() ?? "TODAY") {
This looks for a selectedDate
and, if there is one, converts it to uppercase and uses it as the section header. For a new window, selectedDate
will be nil
, so the header will use TODAY, just as before.
That fixes the header; now for the badge counts. Inside the ForEach
loop for that Section
, the badge count is set using appState.countFor()
. Like appState.dataFor()
, this method can take several optional parameters. You’ve only used eventType
so far, but now you’ll add date
.
Replace the appState.countFor()
line with:
? appState.countFor(eventType: type, date: selectedDate)
Build and run the app now and, when you get events for different dates, the section header and the badge counts change to match. Open a new window. It shows TODAY as the section header because it has no selected date.
Challenges
Challenge 1: Style the Table
Like many SwiftUI views, Table
has its own modifier: tableStyle
. Look up the documentation for this and try out the different styles you can apply. Some of them also let you turn off the alternating row background colors.
Challenge 2: Show the Date in the Window Title
In Chapter 2, you set up a windowTitle
property to show the selected event type as the window title. Expand this to include the selected date if there is one, or the word “Today” if there is not.
Challenge 3: Count the Rows in the Table
In the challenges for the previous chapter, you added a view at the bottom of GridView
to display the number of displayed events. Add a similar feature to TableView
. Don’t forget to hide it whenever showTotals
is set to false
.
Check out TableView.swift and ContentView.swift in the challenge folder if you need any hints.
Key Points
- A table is a good option for displaying a lot of data in a compact form. SwiftUI for macOS now provides a built-in
Table
view. - While some user settings are app-wide, others are only relevant to their own window.
- You can configure tables for sorting and searching. They can be set to allow single or multiple row selections.
- Views can be reused in different places in your app and, by adding modifiers in different places, you can adjust the views to suit each location.
- Using standard controls is almost always the best option but, if your app’s needs are different, you can create a custom control by joining standard controls together.
Where to Go From Here?
Great work! The app is looking really good now with a lot of functionality, and you got to try out the new Table
view. You had to use a custom view instead of a standard DatePicker
, but it still has the native look and feel.
In the next chapter, you’ll wrap up this app by adding some polishing features.
At WWDC 2021, Apple gave two presentations about Mac apps. The sample app they used had an excellent example of using a table. Download the project files and have a look.