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.
When to use one
Section titled “When to use one”- 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.
Minimal setup
Section titled “Minimal setup”import NookAppimport 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.
Composing action chrome
Section titled “Composing action chrome”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 HStackconfiguration.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.
Placement
Section titled “Placement”Anchor and alignment
Section titled “Anchor and alignment”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 panelanchor: .below(alignment: .end) // under the panel, flush with its right sideanchor: .trailing(alignment: .start) // beside the panel, level with its topPlacement 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.
Rows, spacing, and gap
Section titled “Rows, spacing, and gap”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.
Presentation modes and displays
Section titled “Presentation modes and displays”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
Section titled “Backdrop”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.plainstyle.
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 companionconfiguration.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.
Controls
Section titled “Controls”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.
Theme and environment
Section titled “Theme and environment”Companion content renders in the same chrome environment as the home view:
- the resolved palette,
\.nookResolvedTheme- the configuration’stheme, or the companion’s ownthemewhen you pass one; AppStateas 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
Section titled “Visibility”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) }}Presence
Section titled “Presence”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.
Showing and hiding from inside
Section titled “Showing and hiding from inside”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).
Settings
Section titled “Settings”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.
Changing companions while the app runs
Section titled “Changing companions while the app runs”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.
Moving the lock and gear
Section titled “Moving the lock and gear”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 = falseconfiguration.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.
Interaction
Section titled “Interaction”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.
Multiple modules
Section titled “Multiple modules”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.
Accessibility
Section titled “Accessibility”- Every companion is an accessibility group with the identifier
opennook.companion.followed by itsid, next to the panel’s ownopennook.panel. Use them in UI tests. accessibilityLabelnames the group for VoiceOver.- A companion that is not shown is hidden from VoiceOver.
.nookGlyphbuttons keep their titles as their accessibility labels.- Under Reduce Motion a companion fades in and out without the movement of its presence.
See also
Section titled “See also”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
showsKeepOpenButtonandshowsSettingsButton. Sources/NookSurface/NookCompanionSurface.swift,Sources/NookSurface/NookCompanionStyle.swift, andSources/NookKit/App/NookCompanion.swift- the types behind this guide.