Skip to content
GitHub

Companion surfaces

A companion surface is a host view that floats outside the nook but belongs to it: a pill of actions under the expanded panel, a round button beside the compact pill, the framework’s own lock and gear moved out of the top bar. You register one on NookConfiguration with any SwiftUI view, say where it hangs, and the framework does the rest:

  • it anchors the view to the chrome and keeps it there as the nook expands and collapses, in the notch, floating, and auto layouts, on any display;
  • it draws the view with a style - by default the chrome’s own backdrop, padded and sized so companions side by side match - and gives it the chrome’s palette and environment;
  • it treats the chrome and its companions as one hover region, so moving the pointer onto a companion never collapses the nook;
  • clicking a companion never takes focus from the nook or activates the app, and the global hotkey keeps working;
  • it takes a module’s companions off the surface when the module is switched away.

The working example is Examples/CompanionNook/main.swift: a media player with a pill of three section buttons and a separate round sleep-timer button below it, the lock and gear moved into a capsule beside it, and a speaker chip that comes and goes while the app runs. Run it with swift run CompanionNook.

  • Controls that act on the home view but should not take its space - section tabs, quick actions, call controls, a “Show results” pill.
  • A glance that stays beside the compact pill while something runs - a timer, a download, a recording indicator.
  • The chrome’s own controls, somewhere else - see Moving the lock and gear.

For a couple of glyphs that belong in the top bar, use setTopBarTrailingItems (see Chrome customization). For an announcement that briefly takes over the panel, use the activity queue.

import NookApp
import SwiftUI
struct ActionPill: View {
var body: some View {
HStack(spacing: 2) {
Button("Previous", systemImage: "backward.fill") { player.previous() }
Button("Play", systemImage: "play.fill") { player.toggle() }
Button("Next", systemImage: "forward.fill") { player.next() }
}
.buttonStyle(.nookGlyph)
}
}
var configuration = NookConfiguration()
configuration.setHome { PlayerView() }
configuration.addCompanion(id: "actions") { ActionPill() }
NookApp.main(configuration)

With no other arguments the companion hangs centered below the expanded panel, 8 pt under it, in a capsule painted with the chrome’s backdrop, 40 pt tall around 32 pt controls, and steps aside while Settings is showing. Every argument has a default:

configuration.addCompanion(
id: "actions", // unique; keys hover and the accessibility identifier
anchor: .below, // .below, .leading, .trailing (+ alignment)
spacing: 8, // gap to the chrome, and to the companion before it
gap: nil, // a different gap to the companion before it
rowAlignment: nil, // .start, .center, .end across its row
visibility: .expanded, // .compact, .expanded, .both
shape: .capsule, // .capsule, .circle, .roundedRectangle(cornerRadius:)
backdrop: .inherit, // .inherit, .custom(NookBackdrop), .none
style: nil, // nil uses configuration.companionStyle
size: nil, // nil uses configuration.companionSize
presence: nil, // nil uses configuration.companionPresence
hidesInSettings: true, // step aside while Settings fills the panel
accessibilityLabel: "Actions", // read by VoiceOver before the contents
theme: nil // nil uses the configuration's theme
) {
ActionPill()
}

Registering two companions with the same id with addCompanion traps: the id keys each companion’s hover tracking and its accessibility identifier. A companions array built from data is not checked at registration; there the first companion with an id is shown and the rest are logged and left out.

Each companion is one surface. A group of controls is one companion whose content holds them; a control that stands apart is a companion of its own. Nothing about the count, grouping, or color of controls is fixed - it is all your content.

As with setHome and the other content builders, a companion’s content must be Sendable. A View struct is, whatever it holds, so put the controls in a small view and pass an instance of it. A modified view written straight into the closure, such as Button(...).buttonStyle(...), is not, and Swift 6 rejects it.

One button:

struct RecordButton: View {
@ObservedObject var recorder: Recorder // a @MainActor ObservableObject
var body: some View {
Button("Record", systemImage: "record.circle.fill") { recorder.toggle() }
.buttonStyle(.nookGlyph(foreground: .red))
}
}
configuration.addCompanion(id: "record", shape: .circle) { RecordButton(recorder: recorder) }

Three grouped and one separate - two companions in the same row:

configuration.addCompanion(id: "transport") { TransportControls() } // three buttons in an HStack
configuration.addCompanion(id: "share", gap: 16, shape: .circle) { ShareButton() }

