feat: SwiftUI support for screen shielding#3
Conversation
05986a9 to
8e84348
Compare
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.
8e84348 to
a29f1d6
Compare
|
LGTM 👍 👍 |
| @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) |
There was a problem hiding this comment.
| @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?
There was a problem hiding this comment.
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 UIViewControllerRepresentable → UIHostingController 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.
There was a problem hiding this comment.
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.
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)
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 earlierScreenShield { }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 aGroup/VStackand apply the modifier once.Verification
swift build(macOS) passes.Notes
@Environmentvalues 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.