Part 2 — Search, palette, and settings
Level: Intermediate · Time: ~35 minutes · Builds on: Part 1 — Contacts app
Part 1 got you shipping. This one gets you productive. We'll take the Contacts app and give it the ergonomics real users expect: an adaptive sidebar that becomes a tab bar on iPhone, a command palette on ⌘K, honest loading states while data comes in, and a proper settings screen. Zero #if os guards. Zero re-rolled controls.
What we're adding
- An adaptive shell —
DFSidebaron regular width,DFTabBaron compact. - A search field at the top of the list, filtering as you type.
- A ⌘K command palette exposing every action in the app.
- Skeleton loaders for a simulated slow fetch.
- A settings screen — notifications toggle, density picker, sync-interval slider, pinned-since date picker, beta-features checkbox.
- Per-component token overrides on the settings screen, without forking the theme.
1. Shell: sidebar on wide, tab bar on narrow
The routing decision — sidebar vs tab bar — should be data, not a view hierarchy. Enumerate your sections once, then feed the two components the shapes they want.
API note. DFSidebar uses Binding<String?> and is just the sidebar view — you compose the detail pane yourself (naturally via NavigationSplitView). DFTabBar uses Binding<String> (non-optional) and does take a content builder that receives the selected ID. Both use plain String IDs, so we keep a simple Section enum and pass rawValue at the boundary.
enum Section: String, CaseIterable, Identifiable, Hashable {
case contacts, favorites, archive, settings
var id: String { rawValue }
var label: String {
switch self {
case .contacts: "Contacts"
case .favorites: "Favorites"
case .archive: "Archive"
case .settings: "Settings"
}
}
var icon: String {
switch self {
case .contacts: "person.2.fill"
case .favorites: "star.fill"
case .archive: "archivebox.fill"
case .settings: "gear"
}
}
static func from(_ id: String?) -> Section {
id.flatMap(Section.init(rawValue:)) ?? .contacts
}
}
Now the shell. @Environment(\.horizontalSizeClass) picks the right container — everything inside is identical.
struct RootView: View {
@State private var store = ContactStore()
@State private var selectionID: String? = Section.contacts.rawValue
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSize
#endif
var body: some View {
Group {
#if os(iOS)
if hSize == .compact {
tabShell
} else {
sidebarShell
}
#else
sidebarShell
#endif
}
.environment(store)
}
private var sidebarShell: some View {
NavigationSplitView {
DFSidebar(
selection: $selectionID,
sections: [
DFSidebarSection(id: "main", title: "Workspace", items: Section.allCases.map {
DFSidebarItem(id: $0.rawValue, icon: $0.icon, label: $0.label)
})
]
)
.frame(minWidth: 220)
} detail: {
NavigationStack { destination(for: Section.from(selectionID)) }
}
}
private var tabShell: some View {
// DFTabBar's selection is Binding<String>, so bridge the optional.
let tabSelection = Binding<String>(
get: { selectionID ?? Section.contacts.rawValue },
set: { selectionID = $0 }
)
return DFTabBar(
selection: tabSelection,
items: Section.allCases.map { DFTabItem(id: $0.rawValue, icon: $0.icon, label: $0.label) }
) { id in
NavigationStack { destination(for: Section.from(id)) }
}
}
@ViewBuilder private func destination(for section: Section) -> some View {
switch section {
case .contacts: ContactListView()
case .favorites: ContactListView(filter: .favoritesOnly)
case .archive: ContactListView(filter: .archivedOnly)
case .settings: SettingsView()
}
}
}
The only guard in this file is the one that reads horizontalSizeClass — because that concept doesn't exist on macOS. The DF components themselves need no platform branching.
Pro moment. Pro ships four shell layouts on top of
DFSidebar— sidebar-with-inspector (three-pane like Mail), icon-rail (like Slack), floating-panel (like Arc), and adaptive-workspace (multi-column with resizable dividers). Each one is drop-in.
2. Search on the list
Search is a DFTextField with a leading icon and a filter applied to the data source. Note the required label form — leading: must be labeled because DF has overloads.
struct ContactListView: View {
enum Filter { case all, favoritesOnly, archivedOnly }
@Environment(ContactStore.self) private var store
@State private var query = ""
@State private var showAdd = false
var filter: Filter = .all
private var visible: [Contact] {
let scope: [Contact] = switch filter {
case .all: store.contacts
case .favoritesOnly: store.contacts.filter(\.isFavorite)
case .archivedOnly: store.contacts.filter(\.isArchived)
}
guard !query.isEmpty else { return scope }
return scope.filter {
$0.name.localizedCaseInsensitiveContains(query) ||
$0.email.localizedCaseInsensitiveContains(query)
}
}
var body: some View {
VStack(spacing: 0) {
DFTextField(
"Search",
text: $query,
placeholder: "Search name or email",
leading: { Image(systemName: "magnifyingglass") }
)
.padding()
content
}
.dfNavigationBar(title: "Contacts") {
DFButton("Add") { showAdd = true }
.dfButtonStyle(.tinted)
}
.navigationDestination(for: Contact.self) { ContactDetailView(contact: $0) }
.dfSheet(isPresented: $showAdd) {
AddContactView().environment(store)
}
}
@ViewBuilder private var content: some View {
if store.isLoading {
LoadingList()
} else if visible.isEmpty {
DFEmptyState(
icon: query.isEmpty ? "person.crop.circle.badge.plus" : "magnifyingglass",
title: query.isEmpty ? "No contacts yet" : "No matches",
message: query.isEmpty ? "Add your first person to get started." : "Try a different search.",
actionTitle: query.isEmpty ? "Add contact" : nil,
onAction: query.isEmpty ? { showAdd = true } : nil
)
} else {
DFList(visible) { contact in
NavigationLink(value: contact) {
DFListRow(
title: contact.name,
subtitle: contact.email,
showDisclosure: true,
leading: { DFAvatar(contact.initials) },
trailing: { DFBadge(text: contact.role) }
)
}
}
}
}
}
The empty state's message and action swap based on whether the user searched — a small detail that separates a demo from a product.
3. ⌘K command palette
The palette is a modifier, not a view. Give it items, wire an onSelect, and you're done. On macOS, ↑/↓ navigates, Return selects, Escape dismisses. It's a case-insensitive substring filter on title + subtitle.
Extend RootView:
struct RootView: View {
@State private var store = ContactStore()
@State private var selection: Section = .contacts
@State private var showPalette = false
@State private var showAdd = false
private var paletteItems: [DFCommandPaletteItem] {
[
DFCommandPaletteItem(title: "New contact", subtitle: "⌘N", icon: "person.crop.circle.badge.plus"),
DFCommandPaletteItem(title: "Go to contacts", subtitle: "⌘1", icon: "person.2.fill"),
DFCommandPaletteItem(title: "Go to favorites", subtitle: "⌘2", icon: "star.fill"),
DFCommandPaletteItem(title: "Go to archive", subtitle: "⌘3", icon: "archivebox.fill"),
DFCommandPaletteItem(title: "Open settings", subtitle: "⌘,", icon: "gear"),
]
}
var body: some View {
shell
.environment(store)
.dfCommandPalette(isPresented: $showPalette, items: paletteItems) { selected in
handle(selected)
}
#if os(macOS)
.background(
Button("") { showPalette = true }
.keyboardShortcut("k", modifiers: .command)
.opacity(0)
)
#endif
.dfSheet(isPresented: $showAdd) {
AddContactView().environment(store)
}
}
private var shell: some View {
// ... (sidebar/tab code from step 1)
}
private func handle(_ item: DFCommandPaletteItem) {
switch item.title {
case "New contact": showAdd = true
case "Go to contacts": selection = .contacts
case "Go to favorites": selection = .favorites
case "Go to archive": selection = .archive
case "Open settings": selection = .settings
default: break
}
}
}
The hidden Button bound to ⌘K is the standard SwiftUI trick to register a global keyboard shortcut without a visible control. iPhone users invoke the palette from the "Add" button's long-press menu or a button in your toolbar — pick whatever fits your app.
Pro moment. Pro's shell layouts wire the palette by default, register your app's actions automatically from the section registry, and add a persistent search entry point in the sidebar header. You don't build the plumbing again in each project.
4. Honest loading with skeletons
Async loading without a skeleton is the same UX crime as a blank white flash. Replace both with a DFSkeleton stack that mirrors the shape of the real list.
Update the store:
@Observable
final class ContactStore {
var contacts: [Contact] = []
var isLoading = true
init() {
Task { await load() }
}
func load() async {
isLoading = true
try? await Task.sleep(for: .seconds(1.2))
contacts = [
Contact(name: "Ada Lovelace", email: "ada@analytical.co", role: "Engineering"),
Contact(name: "Grace Hopper", email: "grace@compiler.dev", role: "Compilers"),
Contact(name: "Katherine Johnson", email: "kj@trajectory.nasa", role: "Math"),
Contact(name: "Radia Perlman", email: "radia@spanning.net", role: "Networking"),
]
isLoading = false
}
}
And the skeleton row — a mirror of DFListRow in silhouette:
struct LoadingList: View {
var body: some View {
VStack(spacing: 12) {
ForEach(0..<6, id: \.self) { _ in
HStack(spacing: 12) {
DFSkeleton(shape: .circle)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 6) {
DFSkeleton().frame(width: 160, height: 14)
DFSkeleton().frame(width: 220, height: 12)
}
Spacer()
DFSkeleton(shape: .capsule)
.frame(width: 60, height: 22)
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
DFSkeleton takes a shape (.rectangle, .roundedRectangle(cornerRadius:), .circle, .capsule) and gets its size from .frame(). It shimmers automatically — you don't drive an animation.
For progress bars where you actually know the fraction:
DFProgressBar(value: 0.7) // linear determinate
DFProgressBar(variant: .indeterminate) // spinner-style
5. Settings screen
Every control DF ships, one screen. This is the "prove the vocabulary" chapter.
struct SettingsView: View {
@State private var notifications = true
@State private var syncMinutes: Double = 15
@State private var density: Density = .comfortable
@State private var pinnedSince: Date = .now
@State private var betaFeatures = false
enum Density: String, CaseIterable, Identifiable {
case cozy, comfortable, spacious
var id: String { rawValue }
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
DFCard {
VStack(alignment: .leading, spacing: 16) {
DFText("Notifications", scale: .headline)
DFToggle("Enable notifications", isOn: $notifications)
DFDivider()
DFText("Sync every \(Int(syncMinutes)) minutes", scale: .caption)
DFSlider("Sync interval", value: $syncMinutes, in: 5...120)
}
}
DFCard {
VStack(alignment: .leading, spacing: 16) {
DFText("Appearance", scale: .headline)
DFPicker("Density", selection: $density) {
ForEach(Density.allCases) { d in
Text(d.rawValue.capitalized).tag(d)
}
}
}
}
DFCard {
VStack(alignment: .leading, spacing: 16) {
DFText("Data", scale: .headline)
DFDatePicker("Pinned since", selection: $pinnedSince)
DFDivider()
DFCheckbox(isChecked: $betaFeatures, label: "Enable beta features")
}
}
HStack {
Spacer()
DFButton("Reset") {
notifications = true
syncMinutes = 15
density = .comfortable
pinnedSince = .now
betaFeatures = false
DFToastQueue.shared.show(text: "Settings reset", severity: .info)
}
.dfButtonStyle(.ghost)
DFButton("Save") {
DFToastQueue.shared.show(text: "Settings saved", severity: .success)
}
}
}
.padding()
}
.dfNavigationBar(title: "Settings") { }
}
}
Watch the API cues:
-
DFPickertakes a@ViewBuildercontent closure, not anoptions:array. Feed itForEachand.tag(). -
DFCheckboxtakeslabel:as a keyword argument, not a positional string. -
DFSliderusesin:for the range.
Pro moment. Pro ships a full Settings vertical — Account, Appearance, Notifications, Billing, Danger Zone — with the sidebar-plus-inspector shell already wired. Rename the strings, hook up your handlers, ship.
6. Bend the theme on one screen only
You want the Settings cards to feel slightly airier than the rest of the app — bigger padding, sharper corners — without redesigning the theme. That's what DFTheme.components is for.
Add this on the Settings view root:
struct SettingsView: View {
@Environment(\.dfTheme) private var baseTheme
private var settingsTheme: DFTheme {
var theme = baseTheme
theme.components.card = DFCardTokens(padding: 20)
theme.components.button = DFButtonTokens(cornerRadius: 4)
return theme
}
var body: some View {
ScrollView { /* ... */ }
.dfTheme(settingsTheme)
}
}
Nothing else in the app changes. Every field on DFCardTokens/DFButtonTokens/etc. is optional — nil inherits from the base theme. You override the two things you care about and leave the rest alone.
This is the pattern for the more advanced overrides in Part 3 — including full brand-owned themes.
What you built
- Adaptive shell that becomes a sidebar on iPad/macOS and a tab bar on iPhone, with no view-code duplication.
- Live search over the list with a leading-icon text field.
- ⌘K command palette wired to every top-level action.
- Skeleton loaders that mirror the real row shape while data arrives.
- A settings screen exercising every DF control primitive.
- Per-component token overrides scoped to a single screen.
If you shipped Part 1 as "week one," this is "week two." You now have the shell every real app needs.
Coming in Part 3
The polish that separates "nice project" from "shipped product": a hand-built DFTheme that is your brand, a dedicated macOS window opened via @Environment(\.openWindow), a custom DFFieldValidator for domain rules, DFTable/DFDataGrid for tabular data, and the ambient UI — .dfAlert, .dfPopover, .dfTooltip — that users notice only when it's missing.
→ Part 3 — Custom themes, multi-window, and polish
Skip the shell work with Pro
The shell you just built by hand — sidebar-with-inspector, adaptive tab bar, command palette wiring, settings vertical — ships in Pro as production-ready screens. If your next project needs any of it, Pro pays for itself the first afternoon.
Top comments (0)