How screenshot detection works on iOS
Screenshot detection on iOS starts with one UIKit notification:
UIApplication.userDidTakeScreenshotNotification. Receiving that notification is the easy part.
Turning it into a dependable bug-reporting flow means handling what the notification does not
provide: the screenshot image, a view controller from which to present UI, or a guarantee that the
new Photos asset is already available.
BugScreen handles those concerns as separate stages. This post walks through the Swift implementation in the iOS SDK: observer lifecycle, foreground and UI guards, the immediate window snapshot, optional Photos access, and the query that upgrades the fallback to the OS screenshot.
The pipeline
The implementation has three distinct steps:
- Observe UIKit's screenshot notification.
- Capture the active key window and present the reporter with that image as a fallback.
- If Photos access is available, find the recent screenshot asset and replace the fallback.
That separation is important. The notification tells the SDK that a capture happened. It does not
give the SDK the resulting UIImage.
Registering for the screenshot event
When screenshot detection is enabled, SDK configuration starts a shared observer:
if enableScreenshotDetection {
ScreenshotObserver.shared.startObserving()
}
The observer registers with NotificationCenter:
func startObserving() {
guard !isObserving else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(handleScreenshotNotification),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
isObserving = true
}
startObserving() and stopObserving() both guard their current state. Reconfiguring the SDK first
tears down the old configuration, and shutdown stops the observer. The unit tests exercise repeated
starts, repeated stops, stopping before a start, and restarting after a stop.
The notification handler does not perform presentation work inline. It dispatches to the main
queue, and the rest of the path is isolated to MainActor because it reads application state and
touches UIKit:
@objc private func handleScreenshotNotification(_: Notification) {
DispatchQueue.main.async { [weak self] in
self?.presentBugReportIfNeeded()
}
}
Deciding whether the reporter can open
A screenshot event is not automatically a safe moment to present a view controller. Before doing
anything visible, ScreenshotObserver checks that:
- the SDK is configured;
- screenshot detection remains enabled;
- the application is active;
- a foreground-active scene has a key window;
- that window has a root view controller; and
- a BugScreen reporter is not already presented.
The window lookup is scene-aware:
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
else {
return nil
}
return scene.windows.first(where: { $0.isKeyWindow })
From the root, the observer recursively follows presented view controllers, the visible controller in a navigation stack, and the selected controller in a tab bar. This gives the SDK a presenter that matches the UI the user was actually viewing.
The duplicate-presentation guard checks that top-most hierarchy for
BugReportHostingController. If a report screen is already open, another screenshot notification
does not stack a second one on top.
Capture a fallback before presenting anything
At notification time, the OS screenshot may not be available through Photos, and Photos access may never be granted. BugScreen therefore renders the key window immediately:
private func captureWindowSnapshot(_ window: UIWindow) -> UIImage? {
guard window.bounds.width > 0, window.bounds.height > 0 else { return nil }
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
return renderer.image { context in
window.layer.render(in: context.cgContext)
}
}
The code uses CALayer.render(in:) rather than drawHierarchy(afterScreenUpdates:). The
implementation comment records the reason: SwiftUI-hosted windows can render blank through
drawHierarchy because SwiftUI defers layer-tree commits, while rendering the current layer tree
works for both UIKit and SwiftUI windows.
The observer passes that image to the reporter and sets the internal autoAttach flag:
let fallbackImage = captureWindowSnapshot(keyWindow)
BugScreenSDK.presentBugReport(
from: topViewController,
screenshot: fallbackImage,
autoAttach: true
)
The fallback is captured before BugScreen presents its own UI, so the attachment represents the host app rather than the report form.
Photos permission is for the image, not detection
When the report view first appears, its view model runs the auto-attach flow once. It asks a dedicated coordinator for Photos access:
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .authorized, .limited:
completion(.authorized)
case .denied, .restricted:
completion(.denied)
case .notDetermined:
showRationale(on: presenter, completion: completion)
@unknown default:
completion(.denied)
}
For an undetermined status, BugScreen first shows its own rationale alert. Choosing “Continue”
calls PHPhotoLibrary.requestAuthorization(for: .readWrite); choosing “Not now” returns a
cancelled outcome. The authorization callback returns to the main queue before the reporter state
is updated.
Only .authorized and .limited continue to lookup. Denied, restricted, cancelled, and unknown
statuses simply leave the fallback in place. They do not block or dismiss the report.
Finding the screenshot that caused the event
The view model records autoAttachSince when it is created. After authorization, it passes that
timestamp to ScreenshotLocator, which searches for a recent Photos asset classified as a
screenshot:
let earliest = since.addingTimeInterval(-within)
let options = PHFetchOptions()
options.predicate = NSPredicate(
format: "(mediaSubtypes & %d) != 0 AND creationDate >= %@",
PHAssetMediaSubtype.photoScreenshot.rawValue,
earliest as NSDate
)
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.fetchLimit = 1
let result = PHAsset.fetchAssets(with: .image, options: options)
The default within value is five seconds, so the lower bound starts five seconds before the view
model's timestamp. The fetch filters on PHAssetMediaSubtype.photoScreenshot, orders by creation
date descending, and requests only the newest match. That combination avoids selecting an ordinary
photo or an unrelated old screenshot.
There can still be a race between the UIKit notification and the asset appearing in Photos. If the first lookup returns no image, the locator waits 750 milliseconds and tries once more:
attempt(since: since, within: within) { image in
if image != nil {
completion(image)
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
attempt(since: since, within: within, completion: completion)
}
}
This is a bounded retry: one immediate attempt and one delayed attempt.
Requesting the image without an iCloud surprise
After locating the asset, BugScreen asks PHImageManager for the full-size image:
let requestOptions = PHImageRequestOptions()
requestOptions.deliveryMode = .highQualityFormat
requestOptions.isNetworkAccessAllowed = false
requestOptions.isSynchronous = false
requestOptions.resizeMode = .none
PHImageManager.default().requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .default,
options: requestOptions
) { image, info in
// Inspect cancellation, errors, degraded results, and iCloud state.
}
Network access is disabled, so this lookup does not download an iCloud-only image. The result handler rejects cancellations and errors. It also rejects a degraded result when Photos reports that the asset is in iCloud, ignores other degraded callbacks, and finishes with the non-degraded image.
The locator protects its completion with a didComplete flag and dispatches the result to the
main queue. That matters because Photos may invoke its result handler more than once.
Replace the fallback instead of attaching the same screen twice
The view model marks an initial screenshot as a placeholder only when autoAttach is enabled. If
the Photos lookup succeeds, it upgrades that slot in place:
private func upgradePlaceholderOrAppend(with image: UIImage) {
if let index = placeholderIndex, screenshots.indices.contains(index) {
screenshots[index] = image
return
}
let countBefore = screenshots.count
addScreenshots([image])
if screenshots.count > countBefore {
placeholderIndex = screenshots.count - 1
}
}
If there was no fallback, the OS screenshot is appended. If the user removed the placeholder while the lookup was running, the image is appended subject to the four-attachment limit. Tests verify the replacement behavior, the no-fallback path, permission denial and cancellation, a locator that returns no image, and the rule that auto-attach runs only once per presentation.
What the implementation reduces to
The production path is less about observing a notification than about preserving useful behavior around it:
- Treat
userDidTakeScreenshotNotificationas an event, not an image payload. - Keep observer start and stop operations idempotent.
- Move UIKit work to the main actor and refuse presentation outside an active scene.
- Resolve the key window and top-most presenter instead of assuming a single window hierarchy.
- Prevent duplicate reporter presentation.
- Capture an immediate, permission-free window snapshot before opening SDK UI.
- Make access to the saved OS screenshot a separate, optional Photos flow.
- Filter by screenshot subtype and time, then retry once for the Photos write race.
- Replace the fallback rather than showing two versions of the same capture.
- Keep permission denial, an iCloud-only asset, and lookup failure as recoverable outcomes.
iOS provides a clean signal that a screenshot occurred. The rest of the implementation exists to turn that signal into a reliable attachment without making Photos access a prerequisite for reporting a bug.
Frequently asked questions
How does an iOS app know that the user took a screenshot?
UIKit posts UIApplication.userDidTakeScreenshotNotification. BugScreen observes that notification and, after checking the app and UI state, opens its bug report screen.
Does userDidTakeScreenshotNotification contain the screenshot image?
No. BugScreen treats the notification as an event, captures the app's key window as an immediate fallback, and separately looks in Photos for the recent OS screenshot when the user grants access.
Does detecting a screenshot on iOS require Photos permission?
Detection itself does not use Photos. Photos access is a separate, optional step used to replace BugScreen's window snapshot with the actual screenshot saved by iOS.
What happens if the user denies Photos access?
BugScreen keeps the window snapshot it captured before presenting the reporter. Denial, restriction, cancellation, or a failed screenshot lookup does not prevent the report UI from opening.