Every app eventually needs a "nothing here yet" screen. Most need a "no results for your search" screen too.
So teams hand-roll both. A VStack, an SF Symbol, a title, a subtitle. Then the spacing and styling quietly drift between screens.
#The old way
struct EmptyBookmarksView: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "bookmark.slash")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No Bookmarks")
.font(.headline)
Text("Save articles to find them here later.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#The new way
import SwiftUI
struct BookmarksView: View {
let bookmarks: [String]
@State private var searchText = ""
var filtered: [String] {
guard !searchText.isEmpty else { return bookmarks }
return bookmarks.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
Group {
if bookmarks.isEmpty {
ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark.slash",
description: Text("Save articles to find them here later.")
)
} else if filtered.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(filtered, id: \.self) { Text($0) }
}
}
.searchable(text: $searchText)
}
}
#Why it matters
- Looks like Mail and Files for free. Spacing, dimming, and Dynamic Type are all handled
ContentUnavailableView.search(text:)is a ready-made "No results for '…'" state, with no string interpolation to get wrong- One type covers both a fully custom empty state and the standard search-empty case
- Icon and text styling adapt automatically across size classes and platforms
#Gotcha
ContentUnavailableView.search(text:) only formats the message around the text you hand it. It has no idea whether a search is actually active, or whether your data just hasn't loaded yet. Gate it behind your own emptiness check on the filtered results, the way the example above does, or it'll flash on screen for a fresh, unsearched list before the real content arrives.
It also fills whatever space it's placed in, via an internal .frame(maxWidth:maxHeight:). Nest it in a ScrollView, or drop it in as a List row, and the layout comes out odd and oversized. Place it as a direct sibling of your content inside an if/Group. Not inside scrollable content.