Skip to content

Advanced Popups

Daniel S edited this page Sep 21, 2026 · 4 revisions

Advanced Popups (Pro)

DesignFoundation Pro builds on the free Popups engine (.dfPopup, DFPopupConfiguration, DFPopupStyle, DFPopupCard, DFPopupBackdrop) with unified overlay / sheet / window presentation, dismiss reasons, keyboard handling, scrims, motion presets, a scroll popup, a priority queue, and a set of finished rich popups and toasts. These are modifiers and a coordinator object, not constructible views.

Requires DesignFoundationPro 2.3.0 or later, which requires DesignFoundation 1.7.1 or later. import DesignFoundationPro re-exports the free package.

See it in motion: every feature below has a real Simulator recording on the Pro popups section of the docs site.

Card-style rich popups (celebration, permission, promo, rating, input) are built on the free DFPopupCard, so they follow the environment DFPopupStyle (.standard, .frosted, .accent, .gradient, .inverse, .outlined, .tinted(_:), .glass). Apply .dfPopupStyle(_:) outside the presenting modifier:

struct StyledCelebration: View {
    @State private var showPopup = false

    var body: some View {
        Color.clear
            .dfCelebrationPopup(isPresented: $showPopup, title: "Order placed!")
            .dfPopupStyle(.frosted)
    }
}

The macOS NSPanel window path and the visionOS overlay fallback compile but have not been exercised at runtime; iOS is the verified path.

Unified presentation

dfPopupPresentation is one API with three display modes:

display: Behavior
.overlay (default) Drawn over the content
.sheet A real SwiftUI sheet for bottom-anchored popups (always a native sheet on macOS), a full-screen cover with a clear background for the rest. The binding stays in sync both ways; a system swipe-down or a tap outside a bottom sheet reports .drag
.window A transparent UIWindow on iOS (above sheets, covers and navigation bars) and a borderless child NSPanel on macOS (clicks fall through outside the popup); visionOS draws an overlay
struct PresentationExample: View {
    @State private var showPopup = false

    var body: some View {
        Color.clear.dfPopupPresentation(
            isPresented: $showPopup,
            display: .window,
            configuration: .floater(position: .top),
            keyboard: .avoid,                       // .avoid (default) lifts the popup above the keyboard | .ignore
            scrim: .blur(0.7),                      // .dim(0.3) | .blur(_:) | .custom { } | .clear
            motion: .zoom,
            pausesOnHold: true,                     // a finger or pointer on the popup pauses its auto-dismiss timer
            chrome: .style,                         // .style wraps content in DFPopupStyle; .none draws it as-is
            haptic: .success,
            onDismiss: { reason in print(reason) }  // DFPopupDismissReason
        ) {
            Text("Hello")
        }
    }
}

A visible scrim can let touches through in overlay mode with .passingTouches(). There is also an item form and a prebuilt DFPopupOptions form; DFPopupLayer is the engine underneath, usable directly inside your own container.

struct NonBlocking: View {
    @State private var showPopup = false

    var body: some View {
        Color.clear.dfPopupPresentation(isPresented: $showPopup, scrim: .dim(0.2).passingTouches()) {
            Text("Non-blocking")
        }
    }
}

dfPopupWindow(isPresented:configuration:onDismiss:) remains as shorthand for display: .window. Its onDismiss is () -> Void, with no reason, unlike the other presenters.

Dismiss reasons

DFPopupDismissReason is .timeout, .tap, .outsideTap, .drag, .programmatic or .preempted. A reason is delivered to onDismiss by dfPopupPresentation, dfScrollPopup, DFPopupCenter.present, and the rich popups and toasts that take a reason-style onDismiss (see the table under Rich popups). Not every presenter reports one: dfPopupWindow has a plain () -> Void, dfLiveCapsule and the coachmarks have no onDismiss, and dfNotificationBanners reports (item, reason).

Popup content can close its own popup with a reason:

struct Closer: View {
    @Environment(\.dfPopupDismiss) private var dismiss      // no-op outside a Pro popup
    @Environment(\.dfPopupCountdown) private var countdown  // the auto-dismiss timer, if any

    var body: some View {
        Button("Done") { dismiss(.tap) }
    }
}

Pause on hold and countdowns

