Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

AppKit Sheets on macOS: Document Modality, Queueing, and Completion

Present AppKit document-modal sheets with explicit parent ownership, queued presentation handling, semantic responses, and reliable dismissal cleanup.

An AppKit sheet is a document-modal interaction attached to a parent window. It blocks most events directed to that parent while allowing the application’s main run loop to continue. That is different from blocking the entire application with a synchronous modal loop. The distinction matters for architecture: timers, other windows, callbacks, and background work can still progress while the parent document is unavailable for normal interaction.

A sheet has a parent, a child window or hosted view, a presentation request, and a completion path. Applications get into trouble when they open sheets from transient view controllers without retaining the relevant state, infer success from the fact that the sheet disappeared, or try to present several sheets concurrently without defining queueing and ownership.

Begin a document-modal session

The NSWindow.beginSheet(_:completionHandler:) API presents a sheet or queues it if the parent already has one. It returns control to the caller while the sheet is visible; the run loop does not enter a special mode to accomplish document modality. Most events targeted at the parent are prohibited until the sheet ends. The completion handler runs when the modal session ends, making it the appropriate place to reconcile the result and release sheet-specific state.

import AppKit

@MainActor
final class SheetCoordinator {
    private var activeSheet: NSWindow?

    @discardableResult
    func present(_ sheet: NSWindow, on parent: NSWindow) -> Bool {
        guard activeSheet == nil else { return false }
        activeSheet = sheet
        parent.beginSheet(sheet) { [weak self] response in
            self?.activeSheet = nil
            self?.handle(response)
        }
        return true
    }

    func dismiss(_ sheet: NSWindow, on parent: NSWindow,
                 response: NSApplication.ModalResponse) {
        guard activeSheet === sheet else { return }
        parent.endSheet(sheet, returnCode: response)
    }

    private func handle(_ response: NSApplication.ModalResponse) {
        // Map the response to an explicit domain decision.
    }
}

This example accepts only one request owned by this coordinator at a time; false means it already has a pending or visible sheet, while AppKit may queue the accepted request behind a sheet owned elsewhere. If the product should queue multiple requests of its own, store a queue of typed requests and ensure each completion advances exactly one request. Do not keep a single mutable activeSheet slot while multiple requests are pending unless that slot represents the whole queue and its state transitions are defined.

Response codes are not a substitute for application state. Use them to distinguish a user action such as confirm or cancel, then validate the underlying model again before committing. A sheet can end because of explicit user input, programmatic dismissal, owner-window lifecycle, or another termination path. Ensure every close route leads to a safe final state.

Queueing is not the same as parallel presentation

If a parent already has a presented sheet, AppKit queues the new sheet until the current one is dismissed. That means the call can return while the requested sheet is not yet visible. Do not assume a call to beginSheet means the new child is immediately the active sheet. If the UI needs to show progress or cancel a queued request, the app must retain the request and model queue state separately from visible presentation.

Avoid queuing duplicate sheets in response to repeated menu actions, repeated document errors, or multiple validation callbacks. Use a request identifier and coalesce duplicate presentation requests. If a second confirmation depends on the answer to the first, enqueue it after the first completion with the updated model, rather than creating both windows against stale state.

When the parent window closes while a sheet is queued or visible, decide whether to cancel the action, complete it in the background, or ask the user to save. Keep document dirty state and sheet state under the same document coordinator. Do not assume modal presentation itself prevents a document model from being changed by an asynchronous callback.

Keep work asynchronous and responsive

A sheet is modal only relative to its parent. The application event loop continues to run, so asynchronous work and other windows can change shared state. Do not block the main thread while waiting for a sheet’s completion or for an operation that the sheet initiated. Use callbacks or async continuations and keep UI mutations on the main actor.

If a sheet performs validation or file access, expose loading and failure states within the sheet and disable only the controls that cannot safely act during that work. Cancellation should cancel or detach the underlying task according to the product’s semantics. Closing a sheet does not automatically roll back a server request or file write. Use a cancellation token, operation identifier, or staging artifact so late completion cannot silently apply to a different document.

The SwiftUI hosting sheet API is distinct from a manually constructed child NSWindow, but both have a completion path. Apple documents that the SwiftUI sheet completion runs when the sheet is dismissed for any reason. Do not interpret that callback alone as confirmation. If a Boolean presentation state drives a SwiftUI .sheet, bind dismissal state to the model explicitly and consider interactive dismissal rules when data entry must be completed.

End and respond exactly once

Call endSheet(_:returnCode:) on the parent with the child sheet when the app decides to finish a manually managed session. Centralize this call so multiple buttons cannot race to end the same sheet with conflicting responses. Use a one-shot completion transition that marks the request finished, updates the document or feature state, and releases retained references.

If a save operation fails, keep the sheet open or transition to an error state instead of returning a success response. If the user cancels, discard only staged state; preserve original document data. If the action succeeds, persist or commit first, then dismiss with the success response. A disappearing window without durable side-effect completion is not success.

Avoid invoking the completion logic both from a button handler and from the completion callback. The button should request termination; the completion callback should own the final response handling. When an async task completes after the sheet has ended, check the request identifier and presentation generation before updating UI.

Layout, focus, and accessibility

Make sheet content size itself around a useful minimum and adapt to localization and larger text. A form that fits English may exceed the available sheet width in another language. Use Auto Layout constraints that make content scroll or resize rather than clipping controls. Keep the default and cancel actions discoverable and ensure keyboard shortcuts do not bypass validation.

When the sheet is dismissed, focus should return to a meaningful control in the parent document. If the action changed the document, update the accessible state and announce completion where appropriate. Test VoiceOver, keyboard navigation, full keyboard access, Escape behavior, and high contrast. Do not rely exclusively on color to indicate the result of validation.

Treat the parent document as a dependency of the sheet. If the document’s underlying model can change while asynchronous work is running, capture a version or revision token when the request starts. Before applying the result, compare that token with the current document state. If the owner was closed or replaced, discard presentation-only updates and report any durable side effect through the app’s normal model layer.

For a destructive operation, make the consequence and target explicit in the sheet itself. A generic “Continue?” confirmation can be detached from the document the user thinks it affects, especially when several windows are open. Include enough non-sensitive context to identify the target, and revalidate that target at commit time. Window modality prevents interaction with one parent; it does not lock the model against background changes or commands from another process.

Failure and acceptance matrix

Test one sheet, a second queued sheet, repeated identical requests, user confirm, user cancel, programmatic dismiss, validation failure, parent close, owner object deallocation, an asynchronous save completing after cancel, and a window hidden or closed while work continues. Assert there is one final response, no duplicate commit, no stale sheet displayed for a closed document, and the parent becomes interactive again after dismissal.

Log request ID, parent document identity in a privacy-safe form, queued-versus-visible state, response category, and durable commit result. Measure sheet dwell time separately from asynchronous work. Avoid logging field values entered by the user. A sheet that remains open because a save is stuck should be distinguishable from one waiting for user input.

Document modality gives a focused interaction without freezing the whole app. NSWindow supplies presentation and session mechanics, while the application must own queueing, model validation, async cancellation, response semantics, and cleanup. Treat the sheet as a stateful workflow attached to a live document, not as an alert-shaped function call.

Related:

Sources:

Comments