Skip to content
GitHub

Hover and peek

By default the nook opens the moment the pointer reaches the compact pill, as it always has. It can also open in two steps: the pill first grows down into a short peek under the notch, and the full nook opens on a click, or once the pointer has rested on the peek. Something that happens, such as a song changing or the volume moving, can show the peek too, without the pointer.

compact -> peek -> expanded
hover click or dwell
or an event

A peek is a way of being compact: Nook.state stays .compact while Nook.isPeeking is true, and leaving compact ends the peek.

A module’s peek is a view, set like the compact slots. It renders in the chrome environment, so it reads the theme, services, and labels the rest of the chrome does.

var configuration = NookConfiguration()
configuration.setHome { PlayerHome(player: player) }
configuration.setCompactLeading { CoverArt(player: player) }
configuration.setPeek {
VStack(alignment: .leading, spacing: 2) {
Text(player.title).font(.system(size: 12, weight: .semibold))
ProgressView(value: player.progress)
}
.frame(width: 220)
}

A module without a peek never shows an empty one: a peek-first hover opens the nook instead.

Settings has an Open on hover row in the Shortcut & nook group:

Choice What resting the pointer on the pill does
At once (default) Opens the nook
Peek first Grows the pill into its peek; a click, or resting on the peek, opens the nook
Off Nothing; a click on the pill or the shortcut opens the nook

Under it are the wait before anything happens (0 to 1 s), a separate wait for other displays than the Mac’s built-in one, and, for Peek first, how long to rest on the peek before the nook opens on its own (0 waits for a click). These are NookAppearancePreferences.openOnHover, hoverDelay, externalDisplayHoverDelay, and peekDwell, persisted like every other appearance choice. A host seeds them through NookPreferenceDefaults, and the person’s choice wins.

With anything but the default, a click on the compact pill always opens the nook, so a pill that no longer opens on hover is never out of reach.

A host that wants one behavior for everyone sets it on the chrome behavior. Settings then leaves the rows out, the way a theme’s pins leave out the controls they pin.

configuration.chromeBehavior.hoverIntent = NookHoverIntent(
action: .peek,
delay: .milliseconds(120),
externalDisplayDelay: .milliseconds(300),
dwellToExpand: .seconds(1)
)

NookHoverIntent.standard is the default behavior: open at once, no delay.

A surface claim can ask for the peek instead of the full nook. It follows the same rules as any claim: it waits while the person is using the nook, a higher priority preempts it, and a background module needs .urgent.

let claim = NookSurfaceClaim(moduleID: descriptor.id, priority: .ambient, presentation: .peek)
guard let token = await coordinator.beginTransientPresentation(claim) else { return }

When the module has no peek, or the nook is already open, a peek claim opens or keeps the full nook, so it is always seen.

For a HUD, schedule the end instead of tracking it yourself. Calling it again moves the end, so the peek stays up until a moment after the last change:

// On every volume change while the claim is held:
await coordinator.endTransientPresentation(token, after: .milliseconds(1600))

When the last claim ends, the pill shrinks back to compact.

NookSurface has the same pieces for a host that drives Nook directly:

  • peekContent: AnyView?, the view under the slots; nil means no peek.
  • peek(on:) and endPeek(), both awaited until the pill has arrived.
  • isPeeking, published.
  • hoverIntent: NookHoverIntent.

A peek started with peek(on:) lasts until endPeek(); one the pointer started ends when the pointer leaves.

The peek’s shape and motion are theme tokens:

Token Default
shape.peek.bottomRadius 22
shape.peek.maxHeight 120; taller content is clipped
shape.peek.insets.top, .bottom, .leading, .trailing 2, 10, 14, 14
transition.peek {spring.snappy}
motion.peek.enter fade, blur 4, vertical scale 0.9 from the top
motion.peek.exit motion.peek.enter in reverse, unless written

NookStyle.peekBottomCornerRadius, peekContentInsets, and peekMaxHeight, and NookTransitionConfiguration.peekContentTransition, peekContentRemoval, and peekAnimation override them, as configuration.style and configuration.transitions do for the rest of the chrome. See Theming.