Skip to content

feat: SwiftUI support for screen shielding#3

Merged
OhKanghoon merged 5 commits into
mainfrom
feat/swiftui-support
Jul 14, 2026
Merged

feat: SwiftUI support for screen shielding#3
OhKanghoon merged 5 commits into
mainfrom
feat/swiftui-support

Conversation

@OhKanghoon

Copy link
Copy Markdown
Member

Summary

Adds SwiftUI support to ScreenShieldKit: a single .screenShield(_:) view modifier that hides content from screenshots, screen recordings, and other system captures while it stays visible on device — alongside the existing UIKit / AppKit / CALayer surfaces.

What's included

  • feat — Core library: the .screenShield(_:) modifier and its two internal paths (native redaction reason + legacy hosting bridge).
  • feat(sample) — Sample app: a SwiftUI demo tab with state-driven on/off protection, exposed via a UIKit/SwiftUI tab bar, plus an expanded UIKit demo. Deployment target lowered to iOS 16.0 so both SwiftUI paths can be exercised.
  • docs — README expanded with SwiftUI usage, dynamic on/off, and the per-OS mechanism table.
  • chore — File header author cleanup (unrelated to the feature; separated for a clean revert boundary).

Mechanism (selected at runtime by OS version)

Path OS Mechanism
SwiftUI (native) iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+ Activates SwiftUI's native capture-redaction reason in the environment. Transparent to layout; no hosting boundary.
SwiftUI (legacy) iOS 16–17 / macOS 13–14 / tvOS 16–17 / visionOS 1 Bridges content through a hosting controller and applies the Core Animation capture flag to its backing layer.

Toggling protection only flips the underlying flag, so it never changes the view's identity or resets the wrapped content's @State, scroll position, focus, or in-flight animations.

API design note

The public SwiftUI API is the .screenShield(_:) modifier only. An earlier ScreenShield { } container wrapper was dropped before release: it was pure syntactic sugar with zero behavioral difference, and a modifier-only surface matches SwiftUI's own redaction-family conventions (.redacted, .privacySensitive, .blur). Wrap multiple views in a Group/VStack and apply the modifier once.

Verification

  • swift build (macOS) passes.
  • Simulator render confirmed (on-device visibility OK).
  • ⚠️ Not yet verified on a physical device: actual capture hiding relies on private platform behavior and must be checked per OS version (screenshots, screen recording, AirPlay mirroring). Simulator screenshots read the framebuffer directly and do not exercise the hiding path.

Notes

  • Relies on private platform behavior; when unavailable on a device the call is a safe no-op (never crashes).
  • The legacy bridge re-hosts content, so some ancestor @Environment values aren't propagated automatically; the most common ones (color scheme, layout direction, Dynamic Type size) are forwarded. Replacing the bridge's re-hosting with an introspection-based approach (apply the flag to the existing host layer, no re-host) is a candidate follow-up that would remove this limitation entirely.

@OhKanghoon OhKanghoon self-assigned this Jul 13, 2026
@OhKanghoon
OhKanghoon force-pushed the feat/swiftui-support branch from 05986a9 to 8e84348 Compare July 13, 2026 12:44
Add a `.screenShield(_:)` view modifier that hides content from screenshots
and screen recordings while it stays visible on device. The mechanism is
selected at runtime by OS version:

- iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+: activates SwiftUI's
  native capture-redaction reason in the environment (transparent to layout).
- iOS 16-17 / macOS 13-14 / tvOS 16-17 / visionOS 1: bridges the content
  through a hosting controller and applies the Core Animation capture flag to
  its backing layer.

Toggling protection only flips the underlying flag, so it never changes the
view's identity or resets the wrapped content's state.
Add a SwiftUI demo screen showcasing the `.screenShield(_:)` modifier with
state-driven on/off protection, surface it through a UIKit/SwiftUI tab bar,
and expand the existing UIKit demo. Lower the sample's deployment target to
iOS 16.0 so both the legacy and native SwiftUI paths can be exercised.
Document the `.screenShield(_:)` modifier, dynamic on/off protection, and the
per-OS mechanism table, alongside the existing UIKit/AppKit/CALayer usage.
Update the author line in existing file headers.
@OhKanghoon
OhKanghoon force-pushed the feat/swiftui-support branch from 8e84348 to a29f1d6 Compare July 13, 2026 12:58
@cheonsong

