Live activities

The compact row beside the notch, and the detail behind it.

A live activity is Droppy's version of the Dynamic Island: a compact status that stays visible while something is happening, in the wings either side of the notch — or inside a floating island on a display without one.

Only one droplet holds the compact seat at a time. You publish a state; the host decides who wins.

Publishing state

liveActivityState is a publisher. Emit a LiveActivityState while you have something to say, and nil to stand down.

extension WorldClockDroplet: LiveActivityProviding {
    public var liveActivityState: AnyPublisher<LiveActivityState?, Never> {
        activitySubject.eraseToAnyPublisher()
    }

    private func publishActivity() {
        activitySubject.send(
            LiveActivityState(
                priority: 200,
                accessibilityTitle: "Amsterdam \(time)",
                isInteractive: false
            )
        )
    }
}

Emitting every second is fine — the host diffs before it redraws. What is not fine is holding the seat when you have nothing to show: publish nil the moment your task ends.

priority decides who wins the seat. Keep it honest: a timer that is counting down outranks a status that merely exists, and a droplet that pins itself high is one users turn off.

The compact presentation

Two builders, one per wing:

public func makeCompactLeading() -> AnyView {
    AnyView(
        Image(systemName: "globe")
            .font(.system(size: DroppyLiveActivityMetrics.iconSize, weight: .medium))
            .foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
            .padding(.trailing, DroppySpacing.sm)
    )
}

public func makeCompactTrailing() -> AnyView {
    AnyView(
        Text(time)
            .font(.system(size: DroppyLiveActivityMetrics.labelFontSize, weight: .medium, design: .rounded))
            .monospacedDigit()
            .foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
            .padding(.leading, DroppySpacing.sm)
    )
}

The space is small and fixed: 70pt per wing at rest, 120pt when the row is interactive. Use monospacedDigit() on anything that ticks, or the row jitters as digits change width.

Hover and the shelf

A droplet's live activity is a compact row and nothing else, exactly like Droppy's own media, AI coding and File Tray activities. Hovering it or clicking it does what hovering or clicking any of Droppy's activities does: it opens the shelf, where your shelf widget is. The row hides while the shelf is open and comes back when it closes.

makeExpanded(context:) is not mounted by Droppy. It stays a requirement so droplets already built keep loading, but no card grows out of the row. Controls that must stay reachable, Stop and Start above all, belong in a shelf widget.

How content appears

Every motion around your row is Droppy's own, run by the same code that runs for Droppy's activities: the row unfolds out of the notch edge when it takes the seat, the shelf opens and closes on its own springs, and the row returns the way Droppy's own activities return after the shelf closes. You hand the host views; the views take no entrance transition of their own, or the entrance plays twice.

What you do animate is what changes inside the row while it is up, such as a glyph that flips in a wing. Use DroppyTransition, which carries Droppy's own curves:

Image(systemName: isCleaning ? "stop.fill" : "keyboard")
    .id(isCleaning)
    .transition(DroppyTransition.compactContent)

DroppyTransition is 1.8.0 API, so a droplet that uses it declares minAPI 1.8.0.

Seats

seat reports what you currently hold:

  • compact — the primary seat.
  • secondary — your activity rides the round companion pill beside one of Droppy's own, drawn by makeCompanionCompact(context:). Only an activity whose state sets joinsPersistentActivitySet is seated there, after Droppy's own companions. Draw for the context's slotSize, the square Droppy's own companions draw their mark in: a glyph, a ring or a few characters, never your wing's layout. Anything larger is scaled down to it and clipped. A flick or a tap on the pill promotes your activity to compact, as with Droppy's own companions, and the activity it displaced takes the pill.
  • none(_:) — with a reason. Droplets see idle when they publish nil, outranked when another droplet wins, and surfaceSuppressed while Droppy's own surfaces own the notch (media, timer, recording, meeting, HUD up, shelf open). pairFull, displayIneligible, and userDisabled are defined for future use and are not awarded to droplets today.

liveActivitySeatDidChange(_:) tells you when it moves. Use it to stop work nobody can see. Losing the seat is normal, not an error. A droplet holds compact only when none of Droppy's own activities want the notch; when media plays it moves to the companion pill (or stands down, if it does not join the persistent set), and when the shelf opens it stands down.

To stand down before your publisher catches up:

host.liveActivity.yield(reason: .idle)

Interactive rows

Set isInteractive on the state when your row has controls. The host widens the wings, and DroppyLiveActivityControlStyle gives you the 24pt circular control Droppy's own media row uses. Do not use a glass button style in this row; that chrome is for shelf widgets and settings, not island chrome.

Button { skip() } label: { Image(systemName: "forward.fill") }
    .buttonStyle(DroppyLiveActivityControlStyle(prominence: .accent))

Publish one stable state and keep it stable: fixed priority and fixed isInteractive, only accessibilityTitle ticks. Flapping either flag resizes the shell and drops hover. Publish nil only when the task truly ends, never to represent an idle toggle. Start and Stop belong in your shelf widget, which exists whether or not the row is published.

The companion pair

Droppy's own activities can pair as island plus detached pill. External droplets do not hold the pill seat today, so design the compact row to be complete on its own.

When that changes, the second renders through makeCompanionCompact(context:) with the slot size in context.slotSize. Return a narrower composition; the default reuses your trailing wing, which is usually too wide.