Resting a finger or pointer on an auto-dismissing popup pauses its timer. DFPopupCountdown (an observable class) drives it. DFPopupCountdownBar and DFPopupCountdownRing follow the popup's live, pausable countdown; given a duration: they run their own countdown outside a popup.

struct CountdownExample: View {
    var body: some View {
        VStack {
            DFPopupCountdownBar(duration: 5)
            DFPopupCountdownRing(duration: 5)
        }
    }
}

Motion

DFPopupMotion presets: .snappy, .bouncy, .gentle (springs), .zoom, .zoomOut, .elastic (scale + blur + fade), .drop (falls from above), .slideFade and .custom(Animation). Reduce Motion falls back to a fade. On a plain free-engine configuration, .motion(_:enterFrom:exitTo:) sets the animation and independent enter and exit edges.

struct MotionExample: View {
    @State private var showPopup = false

    var body: some View {
        Color.clear.dfPopupPresentation(isPresented: $showPopup, motion: .elastic) { Text("Bouncy") }
    }
}

struct BouncyConfiguration {
    static let bouncy = DFPopupConfiguration.floater(position: .bottom)
        .motion(.bouncy, enterFrom: .bottom, exitTo: .trailing)
}

DFPopupHaptic offers .light, .medium, .selection, .success, .warning and .error: DFPopupHaptic.success.play(). It uses UIKit generators on iOS, a generic trackpad haptic on macOS, and is a no-op on visionOS.

Scroll popup

A scrollable popup with a pinned header. Dragging dismisses only when the content is at its top, so the drag and the scroll never fight. Options: detents (.medium, .large, .fraction, .height) with snapping, a pinned footer:, header styles (.plain, .centered, .prominent, .closable), side sheets (edge: .leading / .trailing, regular width only; compact falls back to the bottom), scrim: and onDismiss:. Other parameters: fromTop, maxHeightFraction (ignored when detents are set), showsGrabber, dismissOnOutsideTap.

struct ScrollPopupExample: View {
    @State private var showScrollPopup = false

    var body: some View {
        Color.clear.dfScrollPopup(
            isPresented: $showScrollPopup,
            edge: .automatic,
            detents: [.medium, .large],
            headerStyle: .closable,
            scrim: .dim(),
            onDismiss: { reason in print(reason) }
        ) {
            Text("Release notes")          // pinned header
        } content: {
            Text("Long, scrollable content…")
        } footer: {
            Button("Update now") { }
        }
    }
}

DFPopupCenter

