- Published on
- Updated Nov 8, 2026
- Tested on Xcode 27.1
- 4 min read
> Detect and Block Screenshots in SwiftUI
iOS tells you when someone has taken a screenshot, and it has no public API for stopping one. The closest you can get is hosting sensitive content inside a secure text field, which blanks it in captures.
This post merges two older ones, and testing them turned up a real problem: the blocking technique I'd published didn't block anything. Everything below was built with Xcode 27.1 in Swift 6 mode and run on an iPhone 17 Pro simulator with iOS 27.0.
Detecting a Screenshot
iOS posts userDidTakeScreenshotNotification after the screenshot has been captured, so you can react to it but can't prevent it. A small view modifier keeps the call sites tidy:
import SwiftUI
import UIKit
struct ScreenshotDetector: ViewModifier {
let onScreenshot: () -> Void
func body(content: Content) -> some View {
content.onReceive(NotificationCenter.default.publisher(
for: UIApplication.userDidTakeScreenshotNotification
)) { _ in
onScreenshot()
}
}
}
extension View {
func onScreenshot(perform action: @escaping () -> Void) -> some View {
modifier(ScreenshotDetector(onScreenshot: action))
}
}
struct AccountView: View {
@State private var showWarning = false
var body: some View {
Text("Account Number: 1234-5678-9012")
.onScreenshot { showWarning = true }
.alert("Screenshot Detected", isPresented: $showWarning) {
Button("OK", role: .cancel) { }
} message: {
Text("Please keep this information private.")
}
}
}
This compiles and runs, but I couldn't fire the notification from a script, so I haven't watched it trigger. Check it on a device or from the Simulator's Device menu.
What Actually Hides Content
The original article wrapped content next to a hidden UITextField with isSecureTextEntry turned on, and claimed the whole view subtree would come out blank. It also said .privacySensitive() hides content from screenshots. I put all of those on one screen and captured it three different ways:

| Content | simctl io screenshot | XCUIScreen screenshot | drawHierarchy |
|---|---|---|---|
Plain Text | Visible | Visible | Visible |
Text beside a hidden secure field | Visible | Visible | Visible |
Text with .privacySensitive() | Visible | Visible | Visible |
Text inside the secure field's canvas | Visible | Blank | Blank |
SecureField contents | Dots | Blank | Blank |
The hidden field does nothing for the views beside it. Secure rendering belongs to one internal view of the text field, so your content has to live inside that view. privacySensitive marks a view for redaction, which is a different feature that has no effect on captures like these.
Notice the first column too. xcrun simctl io screenshot captured everything, including the secure content, so it's useless for testing this.
Hosting Content in the Secure Canvas
Here's the version that blanked in both of the other captures:
import SwiftUI
import UIKit
struct SecureContent<Content: View>: UIViewRepresentable {
@ViewBuilder var content: Content
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.isSecureTextEntry = true
field.isUserInteractionEnabled = false
// iOS applies secure rendering to this internal view. It's undocumented,
// so if it ever goes away, show nothing rather than exposing the content.
guard let canvas = field.subviews.first else { return field }
let host = UIHostingController(rootView: content)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
canvas.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.leadingAnchor.constraint(equalTo: field.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: field.trailingAnchor),
host.view.topAnchor.constraint(equalTo: field.topAnchor),
host.view.bottomAnchor.constraint(equalTo: field.bottomAnchor),
])
context.coordinator.host = host
return field
}
func updateUIView(_ field: UITextField, context: Context) {
context.coordinator.host?.rootView = content
}
final class Coordinator {
var host: UIHostingController<Content>?
}
}
struct BalanceView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Account Summary").font(.title)
SecureContent {
Text("$10,234.56").font(.largeTitle.bold())
}
.frame(height: 50)
}
.padding()
}
}
There are real trade-offs. field.subviews.first was a private class called _UITextLayoutCanvasView on iOS 27.0, and nothing stops Apple from changing it, which is why the guard fails closed. Give the view an explicit frame, because the text field won't size itself to your content. I also turned off user interaction so tapping the area can't bring up a keyboard, which means buttons inside it won't respond.
A simulator isn't a phone either. These are the capture paths I could run, so try a real device before you rely on this, and remember nothing here stops someone photographing the screen.
Further Reading
userDidTakeScreenshotNotificationandisSecureTextEntryin the UIKit documentationprivacySensitive(_:)in the SwiftUI documentation
// Continue_Learning
Accessing the Camera Roll in SwiftUI
Learn how to access photos from the camera roll in SwiftUI, including required permissions, Info.plist configuration, and using PhotosPicker for iOS 16+ or UIImagePickerController for earlier versions.
The New EU App Tracking Transparency Prompt in iOS 27.2
iOS 27.2 changes App Tracking Transparency in the European Union: a full-page prompt with Markdown text, an optional Additional Information button, and a yearly re-prompt. Here's who sees what and how to adopt the new API.
Replacing UIScreen.main Before iPhone Duo Arrives
UIScreen.main assumes your app lives on one screen with one size. iPhone Duo, iPhone Mirroring, and iPad windowing all break that assumption. Here's what to use instead in UIKit and SwiftUI.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.