---
title: "ExpandedSurfaceProviding, DroppyKit"
description: "A provider of expanded surfaces: content, size, and the far end of the host&#x27;s lifecycle."
canonical: "https://getdroppy.app/docs/droppykit/api/expanded-surface-providing"
last-updated: "2026-09-09"
---

# ExpandedSurfaceProviding

A provider of expanded surfaces: content, size, and the far end of the host's lifecycle.

`@MainActor protocol ExpandedSurfaceProviding : AnyObject`

Deliberately `AnyObject` rather than `Droplet`, the same split `QuickActionBarProviding` makes and for the same reason: the object that knows whether a session is running is usually a long-lived manager the droplet owns, not the droplet itself. Vend one by adopting [`ExpandedSurfaceHosting`](https://getdroppy.app/docs/droppykit/api/expanded-surface-hosting).

The split matters more here than it does for the action bar. The host resolves this provider through the droplet registry, so there is exactly one instance and both sides agree on which. The shipping code this replaces used a shared singleton reachable from anywhere in the app, which is why eight unrelated host sites ended up reading it directly and why none of that could survive the move out of the host process boundary.

## When the host reads the surface

- `expandedSurfaces` is read each time the droplet calls [`presentExpandedSurface(_:)`](https://getdroppy.app/docs/droppykit/api/droplet-notch-surface-service#present-expanded-surface), to find the descriptor for the requested id. A presentation keeps the descriptor it was presented with until it is dismissed, its [`suppresses`](https://getdroppy.app/docs/droppykit/api/expanded-surface-descriptor#suppresses) above all: dropping `autoCollapse` halfway through would close the shelf under the user, and adding `outsideClickDismissal` would take away the way out they were using. A changed descriptor applies on the next presentation.
- `expandedSurfaceSize(_:fitting:)` is asked on the shelf's layout passes, and again while the surface is up whenever the droplet broadcasts a state change: its `objectWillChange` fires (when the droplet is an `ObservableObject`); one of its keys in [`DropletPreferencesService`](https://getdroppy.app/docs/droppykit/api/droplet-preferences-service) changes; an `@Observable` property read during the previous call changes (when the droplet is not an `ObservableObject`); or [`statePublisher`](https://getdroppy.app/docs/droppykit/api/droplet-install-state-service#state-publisher) fires. That second ask repeats the shelf's last proposal, and the shelf re-lays itself out only when the answer changed. Broadcasts are coalesced: a burst costs one call, at most about four a second.
- [`expandedSurfaceProvider`](https://getdroppy.app/docs/droppykit/api/expanded-surface-hosting#expanded-surface-provider) is resolved on every render pass, and after a broadcast while the surface is up: when it names another object, or none, the shelf draws from the provider it returns now, an empty view when there is none. A broadcast never dismisses a presented surface, and one you stop offering stays up until it is dismissed. Call [`dismissExpandedSurface(_:)`](https://getdroppy.app/docs/droppykit/api/droplet-notch-surface-service#dismiss-expanded-surface) before you take its provider away.

The host hears the droplet object, not the provider. A provider that is a separate object (a session the droplet owns) reaches the host only by forwarding its `objectWillChange` to the droplet's own, or by storing the value in preferences; `@Observable` state is heard wherever it lives, but only for a droplet that is not itself an `ObservableObject`. Droppy 16.0.1 and earlier asked the size only on layout passes the shelf made for its own reasons, so a surface that grew stayed at its old size until something else moved the shelf.

- Note: Maturity: Stable. Droppy mounts this: your surface replaces the shelf's own contents, sized from your `expandedSurfaceSize(_:fitting:)` proposal and clamped by the host, and every way out of it calls `expandedSurfaceDidDismiss(_:presentation:reason:)` with the real reason. Requires the `expanded-surface` capability. See [Host support](https://getdroppy.app/docs/droppykit/host-support).

## Properties

### expandedSurfaces

`@MainActor var expandedSurfaces: [ExpandedSurfaceDescriptor] { get }`

The surfaces this droplet can present. Read each time the droplet presents one (see above), never on a timer; keep it stable and cheap.

## Methods

### expandedSurfaceDidDismiss(_:presentation:reason:)

`@MainActor func expandedSurfaceDidDismiss(_ id: ExpandedSurfaceID, presentation: ExpandedSurfacePresentation, reason: ExpandedSurfaceDismissalReason)`

The host has taken the surface down.

Called once per presentation, after the close, for every reason including your own [`dismissExpandedSurface(_:)`](https://getdroppy.app/docs/droppykit/api/droplet-notch-surface-service#dismiss-expanded-surface). Compare `presentation` against the one you started, because a delayed teardown can arrive after the user has already summoned the surface again.

### expandedSurfaceDidDismiss(_:presentation:reason:)

`@MainActor func expandedSurfaceDidDismiss(_ id: ExpandedSurfaceID, presentation: ExpandedSurfacePresentation, reason: ExpandedSurfaceDismissalReason)`

Default: nothing to tear down.

### expandedSurfaceSize(_:fitting:)

`@MainActor func expandedSurfaceSize(_ id: ExpandedSurfaceID, fitting proposal: ExpandedSurfaceSizeProposal) -> CGSize?`

How big the surface wants to be, or `nil` for the host's standard size.

Called on layout passes rather than once per presentation, because a surface whose content grows (a transcript arriving a word at a time, a list gaining rows) has to be able to change its answer, and asked again after each state change the droplet broadcasts while the surface is up (see above), which is how a new answer reaches the shelf. Keep it pure and cheap: no side effects, no allocation you can avoid, and no animation of your own. The host clamps to [`maximumSize`](https://getdroppy.app/docs/droppykit/api/expanded-surface-size-proposal#maximum-size) and holds your answer still across its springs.

### expandedSurfaceSize(_:fitting:)

`@MainActor func expandedSurfaceSize(_ id: ExpandedSurfaceID, fitting proposal: ExpandedSurfaceSizeProposal) -> CGSize?`

Default: no preference, so the host uses its standard expanded size.

### makeExpandedSurfaceView(_:context:)

`@MainActor func makeExpandedSurfaceView(_ id: ExpandedSurfaceID, context: ExpandedSurfaceContext) -> AnyView`

Builds the surface's live view. Views render on dark shelf surfaces; use light foreground colors and the DroppyKit design tokens.

Return an empty view for an id you do not recognize rather than trapping. A host can ask about a surface you published a moment ago and have since stopped offering.
