Settings panes

Your droplet's own page inside Droppy's Settings.

A droplet with anything to configure provides a settings pane. It is rendered on the droplet's page in Droppy's Settings window, inside the same grouped form every native page is built from.

Build it from the SDK's settings components and it matches the rest of the app, including after Droppy's styling moves, because those components are kept in step with the host.

Providing one

extension WorldClockDroplet: SettingsPaneProviding {
    public func makeSettingsPane(context: SettingsPaneContext) -> AnyView {
        AnyView(WorldClockSettings(droplet: self))
    }

    public var settingsSearchEntries: [SettingsSearchEntry] {
        [SettingsSearchEntry(title: "Show seconds", keywords: ["time", "clock"])]
    }
}

settingsSearchEntries puts your rows in Droppy's Settings search, so a user who types "seconds" lands on your pane instead of failing to find it. A result opens your droplet's page at its top; an entry's anchorID is not honoured yet.

Never repeat the page header

Droppy draws the top of your droplet's page itself, above your pane: your icon, your name, your summary and the maker badge. Your pane starts with its first setting. Do not open it with a card, a hero or a title that shows your icon or your name again; it reads as a mistake, twice the same thing stacked. The Droplet Store's validator refuses a pane that loads your own icon or shows your name as a title. An About page or a welcome window of your own, which have no such header, may show both.

The rules, in one place

These are the settings mistakes review sends back most. Each is explained further down this page or in Design guidelines.

  • Root the pane in DropletSettingsPane. A pane whose root is a VStack of cards is the legacy shape: it still works, but it never becomes a native page.
  • A page is a group of sections, never a VStack. Cards and DropletSettingsSections go straight inside the pane. A VStack, a frame or a padding around them inside the Form makes them one row.
  • No DropletSettingsDivider in a native pane. The Form separates the rows.
  • Section headers are settingsSectionHeader("Title"), never Text("Title").font(.headline) or a header of your own.
  • A boolean is a switch row. DropletToggleRow, never a picker with two options or a segmented control. Independent flags are one switch row each.
  • A glyph on every row of a card, or on none. A card that mixes rows with an icon: and rows without one looks unfinished.
  • A subtitle is one short sentence. Anything longer belongs in a confirmation dialog or your README.
  • System buttons. .bordered for an ordinary action, .borderedProminent for the one primary action, .borderless for an inline one. The glass styles (DroppyGlassButtonStyle, DroppyQuietButtonStyle, DroppyAccentButtonStyle, DroppyCircleButtonStyle) are for the shelf, never for a settings page.
  • Native text fields. TextField and SecureField with their own border and focus ring, and no fill of your own.
  • Sentence case. Headers, row titles, options and buttons: "Show seconds", not "Show Seconds".
  • No page header of your own. Droppy already shows your icon, name, summary and maker above the pane (see above).

The pane root

Wrap your sections in DropletSettingsPane (DroppyKit 1.9.0). The host places the pane in the grouped Form its own Settings pages are built from, and every row inside it renders as that Form's own row: the card is a Section, a toggle row is the Form's switch row, a picker row carries the System Settings pop-up face, a slider row is the Form's slider. Nothing in the pane draws a card, a divider or a row metric by hand, so the page is indistinguishable from System Settings and follows the OS when the OS moves.

DropletSettingsPane {
    DropletSettingsCard {
        DropletToggleRow(
            title: "Show seconds",
            subtitle: "Ticks the shelf widget every second instead of every minute.",
            isOn: droplet.showsSecondsBinding
        )
        DropletSliderRow(
            title: "Refresh",
            value: "\(Int(interval))s",
            binding: $interval,
            range: 1...60
        )
        DropletControlRow(title: "Clocks") {
            DropletValuePill(text: "\(droplet.clockCount)")
        }
    }

    DropletSettingsSection {
        settingsSectionHeader("Clocks")
    } content: {
        DropletSettingsCard {
            ForEach(clocks) { clock in
                DropletControlRow(title: clock.label) {
                    DropletValuePill(text: droplet.displayTime(for: clock))
                }
            }
        }
    }
}

The pane's content is a list of sections, never a stack: a VStack inside a Form is one row. Put cards and DropletSettingsSections straight inside the pane, top to bottom, and leave the DropletSettingsDividers out (the Form separates the rows; a divider you place renders nothing there).

