Every app eventually needs a "nothing here yet" screen and a "no results for your search" screen, and most teams hand-roll both with a VStack of an SF Symbol, a title, and a subtitle — then let 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
- Matches system apps like Mail and Files visually for free — spacing, dimming, and Dynamic Type all handled
ContentUnavailableView.search(text:)is a ready-made "No results for '…'" state, 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 give it — it has no idea whether a search is actually active or whether your data just hasn't loaded yet. You still have to gate it behind your own emptiness check on the filtered results, as in the example above, or it'll flash on screen for a fresh, unsearched list before real content arrives. It also fills whatever space it's placed in via an internal .frame(maxWidth:maxHeight:), so nesting it inside a ScrollView or as a List row produces odd, oversized layout — place it as a direct sibling of your content inside an if/Group, not inside scrollable content.