View Builder


« Nav Mistake

Less Talk »

I've been playing with an EventKit project where I display my calendars in a list and then navigate to any items I have due today in that calendar.

I wanted to display all of my calendars but only show the disclosure indicator for calendars with items.

In other words, if there were no details to navigate to, I didn't want to imply or allow the user to navigate.

One solution is to only display those calendars which have items - but that wasn't my goal. I wanted to show all calendars and wrap some of them in a NavigationLink.

In SwiftUI, a Text and a Text wrapped in a NavigationLink are two different types.

Fortunately, we can use @ViewBuilder to accommodate our needs.

Start with ContentView which produces a List from the numbers from 1 to 9 and places that list in a NavigationView.

ContentView.swift struct ContentView: View { var body: some View { NavigationView { List(1..<10){int in MainCell(row: int) }.navigationTitle("Main") } } }

Each row in the List is an instance of MainCell. In our first pass, MainCell has a Text inside of a NavigationLink for each row. We also have a data dictionary which has entries for some of the rows.

MainCell.swift let data = [1: ["a", "b", "c"], 3: ["d", "e"], 4: ["f", "g", "h", "i", "j"], 6: ["k", "l"], 9: ["m"]] struct MainCell: View { let row: Int var body: some View { NavigationLink( destination: DetailView(row: row, strings: data[row, default: []])){ Text("Main \(row)") } } }

For completeness, here's the DetailView. Note that it takes the row number and an Array of Strings. If the dictionary doesn't contain an entry for the row number we default to an empty Array.

DetailView.swift struct DetailView: View { let row: Int let strings: [String] var body: some View { List(strings, id: \.self){item in Text(item) } .navigationTitle("Detail \(row)") } }

Run the app and we see this as the ContentView.

The table view containing rows Main 1, Main 2, ... Main 9 with nav links in all rows

Tap a row, say Main 4, and we see its DetailView.

The table view containing rows f, g, h, i, j with nav title Detail 4

Notice that all of the rows on the Main screen have disclosure indicators and you can tap any of them to navigate in whether there's any further content or not.

Ready for the cool part?

We now have ViewBuilder that allows us to conditionally return different types of views from body.

MainCell.swift @ViewBuilder var body: some View { //...

James Dempsey (who also tipped me off about using id: \.self above in DetailView instead of extending String to make it conform to Identifiable) points out that we don't need to explicitly include ViewBuilder either as of Xcode 12 beta 2.

MainCell.swift @ViewBuilder var body: some View { //...

Here's how we show the affordance only if there is something to navigate to. I've highlighted the code below to emphasize that body returns either a NavigationLink or a Text which are two different types.

MainCell.swift struct MainCell: View { let row: Int var body: some View { if let strings = data[row] { NavigationLink( destination: DetailView(row: row, strings: strings)){ Text("Main \(row)") } } else { Text("Main \(row)") } } }

@ViewBuilder allows us to return Views of different types from the two different branches. If there is data to display for the row then we wrap the row in a NavigationLink. Otherwise we don't.

The result looks like this.

The table view containing Main 1 ... Main 9 with disclosure indicators only on 2, 5, 7, 8

Note that at this point we can't replace if let with guard let.

Anyway, I thought this was pretty cool.