A header with a line of description under it, the shape System Settings uses for "Energy Mode", is a section whose header stacks the two:

DropletSettingsSection {
    VStack(alignment: .leading, spacing: 2) {
        settingsSectionHeader("Clocks")
        Text("Shown in the widget, in this order.")
            .font(.body)
            .foregroundStyle(.secondary)
    }
} content: {
    DropletSettingsCard { ... }
}

What the host does with the pane: on macOS 15 and later it recognises the root and mounts your sections inside its Form, under the droplet's hero and above the page's own sections (screenshots, capabilities, about). On macOS 14 there is no native form page, and the pane renders as a plain group of the cards below, with the rows drawing their own chrome.

A pane whose root is a VStack of cards, the shape every droplet built before 1.9.0 has, keeps working unchanged: the host keeps its page for it, the components render exactly what they rendered before, and the dividers are still what separates its rows before macOS 15.

The components

ComponentFor
DropletSettingsPaneThe root of the pane
DropletSettingsSectionA header over a card
DropletSettingsCardThe card every group of rows sits in
DropletControlRowA title with a trailing control
DropletToggleRowA switch
DropletSliderRowA value with a slider, the pill click-to-type and the arrow keys live
DropletStackedRowA title over full-width content
DropletGroupedPickerRowA title with a native pop-up button
DropletSettingsDividerThe hairline between rows of a legacy pane
DropletValuePillA read-only value

Reach for the row, not for the SwiftUI control inside it. Every one of these is Droppy's own control, and the differences are the kind a reader notices without being able to name: DropletSliderRow is the Form's own slider with no tick marks (its step snaps the value instead of drawing one mark per step), its value is a pill you can click and type an exact number into, and the arrow keys nudge it with an accelerating repeat while held.

Sentence case

Section headers and row titles are sentence case: only the first word is capitalised, and proper nouns keep their capitals wherever they appear. Droppy enforces this on its own pages, and a droplet in Title Case is visibly a guest.

Droppy's language

Every title, subtitle, info tip, option and page link you hand these rows as a String is looked up in Droppy's own string catalog before it is drawn, so a row whose words Droppy already has reads in the language Droppy runs in. Wording that matches Droppy's ("Show in Command Center", "Keyboard shortcut") comes out translated with no work of yours; a string the catalog does not know is drawn exactly as you wrote it. Since 1.17.0, in any Droppy that runs the droplet, whatever DroppyKit it was built against.

Picker rows are pop-up buttons

settingsUnifiedPickerRow pairs a title with the pop-up button a System Settings row carries: the current choice as plain text, the up and down chevrons in a small round badge beside it, a checkmarked menu of every option. Build each option with settingsUnifiedSegmentLabel, which is how the row learns the titles it lists; an option drawn any other way keeps the SwiftUI menu picker instead. One option is selected at a time; a boolean is a DropletToggleRow, and independent flags are one switch row each, never options of one picker. The groupPosition and cornerRadius arguments on SettingsUnifiedSegmentedRow remain for compatibility with existing droplets and have no visual effect.

Use the SDK rows so switches, buttons and value pills align with Droppy's own pages. Keep text fields native so system borders and focus rings remain intact.

Inside a DropletSettingsPane the Form separates every row and places every control, and content under a row is the next row, which is what System Settings does too; settingsRowJoinsPrevious() does nothing there. In a legacy pane, on macOS 15 and later a card separates its rows on its own and recognises a DropletSettingsDivider you place so it is never doubled, and content that belongs to the row above it takes settingsRowJoinsPrevious(); on earlier releases the hand-placed dividers are all a card has.

Storage

Rows bind to DropletPreferencesService, which namespaces every key to your droplet:

var showsSecondsBinding: Binding<Bool> {
    Binding(
        get: { self.host?.preferences.value(forKey: "showsSeconds", default: true) ?? true },
        set: { self.host?.preferences.setValue($0, forKey: "showsSeconds") }
    )
}

Preferences are plain UserDefaults, readable by anything that reads Droppy's preferences file. An API key, a token or a password goes in the Keychain instead; see Good citizen.

Opening it from a widget