Two and two - two companions in one centered row, or one at each end:

configuration.addCompanion(id: "media", anchor: .below(alignment: .start)) { MediaControls() }
configuration.addCompanion(id: "call", anchor: .below(alignment: .end)) { CallControls() }

Mixed colors and shapes - color each control in its content, and give a surface its own backdrop, style, or palette:

struct DrawingTools: View {
var body: some View {
HStack(spacing: 2) {
Button("Pencil", systemImage: "pencil.tip") {}
.buttonStyle(.nookGlyph(foreground: .orange))
Button("Eraser", systemImage: "eraser.fill") {}
.buttonStyle(.nookGlyph(foreground: .pink))
}
}
}
struct LeaveButton: View {
var body: some View {
// Its own red circle, so the companion uses the plain style with no pill behind it.
Button("Leave", systemImage: "phone.down.fill") {}
.buttonStyle(.nookGlyph(size: .surface, foreground: .white, fill: .color(.red)))
}
}
configuration.addCompanion(id: "tools") { DrawingTools() }
configuration.addCompanion(id: "send", shape: .circle, backdrop: .custom(.solid(.blue)), style: .raised) {
SendButton() // a .nookGlyph(foreground: .white) button
}
configuration.addCompanion(id: "leave", style: .plain) { LeaveButton() }

Because every companion shares one size, neighbours line up without any arithmetic: a circle holding one control and a capsule holding three are the same height.

NookCompanionAnchor says which chrome edge a companion hangs from and where along that edge:

Anchor Hangs Alignment moves it
.below under the bottom edge left to right: .start, .center, .end
.leading beside the left edge top to bottom: .start, .center, .end
.trailing beside the right edge top to bottom: .start, .center, .end

The shorthands center on their edge; pass an alignment for the rest:

anchor: .below // centered under the panel
anchor: .below(alignment: .end) // under the panel, flush with its right side
anchor: .trailing(alignment: .start) // beside the panel, level with its top

Placement is measured from the chrome’s body - the visible panel - not from the notch ears that flare into the menu bar, so the gap you ask for is the gap the user sees in both the notch and the floating layout.

Companions that share an anchor form one row, in registration order. A .below row runs left to right under the chrome; a .leading or .trailing row runs outward, the first companion nearest the chrome. Each edge takes up to three rows, one per alignment. Rows at different alignments are placed independently, so keep wide ones from meeting on a narrow panel.

spacing is the gap between a companion and the chrome, and between it and the companion before it in the row. In a .below row, gap sets the second one alone, so a separate button can sit further from its neighbour without dropping further below the chrome:

configuration.addCompanion(id: "sections", spacing: 10) { MediaSectionsPill(player: player) }
configuration.addCompanion(id: "sleep-timer", spacing: 10, gap: 14, shape: .circle) {
SleepTimerButton(player: player)
}

Companions in one .below row hang from the same drop, the largest spacing among the ones shown, so their surfaces line up even when their spacings differ. When companions in a row differ in height, each sits across the row by its rowAlignment: centered by default below the chrome, and following the anchor’s alignment beside it. Every companion spans its row, so the pointer never leaves the hover region beside a short one.

Only companions that are shown take up room in a row, so a hidden companion never leaves a hole.

Companions are laid out against the chrome’s own frame inside the chrome’s own panel, so they follow it through NookPresentation - .notch, .floating, and .auto - and onto whichever display the user picks, with nothing to re-anchor.

The panel normally covers the top half of the screen. When a companion hangs lower than that - a tall expanded surface on a small display, say - the panel grows downward to fit it, top edge pinned. The file-drag region stays the top half of the screen it has always been.

NookCompanionSize is the size a companion shares with its controls: the surface’s height, a control’s side, a glyph’s point size, and the space between controls.

Size Surface Control Glyph
.small 32 pt 26 pt 11 pt
.regular (default) 40 pt 32 pt 13 pt
.large 48 pt 40 pt 16 pt

Set it for every companion with configuration.companionSize, or for one with size:. The standard style pads the content by the difference, so a control sits concentric with the surface’s rounded end, and makes the surface at least that tall. Content reads the size from \.nookCompanionSize, which is nil outside a companion:

