Repository navigation
Advanced Popups
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.
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.
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) }
}
}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)
}
}
}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.
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") { }
}
}
}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.
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)
}
}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.
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:).