openSettings() opens Droppy's Settings on this page, for a widget's "Set up" button (DroppyKit 1.5.0). It returns false when the droplet provides no pane, or on an older host.

Pages of your own

A droplet with more settings than one page holds gives its page pages of its own (DroppyKit 1.13.0), the way System Settings does: adopt SettingsPagesProviding, put a DropletSettingsPageLink in the pane for each page, and build the page in makeSettingsPage(id:context:).

extension WorldClock: SettingsPagesProviding {
    func makeSettingsPage(id: String, context: SettingsPaneContext) -> AnyView? {
        id == "cities" ? AnyView(CitiesPage()) : nil
    }
}

// In the pane:
DropletSettingsCard {
    DropletSettingsPageLink(
        "Cities", subtitle: "Which clocks, in which order.",
        page: "cities", icon: citiesIcon
    )
}

// The page:
struct CitiesPage: View {
    var body: some View {
        DropletSettingsPage {
            DropletSettingsCard { ... }
        }
    }
}

The page opens in Droppy's Settings window, not in one of yours: Droppy's sidebar stays beside it, its name is the toolbar title, and Back returns to your droplet's page. Root it in DropletSettingsPage, the grouped Form every Settings page is; a page that already is a SwiftUI Form with .formStyle(.grouped) can be returned as it is. Give every link in a card a tile, an Icon Composer render at 56 by 56 pixels, or none. openSettingsPage(_:title:) opens a page straight away, for a "Settings…" command of your own.

A page's controls in the toolbar

What a page does as a whole, adding an item to its list, its own on/off switch, a search over it, a menu of what can be done to it, goes where Droppy's own pages keep theirs: in Liquid Glass pills at the right of the Settings toolbar, on the page title's line (DroppyKit 1.18.0). Never draw a bar of buttons at the top of the page: it stands still while the page scrolls under it, which nothing else in Settings does.

extension WorldClock: SettingsPagesProviding {
    func makeSettingsPageToolbar(id: String, context: SettingsPaneContext) -> AnyView? {
        guard id == "cities" else { return nil }
        return AnyView(CitiesToolbar(model: .shared))
    }
}

struct CitiesToolbar: View {
    @ObservedObject var model: CitiesModel

    var body: some View {
        HStack(spacing: DropletSettingsToolbarMetrics.pillSpacing) {
            DropletSettingsToolbarSearchField(text: $model.query, prompt: "Search cities")
            DropletSettingsToolbarButton("Add city", systemImage: "plus") {
                model.addCity()
            }
        }
    }
}

DropletSettingsToolbarButton, DropletSettingsToolbarMenu, DropletSettingsToolbarSwitch and DropletSettingsToolbarSearchField are each one of Droppy's pills; DropletSettingsToolbarPill is the glass itself, for a control of your own. Droppy asks for the controls whenever its toolbar redraws, so keep their state in an object the page observes too. Keep the cluster narrow, a text pill or two, a round one and a switch: one the toolbar cannot hold beside the page title goes into AppKit's overflow menu whole.

Shortcuts you keep yourself

A shortcut Droppy registers for you (DropletShortcutsService) records on Droppy's Shortcuts page. A shortcut your droplet keeps itself records through DroppyShortcutRecorder (DroppyKit 1.14.0), which is Droppy's own recorder and bubble, never one of your own:

DroppyShortcutRecorder(
    shortcut: $model.shortcut,
    isTaken: { model.isAlreadyBound($0) },
    onRecordingChanged: { model.pausesHotKeys = $0 }
)

Confirming a change

With DroppyKit 1.19.0, ask through DropletConfirmationDialog from the initiating button. The host draws its own glass popup. Perform the change only when present returns true; Cancel, dismissal and a missing presenter all return false. Keep detailed consequences in the popup instead of the settings row.

Button("Import…") {
    if DropletConfirmationDialog.present(
        title: "Import settings?",
        message: "Replaces your current setup after restart. A backup is kept.",
        confirmTitle: "Import",
        symbol: "square.and.arrow.down"
    ) {
        prepareImport()
    }
}

Declare kit.minAPI as 1.19.0 or newer. The host installs its presenter before loading droplets. Hosts without a presenter, including the development harness, do not authorize the change.

See also