知阅百微 见微知著

Hide SwiftUI Views from Screenshot

The requirement is simple: keep a view visible on screen, but hide that specific view when the user takes a screenshot, records the screen, mirrors the device, or uses another system capture path.

This is different from showing a placeholder after capture has already started, and it is also different from hiding the entire window. The interesting part is per-view capture exclusion.

What Public APIs Give Us

The public APIs are mostly about observing capture state, not marking one arbitrary view as excluded from capture.

In SwiftUI, @Environment(\.isSceneCaptured) tells a view whether its scene is currently being captured. In UIKit, UITraitCollection.sceneCaptureState gives the same kind of signal through traits. These APIs are useful when you want to actively replace sensitive content while screen sharing, mirroring, recording, or remote control is active. Apple's documentation for protecting sensitive content when screen sharing and remote control are active follows that model.

The UIApplication.userDidTakeScreenshotNotification notification is even later in the timeline. It fires after the screenshot has already been taken, so it is useful for logging, analytics, or user messaging, but not for hiding pixels before they enter the screenshot.

So the public route is:

struct AccountView: View {
    @Environment(\.isSceneCaptured) private var isSceneCaptured

    var body: some View {
        if isSceneCaptured {
            Text("Hidden")
        } else {
            Text("Account Number: 1234 5678")
        }
    }
}

That is a good API for state-based replacement. It is not a per-view "do not include this layer in capture output" flag.

The Traditional Secure Text Field Trick

The common workaround is to wrap content inside a secure UITextField. Secure text fields are backed by a secure rendering subtree. If custom content is moved into that subtree, it stays visible on the device but is omitted from system capture output.

For UIKit, this can be done by placing protected content inside the secure text field's internal secure canvas. SwiftUI can use the same idea by introducing a UIKit bridge and hosting the SwiftUI content inside that secure subtree. A representative implementation is described in Prevent screenshot capture of sensitive SwiftUI views: create a UIViewRepresentable, build a secure UITextField, grab its internal secure subview, then add a UIHostingController view into that hierarchy.

That approach works, but the cost is real:

  1. It introduces an extra UIKit hosting boundary.
  2. It depends on the private structure of UITextField.
  3. It adds layout and sizing complexity around the hosted SwiftUI view.
  4. It is conceptually indirect: we are not protecting the SwiftUI view itself, we are moving it into a secure UIKit container.

The CALayer Mechanism Underneath

The secure text field path ultimately maps to Core Animation behavior. The useful low-level observation is documented by Nathan Antoine in Concealing your Views from Screenshots & Screen-Recordings: CALayer has a private disableUpdateMask property, and setting the relevant flags to (1 << 1) | (1 << 4) hides that layer from capture.

That value is 0x12.

The rough UIKit form is:

extension CALayer {
    func hiddenFromCapture(_ hidden: Bool = true) {
        let key = "disableUpdateMask"
        setValue(hidden ? 0x12 : 0, forKey: key)
    }
}

My Kyle-Ye/ScreenShieldKit package previously wrapped this path for UIView and CALayer, so UIKit code can use a direct API instead of manually building a secure text field wrapper. It wraps the private API and avoids using "disableUpdateMask" directly in app code.

import ScreenShieldKit

let view = UIView(frame: .zero)
view.hiddenFromCapture(true)

view.hiddenFromCapture(false)

The new question, and the focus of this post, is how to do the same thing in SwiftUI without wrapping the view in an extra UITextField.

The SwiftUI Problem

In SwiftUI, the UIView and CALayer objects are framework-owned. A View value is not the platform view. It is input to SwiftUI's graph and rendering pipeline. For ordinary SwiftUI code, there is no stable per-view layer handle where we can simply write:

view.layer.disableUpdateMask = 0x12

So the better question is: does SwiftUI already have a rendering property that eventually sets this layer flag?

Looking at OpenSwiftUI's implementation gives the answer. In the display list platform updater, SwiftUI-style rendering properties are applied to the underlying view layer:

private func updateProperties(
    _ viewInfo: inout DisplayList.ViewUpdater.ViewInfo,
    state: UnsafePointer<DisplayList.ViewUpdater.Model.State>
) {
    let properties = state.pointee.properties
    definition.setIgnoresEvents(
        properties.contains(.ignoresEvents),
        of: viewInfo.view
    )
    viewLayer(viewInfo.view).disableUpdateMask =
        properties.contains(.screencaptureProhibited) ? 0x12 : 0
}

This is exactly the bridge we want. If a SwiftUI view can produce DisplayList.Properties.screencaptureProhibited, the platform updater will set disableUpdateMask = 0x12 for the managed layer.

The remaining problem is how to produce that display list property from public SwiftUI code.

The Missing RendererEffect API

Inside SwiftUI, display list properties can be produced by a RendererEffect:

struct PrivacyEffect: RendererEffect {
    var sensitive: Bool
    var shouldRedact: Bool
    var hideForScreencapture: Bool

    func effectValue(size: CGSize) -> DisplayList.Effect {
        var properties: DisplayList.Properties = []
        if sensitive {
            properties.formUnion(.privacySensitive)
        }
        if hideForScreencapture {
            properties.formUnion(.screencaptureProhibited)
        }
        return .properties(properties)
    }
}

But RendererEffect is not a public SwiftUI API. We cannot just write our own public modifier that returns .properties(.screencaptureProhibited).

