Content Unavailable Views

In iOS 17, Apple added a content unavailable view for when you need to show an empty state. It’s available for both SwiftUI and UIKit.

ContentUnavailableView (SwiftUI)

Apple added the ContentUnavailableView to SwiftUI in iOS 17. You use it anytime you need to show an empty state. Some common examples:

  • An empty list
  • The detail view of a split view with nothing selected.
  • A search that did not return any results.

The ContentUnavailableView is configurable with a label, and optionally, a description, and a set of actions:

ContentUnavailableView {
  Label("No messages", systemImage: "exclamationmark.icloud")
} description: {
  Text("Unable to receive new messages")
} actions: {
  Button("Try again") {
    store.fetch()
  }
}

Cloud icon with exclamation mark, No messages, unable to receive new messages. Try again button

Empty Detail View

An example, with a split view showing a list of countries in the sidebar and a detail view that shows the selected item. With nothing selected in the sidebar we show the content unavailable view for the detail view:

NavigationSplitView {
  CountryList(selection: $selectedCountry)
} detail: {
  if let selectedCountry {
    CountryView(country: selectedCountry)
  } else {
    ContentUnavailableView {
      Label("No country selected.",
        systemImage: "map.circle.fill")
    } description: {
      Text("Choose a country to see more details here.")
    }
  }
}

iPad split view with list of countries in sidebar. Details view on right shows map icon, no country selected, choose a country to see more details here

Empty Search Results

Empty search results are common enough that Apple added a specialized version of ContentUnavailableView for searchable views. Using it in a searchable country list with a SwiftData query providing the search results (some details omitted):

struct CountryList: View {
  @Binding private var selection: Country.ID?
    
  @Query(sort: [SortDescriptor(\Country.name,
         comparator: .localizedStandard)])
  private var countries: [Country]
  
  init(selection: Binding<Country.ID?>, 
       predicate: Predicate<Country>? = nil) { ... }
  
  var body: some View {
    List(selection: $selection) {
      ForEach(countries) { country in
        CountryRow(country: country)
      }
    }
    .overlay {
      if countries.isEmpty {
        ContentUnavailableView.search
      }
    }
  }
}

If the collection of countries is empty I overlay the search content unavailable view over the list view:

iPhone showing search for Zz with empty result. No results for &ldquo;Zz&rdquo;. Check the spelling or try a new search

The content view extracts the search query from the searchable view which in my case is the parent view (see making SwiftUI views searchable):

CountryList(selection: $selection, predicate: scope.predicate)
  .searchable(text: $scope.beginsWith)

UIContentUnavailableConfiguration (UIKit)

It’s good to see that Apple is not keeping the new features just for SwiftUI. The content unavailable views are also usable with UIKit.

In iOS 17, UIViewController has a contentUnavailableConfiguration property. This is an optional that you typically set to an instance of UIContentUnavailableConfiguration when you want the view controller to show the empty state.

UIKit provides three default configurations for empty, loading, and search empty states. (I’m not sure why SwiftUI didn’t also get the loading version?).

You can set the property directly, but the Apple sample code appears to prefer calling setNeedsUpdateContentUnavailableConfiguration anytime you update content and then override updateContentUnavailableConfiguration(using:) in your view controller to update the configuration property.

Empty Detail View

Let’s look at the same two examples, first the empty detail view when the sidebar has no selection. My detail view controller has an optional country property which when set updates the user interface:

final class CountryViewController: UIViewController {
  var country: Country? {
    didSet {
      configureView()
    }
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    configureView()
  }
}

When my view is first loaded and any time my country property changes I call configureView to update the user interface. That’s also a good time to update my content unavailable configuration:

private func configureView() {
  title = country?.name
  ...   
  if #available(iOS 17.0, *) {
    setNeedsUpdateContentUnavailableConfiguration()
  }
}

When the view controller calls my updateContentUnavailableConfiguration method I check if I have a country and if not set an empty content configuration:

@available(iOS 17.0, *)
override func updateContentUnavailableConfiguration(using state:
  UIContentUnavailableConfigurationState) {
  var config: UIContentUnavailableConfiguration?
  if country == nil {
    var empty = UIContentUnavailableConfiguration.empty()
    empty.background.backgroundColor = .systemBackground
    empty.image = UIImage(systemName: "map.circle.fill")
    empty.text = "No country selected"
    empty.secondaryText = "Choose a country to see more details here"
    config = empty
  }
  contentUnavailableConfiguration = config
}

The content configuration overlays the normal view so make sure to set the background if you don’t want to see the rest of the user interface.

Note that if I do have a country to show the contentUnavailableConfiguration property is nil removing any content unavailable view.

Empty Search Results

I’m driving my search results from a fetched results controller. The controllerDidChangeContent delegate method gives me a convenient point to trigger an update of the content unavailable configuration:

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
  if #available(iOS 17, *) {
    setNeedsUpdateContentUnavailableConfiguration()
  }
}

Then overriding the updateContentUnavailableConfiguration method I check if I have any fetched objects and if not set the default search configuration:

var fetchedObjectCount: Int {
  fetchedResultsController.fetchedObjects?.count ?? 0
}

@available(iOS 17.0, *)
override func updateContentUnavailableConfiguration(using state: UIContentUnavailableConfigurationState) {
  var config: UIContentUnavailableConfiguration?
  if fetchedObjectCount == 0 {
    config = .search()
  }
  contentUnavailableConfiguration = config
}

Learn More