Shelf widgets
Cards on the expanded shelf, in both the solo and paired layouts.
The shelf is Droppy's expanded surface below the notch. A widget is a card on it. The shelf owns one layout algorithm and widgets supply parameters to it, so the numbers you give in ShelfWidgetLayoutTraits are what the shelf lays out with — there is no per-widget special case in the host.
Declaring a widget
extension WorldClockDroplet: ShelfWidgetProviding {
public var widgetDescriptors: [ShelfWidgetDescriptor] {
[
ShelfWidgetDescriptor(
id: "clocks",
title: "World Clock",
systemImage: "globe",
layoutTraits: ShelfWidgetLayoutTraits(
preferredSoloWidth: 420,
preferredPairedWidth: 210,
contentHeight: .fixed(128),
// Alone on a Mac with a notch, the header row rises
// beside the camera. See "Wrapping around the camera".
notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)
),
searchKeywords: ["time", "timezone"]
)
]
}
}widgetDescriptors is read when the droplet starts and every time it broadcasts a state change (see below), so keep it stable, cheap and free of side effects.
Important. preferredSoloWidth and preferredPairedWidth are both required. They are typed as optional for binary compatibility only; a current host refuses a descriptor that omits either. nil does not select a fallback.
Changing what you offer
The list is not fixed at activation. Droppy reads widgetDescriptors again:
- when the droplet starts, stops, is updated or is removed;
- after the droplet broadcasts a state change: its
objectWillChangefires (when the droplet is anObservableObject, as everydroppykit newscaffold is), one of its keys inDropletPreferencesServicechanges, an@Observableproperty read during the previous read changes (when the droplet is not anObservableObject), orstatePublisherfires; - when the droplet calls
host.shelf.invalidateLayout(for:), or aShelfWidgetDynamicLayoutProvidingdroplet's invalidation publisher emits.
A burst of broadcasts costs one read, at most about four a second, and a read that changed nothing changes nothing on screen. What did change is applied live, without a relaunch:
- a widget id you start declaring appears in the shelf's widget picker, the Custom Shelf editor, Settings and search;
- an id you stop declaring leaves the shelf. Droppy keeps the user's placement of it, so it returns to the same slot when you declare it again;
- a changed title or symbol shows wherever the widget is listed;
- a changed width or height re-lays the open shelf out on its own spring.
So a widget that has nothing to show until the user configures the droplet can be left out of the list until then, rather than drawn empty:
public var widgetDescriptors: [ShelfWidgetDescriptor] {
guard host?.preferences.value(forKey: "apiKey", as: String.self) != nil else { return [] }
return [clocksDescriptor]
}Writing the key through host.preferences is the broadcast. Droppy listens to the droplet object, so state that lives in another object reaches it only through one of the broadcasts above: forward that object's objectWillChange to the droplet's own, or store the value in preferences. (@Observable state is heard wherever it lives, but only for a droplet that is not itself an ObservableObject.) Return the same descriptor for the same state, because Droppy compares each read with the last one to decide what to update.
The widget's view is yours to keep current. A broadcast re-reads descriptors; it does not rebuild a mounted view whose descriptor is unchanged. Droppy 16.0.1 and earlier read the list only when the droplet started, stopped, was updated or was removed, and when it called invalidateLayout(for:).
Solo and paired
Solo — the widget is alone on the shelf and gets the wide standalone canvas. placement is .solo, isPaired and isCompact are false.
Paired — two to four widgets share a row. Each gets its settled slot width, isPaired and isCompact are true, and placement is .leading, .middle, or .trailing.
These are different compositions. Solo has room for a row per item; paired has room for one number. Branch on context.isCompact:
var body: some View {
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
header
if context.isCompact {
// The room a taller row gives you opens here, so the last row
// ends on the row's bottom line beside its neighbours.
Spacer(minLength: 0)
compactBody
} else {
fullBody
Spacer(minLength: 0)
}
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}The padding, the full-size frame and the header row are the layout every Droppy widget shares; Design guidelines spells the rules out, and its controls. contentInsets is the clearance for the rounded corner the host clips the rectangle with: 8 on every edge alone under a notch (an 18pt corner), 0 for a solo card filling the island, which the island shell clips instead so the island keeps one even gap, 0 on every edge in a grouped row, and 0 while the widget is wrapped around the camera; the host clips those last two square.
A row shares its lines (1.21.0). Every widget in a row starts its first row on the row's top line and ends its last row on the bottom line, 17.5pt over the shell's floor, and keeps the same gap to the divider, so Droppy clips a grouped card square and hands it zero insets: your rectangle's edges are those lines. The row is as tall as its tallest widget, so yours may be given more height than it declared; the composition above puts that room between the header and the last row instead of under it. The shelf's chrome has already inset the rectangle, so pad nothing else on top of it, and never a corner inset of your own. The root gets no card: no background, fill, outline or rounded box. The one exception, artwork that is itself the widget's content and fills the tile, is in Design guidelines.
Every control the widget draws is the flat wash of Droppy's Pomodoro and Stay Awake (DroppyKit 1.21.0): DroppyWashCircleButtonStyle for a symbol, DroppyWashPillButtonStyle for a label, .neutral for everything but the one action the widget is for. 34pt alone, 28pt in a row or beside the camera, 24pt inside a list row:
Button { refresh() } label: { Image(systemName: "arrow.clockwise") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: context.isCompact ? 28 : 34,
tone: .neutral,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))Never branch on a width comparison. The same width means different things on a notch and on an island, and a threshold that works today breaks when a widget is added beside yours.
The numbers are the widget's rectangle
Every number in ShelfWidgetLayoutTraits describes the widget's rectangle: the area your view draws in, in points at the Regular shelf size. That is the size availableSize reports back, and the size the harness renders. The shelf adds its own chrome around it (a 1.5pt slot inset on every edge, the side insets, the cutout clearance on a notch), and the Enlarged shelf size scales the rectangle with everything else. DroppyShellMetrics carries the chrome, if you ever need it; you rarely do. The SDK's metric names call this rectangle the card, and that is the only sense in which there is one.
There is no card drawn around it. Droppy paints nothing behind a widget, and almost every one of Droppy's own widgets lays its content straight onto the shelf's black, with no fill and no border. Do the same: no background on your root view, no outline, no rounded box. The harness draws a faint hairline where the rectangle ends so you can see it; Droppy draws nothing there.
The World Clock above is a 420 × 128 rectangle alone and 210 × 128 in a row. Its solo shell on a notch is 420 + 3 + 96 = 519 points wide; the shelf does not care, it opens to whatever the widget needs.
One floor: the shell never opens narrower than shelfMinimumWidth(usesNotchStyle:) (451 on a notch, 405 as an island), and a solo card fills whatever slot that leaves. A card that declares less than shelfMinimumSoloCardWidth(usesNotchStyle:) (352 on a notch, 370 as an island) is stretched to it, and the harness shows you the stretched size. Declare a solo width at or above the floor and lay out to context.availableSize.width rather than to the number you declared.
Height
ShelfWidgetContentHeight is .standard or .fixed(_:), and it is the rectangle's height. .standard is shelfWidgetStandardContentHeight, 93 points, the strip every widget gets when it says nothing; anything with a second row wants .fixed. .fixed is clamped to shelfWidgetFixedContentHeightRange (48 through 480), and the harness marks a clamped height in red. The height includes contentInsets: content sized to fit exactly budgets 16pt for it under the 18pt corner and none alone on the island, and a height computed in currentLayoutTraits(for:), which gets no context, budgets the 16.
A grouped row honours the same height as the solo widget, less the 16pt the corner insets take alone under a notch (a grouped card has none). If your compact composition needs less, it still gets that height, and a taller widget beside it can give it more; there is one number, so declare the one both compositions fit in and let one Spacer take what is left.
For content-adaptive sizing, a height that depends on the data and not just the type, change what widgetDescriptors returns. When the change comes with one of the broadcasts listed under "Changing what you offer" above, that is all it takes. When it does not, tell the host to re-read it:
host.shelf.invalidateLayout(for: "clocks")The host re-reads your descriptors and resizes the open shelf on its own spring when the height changed. Change the height when your model crosses a size threshold, not on every keystroke. A widget that re-measures continuously is a shelf that never settles.
Keeping the shelf open
The shelf closes a moment after the pointer leaves it and on any click outside it. That is right for a widget the user reaches into and wrong for one they watch from across the screen: a teleprompter reading out, a countdown, a live transcript. While that work runs, hold the shelf open (shelf-write, DroppyKit 1.5.0):
host.shelf.setHoldsOpen(true) // the script starts
host.shelf.setHoldsOpen(false) // it stops, finishes or is paused for goodThe hold counts only while one of your widgets is on the expanded shelf, so it cannot pin the user's other widgets open, and a shelf reopened onto your widget while the work still runs is held again without you doing anything. It also keeps the shelf up over a full-screen app. The user can still close it: while a hold counts Droppy draws its floating close button under the shelf, a swipe up and the shelf shortcut still work, and so does close(). Your widget should still offer its own way to stop. Release the hold the moment the work ends; Droppy releases it for you when the droplet deactivates.
Setting up from the widget
A widget that has nothing to show until the user configures it can send them straight to its settings pane. The "Set up" button calls openSettings() (DroppyKit 1.5.0), which opens Droppy's Settings on your droplet's own page:
Button("Set up") { host.workspace.openSettings() }Ask for permissions there, not from the shelf: a system alert raised while the shelf is open is drawn underneath it. Through DropletPermissionsService Droppy moves the notch out of the way, and answers .unavailable for microphone or speech-recognition on a build that cannot prompt for them rather than ending the process.
Focus
keyboardFocusable tells the shelf your widget's primary surface is a text field the user types into. When that widget opens alone, the shelf makes its panel key after the view mounts, so the cursor lands without an extra click. For a field that appears after the widget is already visible (a menu that turns into a name field), call requestKeyboardFocus() when the field appears:
TextField("Name", text: $name)
.onAppear { host.shelf.requestKeyboardFocus() }The request is refused unless one of your widgets is on the expanded shelf and declared keyboard-focusable, and it returns whether the panel became key. Once granted, the panel stays eligible for the keyboard for as long as that widget is on the shelf, including while your droplet's page is open in Settings, which is where the notch panel otherwise yields the keyboard to the Settings window. Do not call makeKey() on the window yourself, and do not poll canBecomeKey: the host decides both. Non-typing widgets never steal focus.
Prewarming
never opts out of being built before it is shown. Use it when construction is expensive or has side effects; the default is to allow prewarming, which makes the shelf open feel instant.
Both notch shapes
context.usesIslandCurvature is true only for a solo card filling a floating island, the one card the host does not clip at a card corner (the island shell clips it); a solo card under a hardware notch is clipped at the 18pt corner, and a grouped row on either shape is clipped square. context.surface says which shape the shelf is. context.usesAdaptiveForegrounds asks you to let the host tint your foregrounds.
Wrapping around the camera
On a Mac with a notch, a widget that has the shelf to itself can rise into the two bands beside the camera, the way Droppy's own Notes, Pomodoro and Calendar do: its header row splits, one half left of the notch and one right of it, and the rest starts under the camera, so the open shelf is shorter. It is opt-in (DroppyKit 1.21.0) and nothing changes for a widget that does not ask.
Declare the classic header row's height, inside contentInsets, and the narrowest band its halves fit in, in notchWrap:
ShelfWidgetLayoutTraits(
preferredSoloWidth: 460,
preferredPairedWidth: 230,
contentHeight: .fixed(220),
notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)
)When the host wraps the mount, context.notchWrap is a ShelfWidgetNotchWrap: the row's height and each band's usable width, both inside contentInsets, whose top is then zero, so the row starts at the card's top edge exactly where Droppy's own wrapped rows do. Keep applying .padding(context.contentInsets) once at the root and never offset the row yourself. Put the title in the leading band and the header's control in the trailing one, never anything in between (that is under the camera), and start the rest of the widget under the row. When it is nil (a grouped row, the Dynamic Island, an external display, a band too narrow, or a Droppy older than the SDK release) draw the classic header. The host grows the rectangle by what the wrapped row adds over the height you declared, so your fixed height stays the classic one.
The view, with the classic header as the fallback. The control is the wash disc at 28pt in the band and 34pt in the classic row:
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
if let wrap = context.notchWrap {
HStack(spacing: 0) {
// The title drops its words before it reaches the camera.
ViewThatFits(in: .horizontal) {
Label("Notes", systemImage: "note.text")
Image(systemName: "note.text")
}
.frame(maxWidth: wrap.bandWidth, alignment: .leading)
Spacer(minLength: 0)
addButton(diameter: 28)
.frame(maxWidth: wrap.bandWidth, alignment: .trailing)
}
.frame(height: wrap.headerHeight)
} else {
HStack {
Label("Notes", systemImage: "note.text")
Spacer(minLength: 0)
addButton(diameter: 34)
}
}
list
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)Keep the two halves short. A band on the Regular shelf is about 60 to 120 points, depending on your solo width and the Mac's notch, and a title should shrink or drop secondary text before it reaches the cutout's edge. Leave the leading half 8 points clear of the notch. The wrap needs a real notch: on a display without one the Notch shape draws no cutout to wrap around, so the host keeps the classic layout there. The harness's Shelf widgets page draws the wrapped stage for a widget that declares it, at the host's numbers, with the camera housing outlined.