How screenshot detection works on iOS
Detecting a screenshot on iOS starts with a single UIKit notification. That is the last easy part.
UIApplication.userDidTakeScreenshotNotification tells you a capture happened. It doesn’t hand you the screenshot image, a view controller to present from or any promise that the new photo is already sitting in the library. Turning that one signal into a dependable bug-reporting flow is mostly about handling everything the notification leaves out.
I recently had to build exactly that. Here’s what I learned.
There are really three steps
The notification is a starting gun, not a payload. Everything useful happens in the stages after it:
- 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 matters. The notification tells me a capture happened. It does not give me the resulting UIImage, so I never write code that assumes it does.
Registering for the screenshot event
When detection is enabled, 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 first tears down the old configuration, and shutdown stops the observer. I made both idempotent and wrote unit tests for repeated starts, repeated stops, stopping before a start and restarting after a stop.
The handler does no presentation work inline. It hops 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()
}
}
A screenshot event isn’t automatically a safe moment to present UI
Before putting anything on screen, I check that:
- the SDK is configured;
- screenshot detection is still enabled;
- the application is active;
- a foreground-active scene has a key window;
- that window has a root view controller; and
- a reporter isn’t already presented.
The window lookup is scene-aware, because assuming a single window hierarchy breaks the moment an app uses multiple scenes:
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, I recursively follow presented view controllers, the visible controller in a navigation stack and the selected controller in a tab bar. That gives me a presenter matching the UI the user was actually looking at.
The duplicate-presentation guard walks that same top-most hierarchy looking for the reporter’s hosting controller. If a report screen is already open, a second screenshot notification doesn’t stack another one on top.
Capture a fallback before presenting anything
At notification time, the OS screenshot may not be available through Photos yet, and Photos access may never be granted at all. So I render 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)
}
}
This uses CALayer.render(in:) rather than drawHierarchy(afterScreenUpdates:), and that choice cost me some debugging. SwiftUI-hosted windows can render blank through drawHierarchy because SwiftUI defers its layer-tree commits. Rendering the current layer tree works for both UIKit and SwiftUI windows.
I pass that image to the reporter and set an internal autoAttach flag:
let fallbackImage = captureWindowSnapshot(keyWindow)
BugScreenSDK.presentBugReport(
from: topViewController,
screenshot: fallbackImage,
autoAttach: true
)
The fallback is captured before the reporter presents its own UI, so the attachment shows the host app rather than the report form covering it.
Photos permission is for the image, not the detection
When the report view first appears, its view model runs the auto-attach flow once and 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 I show my own rationale alert first. “Continue” calls PHPhotoLibrary.requestAuthorization(for: .readWrite); “Not now” returns a cancelled outcome. The authorization callback returns to the main queue before I touch reporter state.
Only .authorized and .limited continue to the lookup. Denied, restricted, cancelled and unknown all simply leave the fallback in place. None of them block or dismiss the report. Detection never depended on Photos in the first place.
Finding the screenshot that caused the event
The view model records autoAttachSince when it’s created. After authorization, it passes that timestamp to a locator that 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 is five seconds, so the lower bound starts five seconds before the view model’s timestamp. Filtering on PHAssetMediaSubtype.photoScreenshot, ordering by creation date descending and requesting only the newest match keeps this from grabbing an ordinary photo or an unrelated old screenshot.
There’s still a race between the UIKit notification and the asset actually appearing in Photos. If the first lookup returns nothing, I wait 750 milliseconds and try 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)
}
}
One immediate attempt and one delayed attempt: a bounded retry, not a polling loop.
Requesting the image without an iCloud surprise
Once I have the asset, I ask 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 off, so this never quietly downloads an iCloud-only image. The handler rejects cancellations and errors, rejects a degraded result when Photos reports the asset lives in iCloud, ignores other degraded callbacks and finishes with the non-degraded image.
I guard the completion with a didComplete flag and dispatch the result to the main queue, because Photos can invoke its result handler more than once.
Replace the fallback instead of attaching the same screen twice
The view model marks the initial screenshot as a placeholder only when autoAttach is on. 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 deleted the placeholder while the lookup was running, the image is appended subject to the four-attachment limit. Tests cover the replacement, 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 survived contact with production code
The production path is less about observing a notification than about preserving useful behaviour around it:
- Treat
userDidTakeScreenshotNotificationas an event, not an image payload. - Keep observer start and stop idempotent.
- Move UIKit work to the main actor and refuse to present outside an active scene.
- Resolve the key window and top-most presenter instead of assuming one window hierarchy.
- Prevent duplicate reporter presentation.
- Capture an immediate, permission-free window snapshot before opening any 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 gives you a clean signal that a screenshot occurred. Everything else exists to turn that signal into a reliable attachment without making Photos access a prerequisite for reporting a bug.
I built this as the screenshot trigger in BugScreen, a bug-reporting SDK I’m building. It opens a report with the screenshot and relevant device context already attached, which is a much nicer payoff than all the notification plumbing suggests.