Part 3 — Custom themes, multi-window, and polish Level: Advanced · Time: ~60 minutes · Builds on: Part 2 — Search, palette, and settings The gap between "a nice SwiftUI project" and "an app that feels professional" lives entirely in the details you skip when you're moving fast: brand-owned theming, real multi-window support on macOS, business-rule validation, tabular data that scales, and the ambient polish (alerts, popovers, tooltips, accessibility) that users notice only when it's missing. This tutorial closes every one of those gaps. By the end you'll have a Contacts app that could stand next to a paid product without embarrassment — and the muscle memory to know exactly which corners are worth cutting on the next one. What we're adding A custom DFTheme — colors, radius, materials, typography — that is your brand, in light and dark variants. A dedicated macOS detail window opened via @Environment(\.openWindow), with WindowGroup(id:for:). A custom DFFieldValidator — unique-email checking against your store. A companies screen using DFTable (sortable, read-only) and a rate-card screen using DFDataGrid (editable). Ambient UI — .dfAlert(configuration:) for destructive confirms, .dfPopover for inline role selection, .dfTooltip on icon-only buttons. An accessibility pass — labels, hints, traits, rotor entries. 1. Own your theme Presets are great for prototyping and terrible for a brand. Extract the theme into its own file and make it yours. // Theme+Brand.swift import SwiftUI import DesignFoundation extension DFTheme { static let brandLight: DFTheme = { var t = DFTheme.slateLight // start from a known-good base t.colors.primary = Color(red: 0.36, green: 0.16, blue: 0.85) // brand purple t.colors.accent = Color(red: 0.98, green: 0.42, blue: 0.35) // warm coral t.colors.background = Color(red: 0.98, green: 0.98, blue: 0.99) t.colors.surface = .white t.colors.surfaceElevated = Color(red: 0.96, green: 0.96, blue: 0.98) t.colors.textPrimary = Color(red: 0.08, green: 0.09, blue: 0.16) t.colors.textSecondary = Color(red: 0.42, green: 0.44, blue: 0.52) t.colors.success = Color(red: 0.11, green: 0.65, blue: 0.42) t.colors.warning = Color(red: 0.92, green: 0.63, blue: 0.10) t.colors.destructive = Color(red: 0.85, green: 0.20, blue: 0.28) t.radius.md = 10 t.radius.lg = 14 // Component-level polish t.components.button = DFButtonTokens(cornerRadius: 8) t.components.card = DFCardTokens(padding: 18) return t }() static let brandDark: DFTheme = { var t = DFTheme.slateDark t.colors.primary = Color(red: 0.62, green: 0.48, blue: 0.98) t.colors.accent = Color(red: 1.00, green: 0.55, blue: 0.48) t.colors.background = Color(red: 0.06, green: 0.07, blue: 0.11) t.colors.surface = Color(red: 0.10, green: 0.11, blue: 0.16) t.colors.surfaceElevated = Color(red: 0.14, green: 0.16, blue: 0.22) t.colors.textPrimary = Color(red: 0.94, green: 0.96, blue: 0.98) t.colors.textSecondary = Color(red: 0.66, green: 0.70, blue: 0.78) t.colors.success = Color(red: 0.36, green: 0.85, blue: 0.60) t.colors.warning = Color(red: 0.99, green: 0.78, blue: 0.32) t.colors.destructive = Color(red: 1.00, green: 0.44, blue: 0.48) t.radius.md = 10 t.radius.lg = 14 t.components.button = DFButtonTokens(cornerRadius: 8) t.components.card = DFCardTokens(padding: 18) return t }() } Enter fullscreen mode Exit fullscreen mode Wire it at the scene root. Replace the .dfThemePreset(.slate) from Part 1 with an explicit light/dark switch — this is where .dfTheme(_:) earns its keep. Because @Environment(\.colorScheme) is a per-view concept, we put the switch inside a small wrapper view rather than on the App itself. struct ThemedRoot: View { @Environment(\.colorScheme) private var scheme let content: () -> Content var body: some View { content() .dfTheme(scheme == .dark ? .brandDark : .brandLight) } } @main struct ContactsApp: App { @State private var store = ContactStore() var body: some Scene { WindowGroup { ThemedRoot { RootView() .environment(store) .dfToast() } } #if os(macOS) WindowGroup("Contact Detail", id: "contact-detail", for: UUID.self) { $id in ThemedRoot { ContactDetailWindow(contactID: id) .environment(store) } } .defaultSize(width: 720, height: 560) .windowStyle(.titleBar) #endif } } Enter fullscreen mode Exit fullscreen mode The store is declared once on the App and injected into every scene, so the detail window sees the same contacts as the primary window. Two things happened. First, every DF surface in the app now speaks your brand. Second, on macOS you just declared a second window group — we'll open it in the next section. If you want to disable the iOS 26 / macOS 26 Liquid Glass appearance and fall back to the flat color-token surfaces for every .glass style, one line: var t = DFTheme.brandLight t.materials.preferLiquidGlass = false Enter fullscreen mode Exit fullscreen mode Pro moment. Pro ships a theme editor — a runtime panel that mutates the active DFTheme and lets designers iterate on colors, radius, and component tokens without opening Xcode. When you're ready to hand tokens to your design partner, that panel is the deliverable. 2. Multi-window on macOS @Environment(\.openWindow) opens the WindowGroup you registered above, passing a UUID value that becomes the window's identity. Only the app-scene declaration needs #if os(macOS); the view code stays cross-platform. The detail window: struct ContactDetailWindow: View { let contactID: UUID? @Environment(ContactStore.self) private var store var body: some View { if let id = contactID, let contact = store.contacts.first(where: { $0.id == id }) { ContactDetailView(contact: contact) } else { DFEmptyState( icon: "person.crop.circle.badge.questionmark", title: "Contact not found" ) } } } Enter fullscreen mode Exit fullscreen mode The trigger on the list row: struct ContactListView: View { // ...existing state... #if os(macOS) @Environment(\.openWindow) private var openWindow #endif // Inside the DFList row: DFListRow( title: contact.name, subtitle: contact.email, showDisclosure: true, leading: { DFAvatar(contact.initials) }, trailing: { HStack(spacing: 6) { DFBadge(text: contact.role) #if os(macOS) Button { openWindow(id: "contact-detail", value: contact.id) } label: { Image(systemName: "arrow.up.forward.square") } .buttonStyle(.borderless) .dfTooltip("Open in new window") #endif } } ) } Enter fullscreen mode Exit fullscreen mode On iOS, users tap the row and get in-place navigation. On macOS, they get both — inline navigation and a "pop out to a real window" affordance. Same code, one guard, in one place. The WindowGroup also needs store injected. Pass it via an app-level Environment object (@State on the App, .environment(store) on every window's root), or use a shared container. The mechanism is orthogonal to DF. 3. Custom validators The built-in validators cover the syntax layer. Business rules — uniqueness, domain whitelisting, phone-number parsing — you write yourself. DFFieldValidator is a single-method protocol. struct UniqueEmailValidator: DFFieldValidator { let store: ContactStore let excluding: UUID? // when editing an existing contact let message: String init(store: ContactStore, excluding: UUID? = nil, message: String = "That email is already in use.") { self.store = store self.excluding = excluding self.message = message } func validate(_ value: String) -> String? { let normalised = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalised.isEmpty else { return nil } // let RequiredValidator handle empty let clashes = store.contacts.contains { c in c.email.lowercased() == normalised && c.id != excluding } return clashes ? message : nil } } Enter fullscreen mode Exit fullscreen mode Return nil for valid, or the error string for invalid. Compose it with the built-ins on the same field: @State private var form: DFFormState init(store: ContactStore) { _form = State(initialValue: DFFormState(fields: [ "name": [DFRequiredValidator()], "email": [DFRequiredValidator(), DFEmailValidator(), UniqueEmailValidator(store: store)], "role": [DFRequiredValidator()], ])) } Enter fullscreen mode Exit fullscreen mode Order matters: DFRequiredValidator catches empty first, DFEmailValidator catches malformed second, UniqueEmailValidator catches collisions last. Only the first failing validator's message shows. Custom validators can be stateful (as above), async-adjacent (call a cache), or purely functional (a regex wrapper). The protocol doesn't care. 4. Tabular data — DFTable and DFDataGrid For a companies roster. DFTable requires Row: Identifiable & Sendable, so both conformances go on the model. struct Company: Identifiable, Hashable, Sendable { let id = UUID() var name: String var domain: String var employees: Int var arr: Double } struct CompaniesView: View { @State private var companies: [Company] = seed @State private var sortBy: String = "name" private var sorted: [Company] { switch sortBy { case "name": companies.sorted { $0.name $1.employees } case "arr": companies.sorted { $0.arr > $1.arr } default: companies } } var body: some View { let columns: [DFTableColumn] = [ DFTableColumn(id: "name", title: "Name") { $0.name }, DFTableColumn(id: "domain", title: "Domain") { $0.domain }, DFTableColumn(id: "employees", title: "Employees") { "\($0.employees)" }, DFTableColumn(id: "arr", title: "ARR") { $0.arr.formatted(.currency(code: "USD")) }, ] return VStack { HStack { DFPicker("Sort by", selection: $sortBy) { Text("Name").tag("name") Text("Employees").tag("employees") Text("ARR").tag("arr") } Spacer() } .padding(.horizontal) DFTable(data: sorted, columns: columns) } .dfNavigationBar(title: "Companies") { } } private static var seed: [Company] { /* ... */ [] } } Enter fullscreen mode Exit fullscreen mode The [DFTableColumn] type annotation is required — without it Swift can't infer the closure parameter type and the file won't compile. That's the single non-obvious rule of DFTable. For an editable rate card, DFDataGrid follows the same column shape but takes closures that produce values you own. Rate needs the same Identifiable & Sendable conformances: struct Rate: Identifiable, Hashable, Sendable { let id = UUID() var tier: String var hoursIncluded: Int var hourly: Double } struct RateCardView: View { @State private var rates: [Rate] = /* ... */ [] var body: some View { let columns: [DFDataGridColumn] = [ DFDataGridColumn(id: "tier", title: "Tier") { $0.tier }, DFDataGridColumn(id: "hours", title: "Hours included") { "\($0.hoursIncluded)" }, DFDataGridColumn(id: "rate", title: "Rate") { $0.hourly.formatted(.currency(code: "USD")) }, ] DFDataGrid(data: rates, columns: columns) .dfNavigationBar(title: "Rate Card") { } } } Enter fullscreen mode Exit fullscreen mode Two things worth internalizing: The columns' value closures return String (or anything you format to a string) — the grid renders text cells, not arbitrary views. Format currency and dates at the call site. The data parameter is data:, not rows:. Same for DFTable. The naming stays consistent across the tabular components. Pro moment. Pro's Analytics vertical wraps DFTable and DFDataGrid in dashboard shells with filters, saved views, CSV export, and totals rows. You're not building the same "table with a filter bar above it" for the fifth project. 5. Ambient UI — alerts, popovers, tooltips These are all view modifiers. There is no DFAlert(isPresented:) or DFPopover(isPresented:) you construct directly — that's a common mistake worth naming explicitly. Delete confirmation with .dfAlert: struct ContactDetailView: View { let contact: Contact @Environment(ContactStore.self) private var store @Environment(\.dismiss) private var dismiss @State private var confirmDelete = false var body: some View { ScrollView { // ... card content from Part 1 ... HStack { DFButton("Delete", role: .destructive) { confirmDelete = true } .dfButtonStyle(.ghost) } } .dfAlert(isPresented: $confirmDelete, configuration: DFAlertConfiguration( title: "Delete \(contact.name)?", message: "This can't be undone.", actions: [ DFAlertAction(title: "Cancel", role: .cancel), DFAlertAction(title: "Delete", role: .destructive) { store.remove(contact) DFToastQueue.shared.show(text: "Contact deleted", severity: .success) dismiss() } ] )) } } Enter fullscreen mode Exit fullscreen mode Inline role editor with .dfPopover: struct RolePill: View { @Binding var role: String @State private var showPicker = false let options = ["Engineering", "Compilers", "Math", "Networking", "Design", "Ops"] var body: some View { Button { showPicker.toggle() } label: { DFBadge(text: role) } .buttonStyle(.plain) .dfPopover(isPresented: $showPicker, attachmentAnchor: .point(.bottom)) { VStack(alignment: .leading, spacing: 4) { ForEach(options, id: \.self) { option in Button { role = option showPicker = false } label: { HStack { DFText(option, scale: .body) Spacer() if option == role { DFIcon("checkmark", size: 14) } } .padding(.vertical, 6) .padding(.horizontal, 10) } .buttonStyle(.plain) } } .padding(6) .frame(minWidth: 180) } } } Enter fullscreen mode Exit fullscreen mode Tooltips on icon-only buttons: Button { openWindow(id: "contact-detail", value: contact.id) } label: { Image(systemName: "arrow.up.forward.square") } .dfTooltip("Open in new window") Enter fullscreen mode Exit fullscreen mode .dfTooltip("...") takes a plain string describing the view it's attached to. No hover state to manage, no bubble to position. On macOS it renders on hover; on iOS it becomes an accessibility hint. 6. Accessibility pass DF's semantic components already carry the important stuff — DFButton is a Button, DFToggle is a Toggle, DFTextField labels itself. What you owe: Custom composed views need explicit labels. Anywhere you built a tappable area out of primitives (like the RolePill above), add .accessibilityLabel and .accessibilityHint: Button { showPicker.toggle() } label: { DFBadge(text: role) } .accessibilityLabel("Role: \(role)") .accessibilityHint("Double tap to change role") Enter fullscreen mode Exit fullscreen mode Icon-only buttons need labels even if they have tooltips. VoiceOver doesn't see the tooltip. Button { /* ... */ } label: { Image(systemName: "arrow.up.forward.square") } .accessibilityLabel("Open in new window") .dfTooltip("Open in new window") Enter fullscreen mode Exit fullscreen mode Group related items with .accessibilityElement(children: .combine) so VoiceOver announces "Ada Lovelace, ada@analytical.co, Engineering" as one row rather than three. Verify with the Accessibility Inspector (Xcode → Open Developer Tool → Accessibility Inspector). Run through the app with rotor navigation — if you can't reach or activate everything with rotor + activate, neither can a screen-reader user. DF hands you a passing grade on 80% of accessibility. The remaining 20% is your composition — and it's the difference between a product a fraction of your users can't use and one that everyone can. What you built A brand-owned DFTheme in light and dark variants, with component-level polish tokens. A macOS detail window opened via @Environment(\.openWindow) — one #if os(macOS) in the scene declaration. A custom UniqueEmailValidator composed with the built-ins. A sortable companies table and an editable rate-card grid using DFTable / DFDataGrid. Ambient UI — destructive alerts, inline popover editing, hover tooltips. An accessibility pass with explicit labels on every composed control. That's the full production stack. Every surface a real app needs, from theming to windowing to tabular data to the fifty small details reviewers notice. All of it composed from DF primitives; none of it hand-rolled. The series arc Three tutorials, one journey: Part 1 — the vocabulary. Ship the app. Part 2 — the shell. Ship the product. Part 3 — the polish. Ship the brand. If you've made it here, you now have the muscle memory to build any surface a modern SwiftUI app needs, and the judgment to know when to stop building and buy the leverage. The Pro pitch, in full Everything above is roughly 30% of what a production app needs — the components, the theme, the shell, the ambient UI. The other 70% is screens, and that's what DesignFoundation Pro is: Auth vertical — sign in / sign up / OTP / forgot password / welcome, wired and themed. Dashboard blocks — stat cards, metric grids, progress rings, line/bar/donut charts. Full vertical screens — AI Chat, Analytics, CRM, Documents, E-commerce, Onboarding, Project Manager, Settings, Social. Shell layouts — sidebar-plus-inspector, icon rail, floating panel, adaptive workspace, file tree, workspace switcher. Data blocks — activity feeds, search results, skeleton loaders, data grids. Every one of them uses the same theme system you just built. Same ergonomics, same platform coverage, same customization surface — further up the stack. If your next project needs any of the above, Pro is the difference between shipping in a week and shipping in a month. → DesignFoundation Pro Thanks for building along. If the series was useful, star the repo — it helps other developers find it.
Part 3 — Custom themes, multi-window, and polish
Full Article
📰 Original Source
Read 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.