BS
BleepingSwift
Published on
Updated Nov 8, 2026
Tested on Xcode 27.1
4 min read

> Detect and Block Screenshots in SwiftUI

Share:

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:

Swift
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:

XCUITest capture of the test screen, with the plain, hidden-field and privacySensitive rows visible and the canvas and SecureField rows blank
Contentsimctl io screenshotXCUIScreen screenshotdrawHierarchy
Plain TextVisibleVisibleVisible
Text beside a hidden secure fieldVisibleVisibleVisible
Text with .privacySensitive()VisibleVisibleVisible
Text inside the secure field's canvasVisibleBlankBlank
SecureField contentsDotsBlankBlank

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:

Swift
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

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.