struct TimerRing: View {
@Environment(\.nookCompanionSize) private var size
var body: some View {
Circle().stroke(.blue, lineWidth: 2)
.frame(width: size?.controlSize ?? 32, height: size?.controlSize ?? 32)
}
}

Beside the compact pill, a companion is fitted to the pill’s height with NookCompanionSize.fitting(height:): its surface, controls, and glyphs scale down so it is no taller than the pill and never reaches above the top of the screen. Beside the expanded panel it has its full size again. A custom size is NookCompanionSize(height:controlSize:glyphSize:controlSpacing:).

shape is the outline the companion is filled and hit-tested with:

  • .capsule (the default) - a pill;
  • .circle - the companion is squared to its content’s larger dimension, so a single control comes out round;
  • .roundedRectangle(cornerRadius:) - a card.

The content is not clipped to the shape, so a badge can overhang the edge.

backdrop decides what the surface is painted with:

  • .inherit (the default) - the chrome’s current backdrop, so the companion follows the user’s surface style (solid, translucent, or Liquid Glass) and Reduce Transparency along with the panel;
  • .custom(NookBackdrop) - a backdrop of its own, for example .custom(.solid(.accentColor));
  • .none - nothing behind the content. The style still pads and sizes the surface; for content that draws every piece itself at its own size, such as a separately filled button, use the .plain style.

A companion’s style draws the surface around its content. The default, NookStandardCompanionStyle, has every part adjustable:

Part What it does Default
fill .backdrop paints the backdrop; .none paints nothing .backdrop
fade thins the fill away from the chrome, from start to end opacity none
stroke a line inside the edge none
shadow a shadow cast by the outline, with or without a fill none
hover a wash, a scale, and a glow under the pointer none
padding around the content the size’s inset
height .shared, .content, .minimum(_:), .fixed(_:) .shared
animation the curve hover animates on a short snappy spring

Pick a preset, adjust the standard style, or write your own:

configuration.companionStyle = .faded // every companion
configuration.addCompanion(id: "send", style: .raised) { SendButton() }
configuration.addCompanion(id: "badge", style: .standard(fade: .toClear, hover: .glow)) { Badge() }
Preset Looks like
.standard the backdrop, padded and sized
.faded solid where it meets the chrome, a quarter strength at the far side
.raised a hairline edge and a soft shadow, lifting under the pointer
.plain no fill and no padding, at the content’s own size

.faded is the chrome’s own trick: the Liquid Glass chrome is shaded strongest under the notch and thins toward the wallpaper, and a faded companion runs the same way away from the chrome - down from a .below companion, outward from a side one.

For full control, conform to NookCompanionStyle. The configuration hands you the content, the shape, the resolved backdrop, the edge, the size, and whether the surface is hovered and shown:

struct NeonStyle: NookCompanionStyle {
var color: Color
func makeBody(configuration: Configuration) -> some View {
let outline = configuration.shape.outline
configuration.content
.padding(configuration.size.inset)
.frame(minHeight: configuration.size.height)
.background { outline.fill(color.opacity(configuration.isHovered ? 0.34 : 0.2)) }
.overlay { outline.stroke(color, lineWidth: 1.5) }
.shadow(color: color.opacity(0.55), radius: configuration.isHovered ? 12 : 6)
}
}
configuration.addCompanion(id: "live", style: .custom(NeonStyle(color: .cyan))) { LiveBadge() }

A style that wants the chrome’s material paints it with NookBackdropView, in any shape - an UnevenRoundedRectangle for a segmented pill, say. The same view is there for content: \.nookChromeBackdrop holds the chrome’s current backdrop inside compact, expanded, and companion content. NookCompanionFadeMask is the fade the standard style uses, and NookOutlineShadow draws its shadow and glow.

NookGlyphButtonStyle - .buttonStyle(.nookGlyph) - is a glyph button in the chrome’s palette, sized from the companion it sits in. Every part is adjustable:

Button("Leave", systemImage: "phone.down.fill") { call.leave() }
.buttonStyle(
.nookGlyph(
size: .surface, // .control (default), .surface, or .points(_:)
foreground: .white, // nil uses the palette's primary label
shape: .circle, // any NookCompanionShape
fill: .color(.red), // .none, .subtle, .color(_:), .chromeBackdrop
fade: .standard, // thins the fill from the top down
hover: .lift, // wash, scale, and glow under the pointer
pressedScale: 0.9
)
)

