PopupKit
is a tool designed for enhanced view presentation within a SwiftUI
app.
Warning
With the public release of iOS 18.0
, Apple modified the internal behavior of UIView.hitTest()
, which has
affected the UIWindow-layering
pattern that PopupKit
relies on.
As a result, PopupKit
's interactive cover
presentation no longer function as expected.
Currently, they block all user interactions with the content underneath while they are presented.
I'm working on the solution.
SwiftUI
application targeted to iOS 15+.
I have a passion for SwiftUI
and use it daily for work. While I appreciate its design,
some components — especially the presentation APIs — still (iOS 18) lack the flexibility developers
often require. PopupKit
is my attempt to bridge these gaps while respecting SwiftUI
design principles, but
with added freedom and flexibility where needed.
PopupKit
offers several useful and fully-customizable view presentation methods that can be useful
in app development:
Notification |
Cover |
![]() |
![]() |
Confirm |
Fullscreen |
![]() |
![]() |
Popup |
Alert |
![]() |
![]() |
-
Notification: a popup notification with text or an image styled similarly to a system push notification. It is displayed above the app's view hierarchy.
- Customizable transition, appearance animations, and visual style.
- Notifications can expire after a set time and automatically dismiss.
- Supports user-initiated dismissal by a scroll-away gesture, just like system notifications.
-
Cover: analogue of the system
.sheet
presentation style with several enhancements:- Customizable transition, appearance animations, and background.
- Configurable height (system
.sheet
supports this only since iOS 16). - The cover's anchor point can be placed on any screen edge, not just the bottom.
- Flexible modality: allows you to block user interaction with content beneath the cover or with the cover itself(not working in iOS 18).
-
Fullscreen: analogue of the system
.fullscreenCover
presentation style, but with enhanced customizability of:- Transition and appearance animations
- Background
- Optional scroll-down-to-dismiss gesture for dismissing the current fullscreen view
-
Confirm: analogue of the system
.confirmationDialog
with several features. Customize:- Transition, appearance animations, and visual style
- Header content
- Actions appearence
- Confirm supports haptic feedback
-
Popup: popup modal window with ability to be stacked and customize parameters:
- Transition, appearance animations, and visual style
- Content
- Popups are stackable
-
Alert: modal window similar to system
.alert
. You can customize:- Transition, appearance animations, and visual style
- Header content
- Actions appearance
- Alerts are stackable
Although in SwiftUI
it's possible to display views above your app's view hierarchy, system sheets and fullscreen
covers will still overlay these views. To bypass these restrictions and unlock the full potential of PopupKit
,
it's necessary to integrate it into the app's lifecycle.
Federico Zanetello brilliantly covers the topic of overlaying SwiftUI
content above the presentation layer in
his article, How to layer multiple windows in SwiftUI. PopupKit
leverages the ideas presented in this article, and I would like to extend my thanks to the author for his research.
Tip
Before diving into the integration steps and usage tips, I’d like to highlight that I’ve created an
example project showcasing the complete integration
of PopupKit
. This example project includes working demonstrations of all the
available features, allowing you to explore and better understand how to implement PopupKit
's tools in your project.
Integrating PopupKit
into your app's lifecycle requires a bit of setup.
The basic principle is to configure a chain: App → AppDelegate → SceneDelegate → View layer.
This creates a second transparent UIWindow
in your app, configures PopupKit
presentation layers within it,
and injects the presenter objects into your view layer.
To achieve this, follow the steps outlined below:
SceneDelegate
setupAppDelegate
setupApp
struct setup- View layer injections
The first step is to create(if you don't have one already) a dedicated SceneDelegate
to manage the second UIWindow
,
which PopupKit
will use for presentation.
If you are fine with the default settings for transitions, animations, and anchor points, you can use the built-in
PopupKitSceneDelegate
class. If your app does not yet have a SceneDelegate
, use this class directly.
If you already have a SceneDelegate
, inherit from PopupKitSceneDelegate
and call the superclass method:
Code
class YourSceneDelegate: PopupKitSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
// Your custom code here
}
}
For a more advanced approach, you can fully customize the presentation behavior by copying
and modifying the PopupKitSceneDelegate
code into your own SceneDelegate
:
Code
class YourSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
private var popupKitWindow: UIWindow?
public lazy var coverPresenter = CoverPresenter()
public lazy var fullscreenPresenter = FullscreenPresenter()
public lazy var notificationPresenter = NotificationPresenter()
public lazy var confirmPresenter = ConfirmPresenter()
public lazy var popupPresenter = PopupPresenter()
open func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let scene = scene as? UIWindowScene {
let popupKitWindow = PassThroughUIWindow(windowScene: scene)
let popupKitViewController = PopupKitHostingController(
rootView: Color.clear
.coverRoot()
.ignoresSafeArea(.all, edges: [.all])
.fullscreenRoot()
.notificationRoot()
.confirmRoot()
.popupRoot()
.environmentObject(coverPresenter)
.environmentObject(fullscreenPresenter)
.environmentObject(notificationPresenter)
.environmentObject(confirmPresenter)
.environmentObject(popupPresenter)
.popupActionTint(.blue)
)
popupKitViewController.view.backgroundColor = .clear
popupKitWindow.rootViewController = popupKitViewController
popupKitWindow.isHidden = false
self.popupKitWindow = popupKitWindow
}
}
}
This code sets up a secondary UIWindow
that will hold and display the PopupKit
presentation layers. The
components of each presentation layer setup include:
- Presenter: The logical core that manages presenting and dismissing views, and keeps track of the stack.
- Root: The frame used to display the presented views.
- Environment: This connects the presenter to the
SwiftUI
view layer.
For example, to use a cover presentation, you will need to:
- Create a
CoverPresenter
object. - Add the
coverRoot()
modifier to the secondary window. - Inject the created
CoverPresenter
into theSwiftUI
environment.
Tip
Each ...Root()
modifier allows for configuration options, such as anchor points, transitions,
and animations, which can be tailored to your specific needs. You can also adjust the safe areas using
the .ignoresSafeArea(_)
modifier between ...Root()
calls.
Once you have set up your presenters and their environment, you're ready to move on to the next step.
On this step, it is necessary to create (if you don't have one already) a dedicated AppDelegate
to make use of the SceneDelegate
that you set up in the previous step.
If you opted for the default setup in the previous step and there is no existing AppDelegate
in your app,
you can proceed to the next step with the default PopupKitAppDelegate
class.
However, if you already have an AppDelegate
, ensure that you are using the SceneDelegate
you configured in
the earlier step:
Code
class YourAppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
sceneConfig.delegateClass = YourSceneDelegate.self
return sceneConfig
}
}
On this step, you need to ensure that you tell to SwiftUI
to use the configured AppDelegate
as the delegate
for your app. To do this, add (or verify that it's already present) the following line of code in your App
struct.
Replace YourAppDelegate
with the actual AppDelegate
class you configured in the previous step:
@main
struct YourApp: App {
@UIApplicationDelegateAdaptor var adaptor: YourAppDelegate //this
var body: some Scene {
WindowGroup {
// App root view
}
}
}
This line ensures that your custom AppDelegate
is properly set as the app’s delegate, allowing it to manage
lifecycle events and integrate PopupKit
. Once added, you can proceed to the last integration step.
At this point, your app's SceneDelegate
holds a set of presenters responsible for PopupKit
's presentation
functionality. Now, you need to inject these presenters into the view hierarchy. To achieve this, create a
dedicated root view in your App
struct, such as a MainSceneView
:
@main
struct YourApp: App {
@UIApplicationDelegateAdaptor var adaptor: YourAppDelegate
var body: some Scene {
WindowGroup {
MainSceneView() //this
}
}
}
In the MainSceneView
, inject all the necessary PopupKit
presenters into the SwiftUI
environment as follows:
struct MainSceneView: View {
@EnvironmentObject var sceneDelegate: YourSceneDelegate
var body: some View {
ContentView()
.environmentObject(sceneDelegate.coverPresenter) // Injects the cover presenter
.environmentObject(sceneDelegate.fullscreenPresenter) // Injects the fullscreen presenter
.environmentObject(sceneDelegate.notificationPresenter) // Injects the notification presenter
.environmentObject(sceneDelegate.confirmPresenter) // Injects the confirm presenter
.environmentObject(sceneDelegate.popupPresenter) // Injects the popup presenter
}
}
Tip
Alternatively, you can perform this injection within your ContentView
, but there is something to keep in mind:
- Injection requires access to
EnvironmentObject
andSwiftUI
does not allow such access directly within theApp
struct. - Ensure that all presenters are injected before any calls to
PopupKit
's presentation methods are made.
Once the integration process is complete, PopupKit
enables you to present views with a variety of tools, similar
to how system views are presented. You can easily implement these features by adding a PopupKit
modifier to your
view, passing a Binding
variable to control its state, and toggling that Binding
to trigger the presentation.
To present a view in fullscreen mode, you can use the fullscreen()
modifier. Here's an example:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit fullscreen") {
isPresented.toggle()
}
.fullscreen(
isPresented: $isPresented, // 1. Controls the presentation state
background: .ultraThinMaterial, // 2. Defines the background style
ignoresEdges: [.bottom, .leading], // 3. Specifies which edges to ignore
dismissalScroll: .dismiss(predictedThreshold: 300) // 4. Enables swipe-to-dismiss with threshold
) {
Color.red // Content of the fullscreen view
}
}
}
}
Let's break down the key elements of the fullscreen()
modifier:
- Background Customization (background): You can define the fullscreen background using a
ShapeStyle
of your choice (e.g.,.ultraThinMaterial
for a blur effect). - Safe Area Ignoring (ignoresEdges): By default, the content respects the safe areas of the device, but you can specify which edges should be ignored if desired.
- Swipe-to-Dismiss Gesture (dismissalScroll): Enable a swipe-down-to-dismiss gesture with a customizable threshold, controlling how much scrolling is required to dismiss the fullscreen view.
To display a view using a cover presentation mode, you can utilize the cover()
modifier provided by PopupKit
.
Here's how you can implement it:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit Cover") {
isPresented.toggle()
}
.cover(
isPresented: $isPresented, // 1. Controls the presentation state
background: .ultraThinMaterial, // 2. Defines the background style
modal: .modal(interactivity: .interactive), // 3. Configures modality (interactive/noninteractive or none)
cornerRadius: 15 // 4. Sets the corner radius for the cover view
) {
Color.red // 5. Content of the cover view
}
}
}
}
Key elements of cover()
modifier:
- Background Customization (background): You can choose the cover's background style, such as
.ultraThinMaterial
to add a subtle blur effect, or any otherShapeStyle
. - Modal behavior (modal):
- Non-modal: The cover view does not block interaction with other views on the screen.
- Modal-interactive: A dimmed background appears around the cover, and the cover can be dismissed by tapping the dimmed area or scrolling it down.
- Modal-noninteractive: Similar to the interactive modal, but the cover cannot be dismissed by tapping outside the cover or scrolling.
- Corner Radius (cornerRadius): You can adjust the corner radius of the cover to create a smooth, rounded edge for the view.
The content inside the cover view is provided as a trailing closure. The height of the cover is determined by the content you provide. If the content’s height exceeds the device’s screen height, the cover will occupy the full screen, and its content will align to the top of the screen.
You can display a view with a notification presentation style by using the notification()
view modifier.
Here’s an example implementation:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit Notification") {
isPresented.toggle()
}
.notification(
isPresented: $isPresented, // 1. Controls the presentation state
expiration: .timeout(.seconds(2)) // 2. Defines how long the notification remains visible
) {
RoundedRectangle(cornerRadius: 15).fill(.yellow) // Content of the notification view
}
}
}
}
Key Elements of notification()
modifier:
- Expiration Time (expiration): You can set an expiration time using
.timeout()
to specify how long the notification remains visible. For instance,.seconds(2)
means the notification will automatically dismiss after 2 seconds. If no expiration is set, the notification will remain until dismissed manually (e.g., by swiping). - Dismissal behaviour:
- Manual Dismissal: All notifications can be manually dismissed by the user with swipe, similar to system push notifications. If no expiration time is set, manual dismissal will be the only method of removal.
- Automatic Dismissal: When an expiration time is set, the notification will automatically dismiss itself once the timer expires.
Tip
If multiple notifications are presented in sequence, the timer resets when a new notification is shown. For example, if Notification A is still active when Notification B appears, A’s timer will restart when B is dismissed.
When you need to make user pick one of actions you can use a confirm presentation mode, utilizing the confirm()
modifier provided by PopupKit
.
Here's how you can implement it:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit Cover") {
isPresented.toggle()
}
.confirm(isPresented: $isPresented) {
Text("Are you sure?")
} actions: {
Regular(
text: Text("Maybe not"),
action: { print("Maybe not was picked") }
)
Cancel(text: Text("Not this time"))
Destructive(
text: Text("I am sure"),
action: { print("I am sure was picked") }
)
}
}
}
}
Key elements of confirm()
modifier:
- Header Customization (header): You can use any
View
to present as dialog's header. - ActionBuilder: Simplified syntax with
@ActionBuilder
for implementig actions. - Auto-sorting: Order of actions during dialog's presentation is the same as you provides, except the cancel actions listed below.
You can customize actions font appearence using dedicated EnvironmentValues
through View
extension functions - .popupActionTint(_)
and .popupActionFonts(_)
. Also, a number of parameters can be customized with passing parameters to .confirmRoot()
call:
- background - background of dialog
- cancelBackground - background of section with cancel actions.
- cornerRadius - a corner radius of section with header and regular actions and section with cancel actions.
Note
It is possible to present only one confirm at a time, any attempts to present a dialog, while there is presented one, will be ignored.
To present some information to user, request text input or some action to pick you can utilize .popup()
presentation modifier provided by PopupKit
.
Here's how you can implement it:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit Cover") {
isPresented.toggle()
}
.popup(
isPresented: $isPresented, // 1. Controls the presentation state
outTapBehavior: .dismiss, // 2. Determines behaviour when user tap outside the view
ignoresEdges: [] // 3. Ignore specified edges of the safe area
) {
PopupView() // Content of the popup view
}
}
}
}
Key elements of popup()
modifier:
- Presentation Control (isPresented): A
Binding<Bool>
variable controls when the popup is presented or dismissed. Toggling this binding will trigger the presentation state. - Tap Outside Behaviour (outTapBehavior): Popup could be dismissed on outside tap.
- Safe Area Ignoring (ignoresEdges): By default, the content respects the safe areas of the device, but you can specify which edges should be ignored if desired.
popupAlert()
is right tool for you if you want more freedom than system .alert()
has to offer.
Here's how you can implement it:
Example
struct YourView: View {
@State private var isPresented = false
var body: some View {
VStack {
Button("Show PopupKit Cover") {
isPresented.toggle()
}
.popupAlert(isPresented: $isPresented) { // 1. Controls the presentation state
YouCustomHeader() // 2. Content of the popup view
} actions: {
Regular( // 3. Actions to offer
text: Text("Okt"),
action: { print("Ok") }
)
)
}
}
}
Key elements of popupAlert()
modifier:
- Respects safe area insets
- Auto scrollable: Alert's actions list becames scrollable if height of the screen is not enough.
- ActionBuilder: Simplified syntax with
@ActionBuilder
for implementig actions. - Auto-sorting: Order of actions during dialog's presentation is the same as you provides, except the cancel actions listed below.
Actions font can be changed appearence using dedicated EnvironmentValues
through View
extension functions - .popupActionTint(_)
and .popupActionFonts(_)
.
In addition to view modifiers, PopupKit
offers another powerful tool for managing presentations: the Presenter
.
Each presentation layer in PopupKit
has its own Presenter
, which is injected into the SwiftUI
environment system
during the integration process. Presenter
acts as the logical core that manages presentation operations, offering a
set of methods to control presentation flow and access the presentation stack.
Key functions of Presenter
:
present()
: Triggers a new presentation, adding a view to the top of the presentation stack.dismiss()
: Dismisses the current (top-most) view on the presentation stack.popToRoot()
: Dismisses all presented views.- and more
Each Presenter
also maintains a presentation stack, which holds all currently presented entities in the order they
were shown. This stack can be checked at any time to determine which view is currently being presented.
PopupKit
provides an easy way to debug presentation behavior using the verbose mode on Presenter
. By enabling
verbose mode, any changes to the presentation stack are logged in the Xcode console, making it easy to track
presentation events and troubleshoot any issues.
You can activate verbose mode when initializing any Presenter
by passing the isVerbose
argument in the Presenter
's
initializer:
let presenter = CoverPresenter(isVerbose: true)
Note
Verbose mode is limited to DEBUG
builds, ensuring that redundant logs do not appear in RELEASE
builds.
This helps you maintain clean, production-ready logs while benefiting from detailed output during development.
Due to its deep integration at a higher app level, unfortunately, PopupKit
doesn’t fully act as expected within SwiftUI
Previews
as it do in simulator or on a device.
I’ve worked to minimize the inconvenience, but some limitations remain. You can choose one of two options
which is more suitable in your case.
If you don’t need PopupKit
to work in Previews
, you can easily disable it. Go to the
Package.swift
file in PopupKit
package and uncomment the section that includes the line:
.define(«DISABLE_POPUPKIT_IN_PREVIEWS»)
This will completely disable PopupKit
from running in Previews
and (as desribed below) will free you from writing
a bolierplate code.
If you do want to preview PopupKit
presentation methods, use the following modifiers in
your previews:
- previewPopupKit(_)
- previewPopupKit(ignoresSafeAreaEdges)
These modifiers should be applied in every preview macro or PreviewProvider
where PopupKit
is expected to function. It's important to attach these modifiers as high as possible in the
view hierarchy. The view you attach the modifier to is treated as the root view,
so make sure this root view occupies the full screen — otherwise, the presentation views won’t behave as expected.
SPM
installation: in Xcode tap File → Add packages…, paste is search field the URL of this page and
press Add package. After that, you should complete the integration.
❌ NavigationStack
is not working inside a cover
.
❌ NavigationStack
is not working inside a dismissable fullscreen
. Fullscreen with DismissalScroll.none
is fine.
❌ Interactive covers is not letting you to interact with the content beneath on iOS 18
.
- Notification
- Cover
- Fullscreen
- Confirmation dialog
- Popup
- Alert
- Confirm support for album orientation
- Fix known issues