Home iOS & Swift Books macOS by Tutorials

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:

  1. Initialize a Table view with its data. This iterates over all the events, like a List does, with one event per row.
  2. Create a TableColumn with the label Year.
  3. Inside the cell for each row in this column, use a Text view to display the year for the event.
  4. 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:

Table preview
Table preview

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 its EnvironmentObject, 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.

Table view
Table view

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:

  1. You create a ToolbarItem with an id property for customization. By default, placement is automatic and showByDefault is true, so there’s no need to specify them.
  2. Inside the ToolbarItem, you add a Picker with a title and with its selection bound to the viewMode property.
  3. 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.
  4. You set the pickerStyle to segmented.
  5. 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:

View selector
View selector

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 — or press Control-Shift-A — 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.

Window settings
Window settings

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:

Table sorting
Table sorting

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 a row.
Selecting a row.

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:

Selecting multiple rows.
Selecting multiple rows.

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?

  1. It checks to see if there is a selectedEventID and if not, returns nil.
  2. It uses first(where:) to find the first event in tableData with a matching ID.
  3. 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?

  1. Check to see if there is an event to display.
  2. If there is, use EventView to show it.
  3. Set a fixed width so the display doesn’t jump around as you select different events with different amounts of text.
  4. 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:

Selecting an event.
Selecting an 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.

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:

Date Pickers
Date Pickers

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?

  1. When you select a day, appState will load the events for that day. It also provides a list of month names.
  2. 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?

  1. Starts with a VStack to show a header before the two pickers.
  2. Uses an HStack to display the pickers side by side.
  3. Sets up a Picker bound to the month property.
  4. Loops through the English month names, provided by appState, to create the picker items.
  5. Sets the picker style to menu so it appears as a popup menu.
  6. Does the same for the day picker, using the maxDays computed property.
  7. Sets a small width for the day picker and pads it out from the trailing edge.
  8. 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:

Day Picker
Day Picker

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?

  1. It’s calling an async method using await, so must be async itself.
  2. Gets the index number for the selected month, using zero as the default.
  3. Adds one to the zero-based month index to get the month number.
  4. 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!

  1. If appState is already loading from the API, show a ProgressView instead of a button. The standard ProgressView is a spinner. To stop this view resizing vertically, it’s set to the same height as the button will be.
  2. If appState is not loading, show a Button with a title.
  3. The action for the button is an asynchronous Task that calls the method you just added.
  4. 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:

Day picker in the sidebar
Day picker in the sidebar

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:

Showing data for a selected date.
Showing data for a selected date.

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?

  1. Add a new Section with a title.
  2. Loop through the dates appState has events for. There’s a computed property in appState that returns the dates sorted by month and day instead of alphabetically.
  3. Show a Button for each date that sets the @SceneStorage property.
  4. Inside each button, show the date, pushed to the left by a Spacer.
  5. 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.

Sidebar showing multiple dates
Sidebar showing multiple 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:

  1. Create a new structure conforming to ViewModifier.
  2. Declare a single property that indicates whether this is a selected button.
  3. Define the body method required by ViewModifier. Its content parameter is the original unmodified (button) View.
  4. Apply a modifier to content if selected is true.
  5. Set the style of the button to borderedProminent. This causes SwiftUI to fill the button with the accent color.
  6. 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:

Sidebar showing selected date
Sidebar showing selected date

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.

Top section in sidebar showing selected date and counts.
Top section in sidebar showing selected date and counts.

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

© 2022 Razeware LLC