Copy link
Copy Markdown

LGTM 👍 👍

Comment on lines +33 to +47
@Environment(\.colorScheme) private var colorScheme
@Environment(\.layoutDirection) private var layoutDirection
@Environment(\.dynamicTypeSize) private var dynamicTypeSize

init(isProtected: Bool, content: Content) {
self.isProtected = isProtected
self.content = content
}

var body: some View {
ScreenShieldHostingController(isProtected: isProtected) {
content
.environment(\.colorScheme, colorScheme)
.environment(\.layoutDirection, layoutDirection)
.environment(\.dynamicTypeSize, dynamicTypeSize)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Environment(\.colorScheme) private var colorScheme
@Environment(\.layoutDirection) private var layoutDirection
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
init(isProtected: Bool, content: Content) {
self.isProtected = isProtected
self.content = content
}
var body: some View {
ScreenShieldHostingController(isProtected: isProtected) {
content
.environment(\.colorScheme, colorScheme)
.environment(\.layoutDirection, layoutDirection)
.environment(\.dynamicTypeSize, dynamicTypeSize)
@Environment(\.self) private var environmentValues
init(isProtected: Bool, content: Content) {
self.isProtected = isProtected
self.content = content
}
var body: some View {
ScreenShieldHostingController(isProtected: isProtected) {
content
.environment(\.self, environmentValues)

Not sure if this would actually work, but did you give it a try?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call — this is what sent me down the rabbit hole, so thanks 🙏 I did try it, along with some research and an on-device check.

Does .environment(\.self, …) work? Yes, and it's strictly better than the 3-key forward for value environment — it carries the whole EnvironmentValues, including custom EnvironmentKeys. Two caveats though: it does not carry @EnvironmentObject objects (they live in a separate, type-keyed store that EnvironmentValues doesn't expose), and depending on \.self makes the wrapper invalidate on any environment change.

But it turned out to be moot. While testing I found SwiftUI already propagates the ancestor environment across this representable-managed hosting controller with no forwarding at all. I built a probe with the same UIViewControllerRepresentableUIHostingController shape as the bridge and measured on an iOS 16.4 simulator:

what forwarded manually? crossed the boundary?
system value + override (dynamicTypeSize) no
custom EnvironmentKey no
@EnvironmentObject no
runtime change (@State flipping every 0.7s) no ✅ live

So the 3-key forward was redundant and incomplete, and even .environment(\.self, …) wouldn't have closed the @EnvironmentObject gap — auto-propagation does. I removed the forwarding entirely and corrected the docs that claimed the ancestor environment isn't propagated, in b0b7d03.

One caveat: I could only test iOS 16.4 (no iOS 17 simulator on hand), so I'll re-verify on iOS 17 on a device before release.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice find! Thanks for verifying this thoroughly. Looks good to me. 👍

SwiftUI already propagates the ancestor environment across the
representable-managed hosting controller in ScreenShieldBridge: @Environment
values (including custom EnvironmentKeys) and @EnvironmentObject dependencies
all reach the hosted content, and runtime changes stay live. Verified on an
iOS 16.4 simulator with a probe (static value, override, custom key, object,
and dynamic update) and by running the real SwiftUIDemoView.

Remove the manual colorScheme/layoutDirection/dynamicTypeSize forwarding, which
was redundant and incomplete, and correct the docs that claimed the ancestor
environment is not propagated across the hosting boundary.
@OhKanghoon
OhKanghoon merged commit 57b6252 into main Jul 14, 2026
2 checks passed
@OhKanghoon
OhKanghoon deleted the feat/swiftui-support branch July 14, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants