Part 3 โ€” Custom themes, multi-window, and polish
DEV Community

Part 3 - Custom themes, multi-window, and polish

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
    }()
}

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<Content: View>: 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
    }
}

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

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")
        }
    }
}

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
            }
        }
    )
}

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
    }
}

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()],
    ]))
}

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.name }
        case "employees": companies.sorted { $0.employees > $1.employees }
        case "arr": companies.sorted { $0.arr > $1.arr }
        default: companies
        }
    }

    var body: some View {
        let columns: [DFTableColumn<Company>] = [
            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] { /* ... */ [] }
}

The [DFTableColumn<Company>] 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<Rate>] = [
            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") { }
    }
}

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()
                }
            ]
        ))
    }
}

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)
        }
    }
}

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")

.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

Comments

No comments yet. Start the discussion.