Fortunately, SwiftUI already exposes one public modifier that installs the relevant private renderer effect: privacySensitive(_:).

At first glance, this looks useless:

Text("Sensitive")
    .privacySensitive(false)

Passing false sounds equivalent to doing nothing. But structurally it is not a no-op. The public API still installs PrivacyRedactionViewModifier; it only passes sensitive: false into that modifier.

The relevant shape is:

extension View {
    public func privacySensitive(_ sensitive: Bool = true) -> some View {
        modifier(PrivacyRedactionViewModifier(sensitive: sensitive))
    }
}

That modifier creates a Transform rule with both sensitive and the current redactionReasons:

struct Transform<Provider>: ViewModifier where Provider: PrivacyReductionAccessibilityProvider {
    var sensitive: Bool
    var redactionReasons: RedactionReasons

    @inline(__always)
    private var shouldRedact: Bool {
        redactionReasons.contains(.privacy) && sensitive
    }

    func body(content: Content) -> some View {
        content
            .unredacted()
            .modifier(
                PrivacyEffect(
                    sensitive: sensitive,
                    shouldRedact: shouldRedact,
                    hideForScreencapture: redactionReasons.contains(.screencaptureProhibited)
                )
            )
            .opacity(shouldRedact ? 0 : 1)
            .modifier(Provider.makeModifier(shouldRedact: shouldRedact))
            .overlay {
                if shouldRedact {
                    content
                        .environment(\.redactionReasons, .privacy)
                        .environment(\.sensitiveContent, sensitive)
                        .transition(.opacity)
                }
            }
    }
}

The capture flag is driven by the private redaction reason that flows into hideForScreencapture, while sensitive only controls the normal privacy-redaction behavior. That means we can keep sensitive == false to avoid normal privacy redaction, while still turning on the screen capture property through a private redaction reason.

The Private Redaction Reason

SwiftUI has a private redaction reason for this:

public struct RedactionReasons: OptionSet, Sendable {
    @_spi(Private)
    @available(SwiftUI_v6_0, *)
    public static let screencaptureProhibited: RedactionReasons = .init(rawValue: 1 << 3)
}

Since it is SPI, normal app code cannot reference it from the public SwiftUI module. There are two practical options:

  1. Build a local SwiftUI private SPI interface and import that symbol.
  2. Define the same option set value yourself with .init(rawValue: 1 << 3).

The Kyle-Ye/ScreenShieldKit package uses the second route by default for the public wrapper:

import SwiftUI

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
extension RedactionReasons {
    @_spi(Private)
    public static let screencaptureProhibited: RedactionReasons = .init(rawValue: 1 << 3)
}

Starting with 0.2.1, ScreenShieldKit also supports the first route. If you want the package to reference this symbol through a local SwiftUI private SPI interface, set SCREENSHIELDKIT_USE_SPI_INTERFACES at build time; otherwise it keeps using the raw-value wrapper above.

Then the modifier only needs to install the privacy renderer effect and inject that redaction reason into the environment:

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
private struct ScreenCaptureRedactionModifier: ViewModifier {
    var hidden: Bool

    func body(content: Content) -> some View {
        if hidden {
            content
                .privacySensitive(false)
                .transformEnvironment(\.redactionReasons) { reasons in
                    reasons.insert(.screencaptureProhibited)
                }
        } else {
            content
                .transformEnvironment(\.redactionReasons) { reasons in
                    reasons.remove(.screencaptureProhibited)
                }
        }
    }
}

Finally, expose it as a normal SwiftUI modifier:

@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
extension View {
    public func hiddenFromCapture(_ hidden: Bool = true) -> some View {
        modifier(ScreenCaptureRedactionModifier(hidden: hidden))
    }
}

Usage becomes the SwiftUI API we originally wanted:

import ScreenShieldKit
import SwiftUI

Text("Account Number: 1234 5678")
    .hiddenFromCapture()

Version 0.2.1 of Kyle-Ye/ScreenShieldKit provides this SwiftUI API directly, so you do not need to copy the implementation into each app.

Why the Order Matters

The working shape is:

content
    .privacySensitive(false)
    .transformEnvironment(\.redactionReasons) { reasons in
        reasons.insert(.screencaptureProhibited)
    }

The environment transform must be written after .privacySensitive(false). In SwiftUI modifier order, that makes the environment transform wrap the PrivacyRedactionViewModifier, so the privacy modifier sees .screencaptureProhibited when it builds its renderer effect. If the order is reversed, the privacy modifier has already read the old redaction reasons, and no DisplayList.Properties.screencaptureProhibited property will be produced.

This is also why the behavior is worth hiding behind one dedicated modifier. The API call should express the intent, not make every call site remember SwiftUI modifier ordering details.

Caveats

This is not a public SwiftUI feature. It relies on a private RedactionReasons bit and on SwiftUI continuing to map DisplayList.Properties.screencaptureProhibited to the platform layer capture mask. It is useful as a focused privacy affordance, but it should be treated as SPI-dependent.

It is also not a security boundary. It can hide content from system screenshot and capture pipelines, but it cannot prevent someone from photographing the device with another camera, and it should not replace server-side authorization or data access control.

Still, the path is much cleaner than wrapping SwiftUI content in a secure text field: SwiftUI keeps managing its own view and layer hierarchy, and we only feed the renderer the capture-prohibition property it already knows how to consume.