A HUD for the loading, success and failure states of an iOS app, for SwiftUI and UIKit.
- What it is
- Requirements
- Installation
- Quick start
- Usage
- Configuration
- Behaviour your app must know
- Example app and tests
- Versions and migration
- Known issues
- The family
- Contributing, security, licence
DMUnLoader shows one HUD at a time over a scene of your app: a spinner while work runs, then a
success, or a failure with Close and an optional Retry. A loading manager holds the state, and
your code calls it: showLoading, showSuccess, showFailure and hide. The HUD is drawn in a
window of its own above the scene, and while it is shown it takes the touches of the scene, so
the user waits for the result. The views of the states are the library's, with your settings, or
your own.
Use it when an operation blocks the screen: a sign-in, a payment, a save that the user waits for before going on.
When not to use it:
- For progress that must not block the screen. While a HUD is shown, the content under it
receives no touch: put a
ProgressViewin your view instead. - For several operations at once, each with its own progress: a scene shows one state at a time.
- If your app must not contain a private API of the system. The default backdrop is the variable blur of DMVariableBlurView, which uses one, and DMUnLoader depends on DMVariableBlurView, so its code stays in your app whatever backdrop you choose. See Backdrop.
- Swift 6.2 or later, which is Xcode 26.0 or later.
- iOS 17 or later.
What each version is verified with:
| What | How |
|---|---|
| iOS 26.5 and 26.2 (Xcode 26.6) | the tests of the package run on simulators in CI |
| iOS 26.5 (Xcode 26.6) and 18.6 (Xcode 26.3) | the tests of the example app, hosted and UI tests, run on simulators in CI |
| iOS 17.5, 18.6 and 26.5 (Xcode 27.2) | the tests of the package and of the example app ran on simulators for 1.2.0; before each release the package suite runs locally on iOS 17.5 and 18.6, and the example app on iOS 17.5 |
| iPadOS 26.2 (Xcode 27.2) | the tests of the example app, two scenes included, run on an iPad simulator before each release |
| Swift 6.2 (Xcode 26.0.1) | CI compiles the library with that compiler; no test runs with it |
Several windows on an iPad, beyond the two scenes that the tests of the example app use, are not tested.
In Xcode, choose File > Add Package Dependencies and enter
https://github.com/nikolay-dementiev/DMUnLoader. In a package manifest:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.iOS(.v17)],
dependencies: [
.package(url: "https://github.com/nikolay-dementiev/DMUnLoader.git", from: "1.2.0")
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "DMUnLoader", package: "DMUnLoader")
]
)
]
)DMUnLoader brings DMAction and DMVariableBlurView with it. The libraries that only its tests use are not fetched for your app.
pod 'DMUnLoader', '1.1.0'Version 1.1.0 is the last release planned for the CocoaPods trunk: the CocoaPods post of 30 November 2024 announces that the trunk stops accepting new podspecs on 2 December 2026. Later releases come through Swift Package Manager only. The podspec stays in the repository, and CI lints it.
import DMUnLoader
import SwiftUI
@main
struct QuickStartApp: App {
@StateObject private var loadingManager = DMLoadingManagerMain()
var body: some Scene {
WindowGroup {
DMRootLoadingView(manager: loadingManager) { manager in
LoadButton(loadingManager: manager)
}
}
}
}
struct LoadButton: View {
let loadingManager: DMLoadingManagerMain
private let provider = DefaultDMLoadingViewProvider()
var body: some View {
Button("Load") {
loadingManager.showLoading(provider: provider)
Task {
do {
let message = try await loadMessage()
loadingManager.showSuccess(message, provider: provider)
} catch {
loadingManager.showFailure(error, provider: provider)
}
}
}
}
}
// A stand-in for your own work.
func loadMessage() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "Loaded"
}A tap shows the spinner, then the success, which hides by itself after 2 seconds.
DMRootLoadingView(manager:content:), as in the quick start, shows the HUD of the manager you
give it over the scene of the window the view is in. It needs no app delegate of DMUnLoader.
Keep the manager alive outside the view, as the @StateObject does, and use one such view per
scene: the HUD of each view joins the scene of that view. Another manager given in a later update
takes over the HUD. When the view moves to another window, its HUD moves with it; a window that the
host moves to another scene is not followed.
DMObservableLoadingManager is for an app that keeps its state with Observation (@State, the
environment) and not with ObservableObject. It wraps a DMLoadingManagerMain. The manager stays the
one owner of the state: the HUD still observes it as an ObservableObject, its $loadableState
publisher is unchanged, and a manager of your own is untouched, because the facade wraps
DMLoadingManagerMain only. The facade holds the manager, shows its loadableState as a property that
Observation tracks and that you cannot set, and forwards showLoading(provider:),
showSuccess(_:provider:), showFailure(_:provider:onRetry:) and hide() to it. Give
facade.manager to DMRootLoadingView(manager:content:), and read loadableState on the facade: a
view that reads it on the manager is not updated, because Observation does not track the manager.
import DMUnLoader
import SwiftUI
@main
struct ObservationApp: App {
@State private var loading = DMObservableLoadingManager(manager: DMLoadingManagerMain())
var body: some Scene {
WindowGroup {
DMRootLoadingView(manager: loading.manager) { _ in
ObservationContent(loading: loading)
}
}
}
}
struct ObservationContent: View {
let loading: DMObservableLoadingManager
var body: some View {
VStack {
Text(verbatim: loading.loadableState.rawValue)
Button("Load") {
loading.showLoading(provider: DefaultDMLoadingViewProvider())
}
}
}
}A view that reads loadableState is evaluated again when the state changes, wherever the change comes
from: the operations of the facade, those of the manager, or its auto-hide. Several facades may wrap
one manager, and each follows it. Everything runs on the main actor.
- Observation is not synchronisation and not a lossless stream of events. Measured with Xcode 27.1:
showing a state that equals the current one again (the loading state of the same provider twice, or
hide()when nothing is shown) did not notify, while the loading state of another provider and thehide()of a shown HUD did. If you need to see every change, subscribe to the$loadableStatepublisher of the manager, which is unchanged. - Where to make the facade. Measured with an app built by Xcode 27.1 on iOS 17.5, 18.6 and 26.5: a
parent view that was rebuilt six times made one facade, one manager and one subscription for a child
view with
@State private var loading = DMObservableLoadingManager(manager: DMLoadingManagerMain()). The SDK decides how often the initial value of@Stateis evaluated, and no other SDK was measured. To not depend on it, make the facade once, in yourAppstruct, which SwiftUI creates once, and pass it down, as the example above does. - UIKit. A view controller that read
loadableStateinviewWillLayoutSubviews()was laid out again, with no call tosetNeedsLayout(), when the state changed: on iOS 26.5 always; on iOS 18.6 only when the Info.plist of the app hasUIObservationTrackingEnabledset to YES; on iOS 17.5 never, with the key or without it. Nothing else of UIKit is promised. Where UIKit does not track, callwithObservationTrackingyourself, which fired on all three systems, or keep using the$loadableStatepublisher. withObservationTrackingruns itsonChangebefore the state changes. Measured on iOS 17.5, 18.6 and 26.5: a read ofloadableStateinsideonChangestill gave the previous state, and a read after the call that changed it gave the new one. So do not render from insideonChange: register again, and read, in a later turn of the main actor, as the snippet below does. Do not change the manager from insideonChangeeither, because the change is still in progress.
import DMUnLoader
import Observation
@MainActor
func followLoadableState(of loading: DMObservableLoadingManager, render: @escaping @MainActor (DMLoadableType) -> Void) {
withObservationTracking {
render(loading.loadableState)
} onChange: {
Task { @MainActor in
followLoadableState(of: loading, render: render)
}
}
}DMRootLoadingView(content:) creates the loading manager itself and passes it to your content.
It needs the app delegate: DMAppDelegateType, or DMAppDelegate<YourManager> for a manager type
of your own, puts the scene delegate of DMUnLoader into the environment. Without it SwiftUI stops
the app when the view appears.
import DMUnLoader
import SwiftUI
@main
struct ExampleApp: App {
@UIApplicationDelegateAdaptor private var delegate: DMAppDelegateType
var body: some Scene {
WindowGroup {
DMRootLoadingView { loadingManager in
ContentView(loadingManager: loadingManager)
}
}
}
}
struct ContentView: View {
let loadingManager: DMLoadingManagerMain
private let provider = DefaultDMLoadingViewProvider()
var body: some View {
Button("Show success") {
loadingManager.showSuccess("Data loaded", provider: provider)
}
}
}The UIKit integration installs DMSceneDelegateTypeUIKit as the scene delegate.
DMSceneDelegateHelper supplies the root view controller and receives the loading manager. The
app declares a scene manifest, UIApplicationSceneManifest in its Info.plist: without one, UIKit
does not ask the app delegate for a scene configuration.
import UIKit
import DMUnLoader
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = DMSceneDelegateTypeUIKit<AppDelegateHelper>.self
return configuration
}
}
@MainActor
struct AppDelegateHelper {}
extension AppDelegateHelper: @MainActor DMSceneDelegateHelper {
static func makeUIKitRootViewHierarhy<LM: DMLoadingManager>(
loadingManager: LM
) -> UIViewController {
LoadingViewController(loadingManager: loadingManager)
}
}
final class LoadingViewController<LM: DMLoadingManager>: UIViewController {
private let loadingManager: LM
private let provider = DefaultDMLoadingViewProvider()
init(loadingManager: LM) {
self.loadingManager = loadingManager
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
return nil
}
override func loadView() {
let button = UIButton(type: .system)
button.setTitle("Show success", for: .normal)
button.addTarget(self, action: #selector(showSuccess), for: .touchUpInside)
view = button
}
@objc
private func showSuccess() {
loadingManager.showSuccess("Data successfully loaded!", provider: provider)
}
}The requirement keeps its released name, makeUIKitRootViewHierarhy, until 2.0.0. DMUnLoader
calls it on the main actor, so make the helper a @MainActor type that conforms with
@MainActor DMSceneDelegateHelper, as above: in Swift 6 mode a plain conformance stops with
"sending 'loadingManager' risks causing data races". The scene delegate creates its loading
manager with init(), so with DMLoadingManagerMain it has the default settings.
showLoading(provider:)shows the spinner until the next state.showSuccess(_:provider:)shows a message: anyDMLoadableTypeSuccess, aStringincluded.showFailure(_:provider:onRetry:)shows a failure. The library's error view shows the title of the error settings, the error'slocalizedDescription, Close, and Retry whenonRetryis given.showFailure(_:provider:)shows a failure without Retry.hide()removes the HUD.
Retry runs your action and leaves the HUD as it is, so the action shows the next state itself. By default a failure with Retry also hides by itself after 2 seconds and on any tap; The loading manager's settings shows how to keep it until the user acts.
import DMUnLoader
import Foundation
@MainActor
final class DownloadModel {
private let loadingManager: DMLoadingManagerMain
private let provider = DefaultDMLoadingViewProvider()
init(loadingManager: DMLoadingManagerMain) {
self.loadingManager = loadingManager
}
func downloadFailed(with error: any Error) {
let retry = DMButtonAction { [weak self] in
self?.download()
}
loadingManager.showFailure(error, provider: provider, onRetry: retry)
}
func download() {
loadingManager.showLoading(provider: provider)
// Start the work again here.
}
}The provider of a state decides its views: DefaultDMLoadingViewProvider draws the library's
views, and Custom views shows a provider of your own.
DMLoadingManagerDefaultSettings holds the settings of a DMLoadingManagerMain. Each one
defaults to the behaviour of every release before 1.1.0:
autoHideDelay: how long a success or a failure stays. 2 seconds.hudDismissal: how each kind leaves the screen, by itself or by a tap on its card or outside it, for a success, a failure without Retry and a failure with Retry. By default each hides afterautoHideDelayand on any tap. The loading HUD stays until the next state, and taps do nothing.hudWindowLevel: the level of the HUD window..normal.backdrop: what the HUD draws behind its card..variableBlur; see Backdrop.onBackdropFailure: told when the variable blur of the backdrop could not be drawn. None; see Backdrop.
import DMUnLoader
@MainActor
func makeLoadingManager() -> DMLoadingManagerMain {
DMLoadingManagerMain(
state: .none,
settings: DMLoadingManagerDefaultSettings(
autoHideDelay: .seconds(4),
hudDismissal: DMHUDDismissalRules(
failureWithRetry: DMHUDDismissal(autoHide: .never, cardTapHides: false)
),
backdrop: .dim()
)
)
}Here a failure with Retry waits for the user: it leaves through Close, a tap outside its card,
or another state. Give such a manager to DMRootLoadingView(manager:content:). The settings of
a view provider do not change the manager: its loadingManagerSettings is not read. The README of
1.0.x set the delay there, which never had an effect.
While a HUD is shown, DMUnLoader draws a backdrop behind its card. The default, .variableBlur,
is the backdrop of every release so far: the variable blur of
DMVariableBlurView under a black dim.
That blur runs a private filter of the system, variableBlur of the CAFilter class, which the
README of DMVariableBlurView names; read that README before you ship it. Where the system does not
offer that filter, DMVariableBlurView draws the plain blur of the system over the whole screen
instead and logs the reason under its own subsystem, unless you set a handler, see below. .dim(),
.material() and .clear draw with public API only. Under the system's Reduce Transparency the HUD
draws neither the blur nor a material: .variableBlur keeps its dim, .material() gives way to that
dim, and the card of the HUD is opaque.
Choosing another backdrop only stops DMUnLoader from creating the variable blur while your app runs. DMUnLoader still depends on DMVariableBlurView, so its code, with the private names a scan of your binary finds, stays in your app. No setting of DMUnLoader 1.1.0 removes it.
To learn that the blur could not be drawn, give the settings a handler: the argument
onBackdropFailure of DMLoadingManagerDefaultSettings, or the property of that name of settings
of your own.
import DMUnLoader
import OSLog
@MainActor
func makeLoadingManager() -> DMLoadingManagerMain {
DMLoadingManagerMain(
state: .none,
settings: DMLoadingManagerDefaultSettings(
onBackdropFailure: { failure in
Logger(subsystem: "com.example.app", category: "hud")
.error("The backdrop of the HUD lost its blur: \(String(describing: failure), privacy: .public)")
}
)
)
}The handler runs on the main actor, with a DMHUDBackdropFailure: effectUnavailable or
maskCreationFailed; invalidConfiguration stands for a value of the blur that was not valid, which
the HUD, with its own values, does not cause. It is told for each failure that DMVariableBlurView
reports. The HUD makes a new blur view each time it appears, so a device that cannot draw the blur
reports once for each appearance of a HUD, and DMVariableBlurView can report again when the system
rebuilds the effect, for example on a change between light and dark appearance. The handler is read at
each render of the HUD, and one that you set later is used for the failures that follow: a failure that
happened while the settings had none is not reported afterwards, it went to the log. The handler
replaces the line that DMVariableBlurView writes to the log, so write one yourself if you want it. The
HUD does not change because of a failure: its state, its buttons and the way it leaves the screen stay
as they are. The handler is not called for another backdrop, nor under Reduce Transparency, where no
blur is drawn. Settings that do not set one behave as before.
The settings live as long as the loading manager, and so does the handler. A handler that holds the manager, or an object that holds it, strongly makes a reference cycle: capture such an object weakly.
Conform a class to DMLoadingViewProvider to replace the views of the states. Every requirement
has a default, the library's view, so implement only what you change:
import DMUnLoader
import SwiftUI
final class BrandedViewProvider: DMLoadingViewProvider {
@MainActor
func getLoadingView() -> some View {
ProgressView("Please wait")
.padding()
.background(Color.white)
}
@MainActor
func getErrorView(error: any Error, onRetry: (any DMAction)?, onClose: any DMAction) -> some View {
VStack {
Text(error.localizedDescription)
if let onRetry {
Button("Try again", action: onRetry.simpleAction)
}
Button("Close", action: onClose.simpleAction)
}
.padding()
.background(Color.white)
}
}A provider can keep the library's views and change their settings: texts, colours, images and layout. This one only changes the colour of the success image:
import DMUnLoader
import SwiftUI
final class MintSuccessProvider: DMLoadingViewProvider {
var successViewSettings: any DMSuccessViewSettings {
DMSuccessDefaultViewSettings(
successImageProperties: SuccessImageProperties(foregroundColor: .mint)
)
}
}loadingViewSettings, errorViewSettings and successViewSettings take
DMProgressViewDefaultSettings, DMErrorDefaultViewSettings and DMSuccessDefaultViewSettings,
or a type of your own that conforms to their protocol.
The default texts of the HUD, the failure title "An error has occurred!", "Close", "Retry" and "Loading...", come from the string catalog of DMUnLoader and follow the language of your app. In 1.1.0 the catalog holds English only, so every language shows these English texts. A text you set in the settings is shown as you wrote it. A text equal to an English default counts as that default, also when you pass it yourself, and follows the catalog.
While a HUD is shown, its window takes every touch that reaches it: the windows of your app under it receive none, whatever the backdrop. A tap on the card or outside it hides a success or a failure as the dismissal rules say. Close hides a failure, and Retry runs your action. With no HUD shown, every touch reaches your app.
The loading managers, the view providers and the root view work on the main actor: call them
there. Keep the manager alive: in a @StateObject of your app, or the one that the scene delegate
creates. The HUD keeps no replaced manager alive. Use one integration per scene: a scene whose
scene delegate of DMUnLoader holds a manager and that also shows
DMRootLoadingView(manager:content:) gets two HUD windows.
DMRootLoadingView(content:) without the app delegate of DMUnLoader stops the app when the view
appears. DMRootLoadingView(manager:content:) needs no app delegate: until the view is in a
window of a scene, the manager waits, and the HUD appears once the view is in one.
While a HUD is shown, DMUnLoader hides from assistive technology, and so from your own UI tests, the windows of its scene at the HUD window's level or below that are open when the HUD appears, until the HUD goes. When the HUD appears or shows another state, VoiceOver is told that the screen changed and starts at its first element: the HUD's, unless a window above the HUD has elements. When the HUD goes, VoiceOver returns to the element it was on, if that element is still on the screen. The escape gesture hides a success or a failure as a tap outside the card does. The loading HUD is one accessibility element named by its text, or by the default loading text ("Loading..." in English) when the text is empty or only whitespace. Reduce Motion keeps the card and a pressed button still. VoiceOver does not read the default images; an image you supply keeps the accessibility you give it.
A success or a failure hides after 2 seconds by default, which can be too short to reach Retry
with VoiceOver or Switch Control. An app whose users need more time keeps a failure on the screen
with DMHUDDismissal(autoHide: .never), or gives it a longer delay; see
The loading manager's settings.
The default look of the HUD, white text on a gray card, does not reach a contrast of 4.5:1, and its
loading view cuts a long text at large accessibility text sizes. It stays the default so that no
screen of your app changes with an update. DMHUDAppearance.accessible is an opt-in look of the
library's own views: pass it to the provider that you show a state with.
import DMUnLoader
@MainActor
func showAccessibleLoading(on manager: some DMLoadingManager) {
manager.showLoading(provider: DefaultDMLoadingViewProvider(appearance: .accessible))
}It guarantees, and the tests of the package measure:
- every text at a contrast of 4.5:1 or more against its card, in light and in dark mode;
- the colour of every icon, progress indicator and button outline at 3:1 or more against the card, and that of the failure sign and the success mark at 4.5:1;
- an opaque card in the secondary background colour of the system, which follows light and dark mode;
- a loading text that wraps at the width of the screen and shows in full at every text size, and a card taller than the screen that scrolls;
- Close and Retry one above the other at accessibility text sizes.
DMHUDAppearance.standard, the look of every release so far, is what every provider gives unless it
says otherwise. A provider of your own opts in by returning .accessible from hudAppearance. If it
leaves the views to the library, they take the accessible colours too; views of your own get the
accessible card and keep the colours they have, so keeping them legible on it is yours to do. A card
that grows past the screen, or shrinks back, switches between a plain card and a scrolling one,
which builds the views inside it again.
Examples/DMUnLoaderExample is an app that shows each state. Open
DMUnLoaderExample.xcodeproj and run one of its schemes: DMUnLoaderExample (SwiftUI with the
app delegate), DMUnLoaderExample-Injected (SwiftUI with a manager the app owns),
DMUnLoaderExample-UIKit, or DMUnLoaderExample-CustomManager (a loading manager written by the
host). The example of 1.0.x, which resolves the package through both CocoaPods and Swift Package
Manager as described in
Using Swift Package Manager and CocoaPods with the same SDK,
remains at the tag 1.0.3.
How the package is tested:
- Package tests: the state and the policies of the HUD, the managers and their timers, the adapters with hand-written spies, the views, and snapshots of the views.
- Tests hosted in the example app: the HUD windows of real scenes, two scenes on an iPad included, the touches they take and let through, and what the HUD draws under Reduce Transparency.
- UI tests: Retry, Close and taps on and outside the card, the touches that reach the app with and without a HUD in the four integrations, the texts in other languages and right to left, the failure HUD over content compared with reference pictures, and accessibility audits of the HUD: the audit of the standard look leaves out contrast and, for the loading HUD, clipped text and the readability of its "Loading..." label; the audit of the accessible look runs every audit type, at the default text size and at the largest one, and leaves out only that label.
- CI also lints, compares the public interface with a committed baseline, builds a consumer package and every Swift block of this README and of the documentation catalog, builds the documentation, lints the podspec, runs the tests under the Thread Sanitizer, and fails below a coverage floor.
DMUnLoader follows semantic versioning. CHANGELOG.md records every release. From the release after 1.1.0 on, every breaking change, as ENGINEERING.md defines it, needs a major version.
Coming from 1.0.x: no declaration was removed, and the new settings default to the released behaviour. Building needs Xcode 26, the Swift 6.2 compiler that the sources already needed. Some behaviour changed, and the changelog lists each change. The ones most likely to matter:
- Retry, Close and a tap outside the card work on iOS 18 and 26, where the buttons of the failure HUD did nothing;
- settings and states compare what they show, and a provider and a manager are equal only to themselves;
- a loading text or a success message of more than one line is centred by default;
- the default failure title reads "An error has occurred!";
- the HUD hides the content under it from assistive technology, as described above.
The default card of the HUD, white text on gray at opacity 0.8, does not reach the contrast of
4.5:1; an app that needs it passes DMHUDAppearance.accessible to its provider, see
The accessible appearance, or sets its own colours through the
settings types, or supplies its own views through DMLoadingViewProvider.
At large accessibility text sizes the default loading view, at most 150 points wide, cuts its
text; an app that needs those sizes passes DMHUDAppearance.accessible as well, or supplies its
own loading view. Changing the default look is a decision for the next major version.
A success or a failure hides after 2 seconds by default. With VoiceOver or Switch Control that can be too short to reach Retry; the Accessibility section names the settings that give more time.
DMUnLoader is one of three packages that share their conventions:
- DMAction: composes completion-based actions with retries and fallbacks. The Retry button of DMUnLoader runs one.
- DMVariableBlurView: a blur whose radius changes from row to row. DMUnLoader draws it behind its HUD.
- CONTRIBUTING.md: how to build, test and propose a change.
- ENGINEERING.md: the compatibility and release policy, and what the package is made of.
- SECURITY.md: how to report a vulnerability. Not in a public issue.
- DMUnLoader is available under the MIT License. See LICENSE.
- The HUD window was inspired by Custom HUDs in SwiftUI and How to layer multiple windows in SwiftUI.