Only the glyph shows; the title stays the button’s accessibility label, and help(_:) adds a tooltip. .control suits a button inside a surface; .surface makes a button as tall as a companion, for a filled button that stands on its own in a .plain companion.

Companion content renders in the same chrome environment as the home view:

  • the resolved palette, \.nookResolvedTheme - the configuration’s theme, or the companion’s own theme when you pass one;
  • AppState as an @EnvironmentObject;
  • the module’s services, \.appServices;
  • the chrome labels, metrics, motion, and typography;
  • the chrome actions, \.nookChromeActions (see below);
  • the companion’s size, \.nookCompanionSize, and the chrome’s backdrop, \.nookChromeBackdrop;
  • whether the companion is shown, \.nookCompanionIsPresented, and hovered, \.nookCompanionIsHovered;
  • the panel-wide scroll edge fade, \.nookScrollEdgeFade (see Rim glow and edge fade).

visibility is the set of nook states a companion is shown in:

  • .expanded (the default) - beside the expanded panel;
  • .compact - beside the compact pill;
  • .both - in both states.

A control that should sit somewhere else in each state is two companions, one .compact and one .expanded, sharing a model.

A companion that is not shown in the current state stays mounted and goes away with its presence, so it grows out of the panel as the nook expands and folds back in as it collapses, on the same animation. Because the content stays mounted, read \.nookCompanionIsPresented to pause timers or animations while it is hidden:

struct LiveGlance: View {
@Environment(\.nookCompanionIsPresented) private var isPresented
var body: some View {
Waveform(isAnimating: isPresented)
}
}

NookCompanionPresence is how a companion comes and goes: when the chrome changes state, when its content hides it, and when it is added or removed.

  • .fold (the default) - shrinks toward the chrome edge and blurs;
  • .fade - fades in place;
  • .slide - slides back toward the chrome;
  • .pop - shrinks toward its own center.

Set it for every companion with configuration.companionPresence, or for one with presence:. By default a companion moves on the chrome’s own expand and collapse curve. A companion added or removed plays its presence as well, on the curve the change was made with, or on the chrome’s curve if the change was made outside withAnimation. Give a presence a curve of its own with .animation(_:):

configuration.addCompanion(id: "badge", presence: .pop.animation(.bouncy)) { Badge() }

Under Reduce Motion every presence is a plain fade.

The registered visibility is the most a companion can be shown. Its content can narrow that at runtime with nookCompanionVisibility(_:), or hide it entirely with nookCompanionHidden(_:); the change runs on its presence. The media player’s sleep-timer button is registered for .both and stays beside the compact pill only while its timer runs:

struct SleepTimerButton: View {
@ObservedObject var player: MediaPlayer
var body: some View {
Button { player.toggleSleepTimer() } label: { TimerGlyph(player: player) }
.buttonStyle(.plain)
.nookCompanionVisibility(player.isSleepTimerRunning ? .both : .expanded)
}
}

A “Show results” pill that appears once there are results is .nookCompanionHidden(results.isEmpty).

While the built-in Settings screen fills the expanded panel, companions with hidesInSettings: true (the default) step aside - they usually act on the home view that Settings replaces. The compact state is unaffected. Pass false for a companion that should stay, such as one holding the gear.

There are two kinds of change, and neither needs a restart.

What a companion holds is its content, so it changes like any SwiftUI view: a group that shows two buttons or five just observes its model. Nothing else is needed.

Which companions exist - a button that appears while a call is live, a group per open document - goes on a NookCompanionSource. Set one on the configuration, then add, replace, move, and remove companions on it whenever you like; the chrome follows at once:

let live = NookCompanionSource()
configuration.companionSource = live
// Later, anywhere on the main actor:
live.set(NookCompanion(id: "leave", shape: .circle, presence: .pop) { LeaveButton() })
live.remove(id: "leave")

A source’s companions come after the configuration’s own, take the configuration’s style, size, and presence unless they set their own, and add and remove with their presence. Make a change inside withAnimation to run it on a curve of your choosing. CompanionNook adds a speaker chip beside the compact pill this way while music plays on a speaker.

A module can also rebuild its whole configuration with AppCoordinator.reloadActiveConfiguration(), which projects its companions again; see Playground for a module that does that on every change.

