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 — DFSidebar on regular width, DFTabBar on 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 and is just the sidebar view — you compose the detail pane yourself (naturally via NavigationSplitView). DFTabBar uses Binding (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 } } Enter fullscreen mode Exit fullscreen mode 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, so bridge the optional. let tabSelection = Binding( 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() } } } Enter fullscreen mode Exit fullscreen mode 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) } ) } } } } } Enter fullscreen mode Exit fullscreen mode 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 } } } Enter fullscreen mode Exit fullscreen mode 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 } } Enter fullscreen mode Exit fullscreen mode 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) } } Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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") { } } } Enter fullscreen mode Exit fullscreen mode Watch the API cues: DFPicker takes a @ViewBuilder content closure, not an options: array. Feed it ForEach and .tag(). DFCheckbox takes label: as a keyword argument, not a positional string. DFSlider uses in: 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) } } Enter fullscreen mode Exit fullscreen mode 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. → DesignFoundation Pro
Part 2 — Search, palette, and settings
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.