One presenter for many popups. One shows at a time; the rest wait ordered by DFPopupPriority (.low, .normal, .high, .critical). A higher priority preempts the current popup (its onDismiss gets .preempted and it re-queues). Re-presenting an id that is showing or waiting replaces it instead of stacking a duplicate (the replaced popup's onDismiss gets .programmatic).

struct CenterExample: View {
    @StateObject private var popups = DFPopupCenter()

    var body: some View {
        Button("Go offline") {
            popups.present(id: "offline", priority: .high, configuration: .toast(), haptic: .warning,
                           onDismiss: { reason in print(reason) }) {
                VStack {
                    Text("You're offline")
                    DFPopupCountdownBar()
                }
            }
        }
        .dfPopupCenter(popups)
    }
}

popups.dismiss(id:), popups.dismissAll() and popups.presentedIDs (showing or waiting, current first) complete the API.

Rich popups

Each is a public view plus a presenting modifier. Which ones accept display: and a reason-style onDismiss differs:

Modifier display: onDismiss: (DFPopupDismissReason)
dfCelebrationPopup, dfPermissionPrompt, dfPromoPopup, dfRatingPrompt, dfInputPopup, dfConsentSheet, dfActionPopup, dfUndoToast, dfProgressToast Yes Yes
dfLiveCapsule Yes No
dfNotificationBanners No (item, reason) form only
dfCoachmarkTour, dfCoachmark No No

Nothing needs image assets: everything is SF Symbols and theme gradients.

struct RichPopupExamples: View {
    @State private var celebrate = false
    @State private var permission = false
    @State private var promo = false
    @State private var rating = false
    @State private var input = false
    @State private var consent = false
    @State private var actions = false

    var body: some View {
        Color.clear
            // Hero icon springs in with a confetti burst (Reduce Motion: fade only)
            .dfCelebrationPopup(isPresented: $celebrate, title: "Order placed!",
                                message: "Tracking is on its way.", actionTitle: "Continue")
            // Pre-permission priming; trigger the system prompt yourself in onAllow
            .dfPermissionPrompt(isPresented: $permission, kind: .notifications, onAllow: { }, onDecline: { })
            // Media area (.symbol / .gradient / .image), badge, CTA, close button
            .dfPromoPopup(isPresented: $promo, badge: "New", media: .symbol("sparkles"),
                          title: "Meet Focus mode", message: "Silence the noise.",
                          actionTitle: "Try it", secondaryTitle: "Later", onAction: { })
            // Stars, then feedback text (below threshold) or a review nudge (at or above)
            .dfRatingPrompt(isPresented: $rating, appName: "Orbit", threshold: 4,
                            onSubmit: { stars, feedback in }, onRequestReview: { })
            // Validated text field, keyboard-aware, auto-focused
            .dfInputPopup(isPresented: $input, title: "Rename folder", initialText: "Projects",
                          validators: [DFRequiredValidator(), DFMaxLengthValidator(maxLength: 40)],
                          onSubmit: { name in })
            // A sheet of toggleable categories
            .dfConsentSheet(isPresented: $consent, categories: DFConsentCategory.standard, onSave: { enabledIDs in })
            // Rows with normal, destructive and cancel roles; anchor .center | .bottom | .target(id)
            .dfActionPopup(isPresented: $actions, title: "Photo", actions: [
                DFActionPopupItem("Share", systemImage: "square.and.arrow.up") { },
                DFActionPopupItem("Delete", systemImage: "trash", role: .destructive) { },
                DFActionPopupItem("Cancel", role: .cancel) { },
            ], anchor: .bottom)
    }
}

Toasts and live status

struct StatusPopupExamples: View {
    @State private var undo = false
    @State private var progress = false
    @State private var capsule = false
    @State private var capsuleExpanded = false
    @State private var banners: [DFNotificationBannerItem] = [
        DFNotificationBannerItem(icon: .symbol("message.fill"), title: "Maya", message: "Lunch tomorrow?", timestamp: "now")
    ]

    var body: some View {
        Color.clear
            // Message + Undo with a live countdown ring (or .bar); holding pauses it.
            // onExpire fires only on .timeout, so it is the place to commit the destructive action.
            .dfUndoToast(isPresented: $undo, message: "Photo deleted", duration: 5, indicator: .ring,
                         onUndo: { }, onExpire: { })
            // Ring morphs progress, then a success check or failure cross; Cancel while running, Retry on failure
            .dfProgressToast(isPresented: $progress, state: .progress(0.4), title: "Uploading photo",
                             detail: "3.2 of 8 MB", onCancel: { }, onRetry: { })
            // Push-style banners from the top, newest in front; swipe up to dismiss
            .dfNotificationBanners(items: $banners, onTap: { item in })
            // Dynamic-Island-style pill that expands with matched geometry
            .dfLiveCapsule(isPresented: $capsule, isExpanded: $capsuleExpanded, label: "Recording") {
                Image(systemName: "waveform")
            } trailing: {
                Text("0:42")
            } expanded: {
                Text("Recording voice memo")
            }
    }
}

DFProgressToastState is .progress(Double?) (nil is indeterminate), .success or .failure. After .success the toast dismisses itself after autoDismissOnSuccess (default 2 s); .failure stays until dismissed.

Coachmarks

Mark targets with .dfPopupAnchor(id) and run a DFCoachmarkTour (an observable class; keep it in @State) over the view that contains them. The dim gets a spotlight cut-out (spotlight: false for a plain scrim) and the callout arrow points at the target.

struct CoachmarkExample: View {
    @State private var tour = DFCoachmarkTour(steps: [
        DFCoachmarkStep(target: "share", title: "Share", message: "Send it to a friend.")
    ])

    var body: some View {
        VStack {
            Button("Share") { }.dfPopupAnchor("share")
            Button("Start tour") { tour.start() }
        }
        .dfCoachmarkTour(tour, spotlight: true)
    }
}

The tour also has .next(), .back(), .skip(), .finish() and an onFinish callback. For a single hint, use .dfCoachmark(isPresented:target:title:message:).

Clone this wiki locally