The top bar’s keep-open lock and Settings gear can live in a companion instead. Turn them off in the top bar and drop the framework’s own controls into a companion - they behave exactly like the top bar’s, and take the companion’s control size so they line up with its other controls:

configuration.topBar.showsKeepOpenButton = false
configuration.topBar.showsSettingsButton = false
configuration.addCompanion(
id: "chrome-controls",
anchor: .trailing,
hidesInSettings: false // its gear is the way back out of Settings
) {
ChromeControls()
}
struct ChromeControls: View {
var body: some View {
VStack(spacing: 2) {
NookKeepOpenButton()
NookSettingsButton()
}
}
}

Unlike showsSettings, showsSettingsButton removes only the gear: Settings stays reachable from the menu bar, AppCoordinator.showSettings(), and any view that calls the chrome actions.

To build your own controls, read the actions from the environment. They are what the lock and gear do: toggleKeepOpen() flips “stay expanded” and applies it at once, toggleSettings() switches between home and Settings (expanding the nook first when it is collapsed), and collapse() returns to the compact pill.

struct SettingsChip: View {
@Environment(\.nookChromeActions) private var chromeActions
var body: some View {
Button("Settings", systemImage: "gearshape") { chromeActions.toggleSettings() }
.buttonStyle(.nookGlyph)
}
}

The actions are live in home, compact, and companion content, and inert anywhere else.

Hover. The chrome and its companions are one hover region. Each companion fills its row and carries a transparent bridge across its gap to the chrome, and while companions are shown a hover exit waits a quarter of a second before collapsing, so the pointer can cross between surfaces. Hovering a companion keeps the nook open and counts as the user engaging it (an activity queue waits), but it never expands a compact nook - only the chrome itself does - so a compact-only companion cannot fold away under the pointer.

Clicks and focus. Companions render in the nook’s own non-activating panel. Clicking one is clicking the nook: the app is not activated, focus stays where the nook left it, and the global show/hide hotkey keeps toggling the nook. Clicks on the transparent space around a companion fall through to whatever is behind the panel.

Popovers and menus. A popover, menu, or sheet opens in a window of its own, so the pointer leaves the panel while it is up. Hold the nook open for its duration with nookKeepsExpanded(while:), the same modifier home content uses:

Button("Pick a time") { isPicking = true }
.popover(isPresented: $isPicking) { TimePicker() }
.nookKeepsExpanded(while: $isPicking)

File drops. Files dropped anywhere on the panel go to onFileDrop, companions included. A basket-style companion can light up during a drag by reading appState.isDragInFlight, and list what onFileDrop accepted.

Companions belong to the NookConfiguration that registered them. In a multi-module host a switch takes the outgoing module’s companions off the surface in the same transaction that brings the incoming module’s in, each playing its presence. Their views disappear, so onDisappear runs, a presentation pin they held is released, and any hover they held ends. A module’s companionSource stops reaching the surface while the module is switched away, and the surface shows whatever the source holds when the module comes back. A module with the default .unloadOnSwitchAway background policy is rebuilt on its way back, so a source the module creates starts over. Keep the source somewhere that outlives the module, or use .stayResident, to keep what it holds.

Work a module runs for its companions - a timer, a download - is the module’s to stop, in onDeactivate() or prepareForSwitchAway(), the way ActivityNook quiesces its queue. CompanionNook stops its playback clock in onDeactivate(). Note that the host calls onActivate() only when the user switches to a module, not for the module it launches with, so start launch-time work in the module’s initializer.

  • Every companion is an accessibility group with the identifier opennook.companion. followed by its id, next to the panel’s own opennook.panel. Use them in UI tests.
  • accessibilityLabel names the group for VoiceOver.
  • A companion that is not shown is hidden from VoiceOver.
  • .nookGlyph buttons keep their titles as their accessibility labels.
  • Under Reduce Motion a companion fades in and out without the movement of its presence.
  • Examples/CompanionNook/main.swift - the media player with companions below and beside the panel, and one that comes and goes.
  • Playground - compose companions live, item by item, and export the Swift.
  • Rim glow and edge fade - the two panel effects the example also uses.
  • Settings chrome - the top bar flags, including showsKeepOpenButton and showsSettingsButton.
  • Sources/NookSurface/NookCompanionSurface.swift, Sources/NookSurface/NookCompanionStyle.swift, and Sources/NookKit/App/NookCompanion.swift - the types behind this guide.