Skip to content

Commit 57b6252

Browse files
authored
Merge pull request #3 from daangn/feat/swiftui-support
feat: SwiftUI support for screen shielding
2 parents 7ebc308 + b0b7d03 commit 57b6252

13 files changed

Lines changed: 625 additions & 57 deletions

README.md

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
11
# ScreenShieldKit
22

3-
A Swift package to protect sensitive content from screenshots and screen recording on iOS and macOS platforms.
3+
A Swift package that protects sensitive content from screenshots and screen recordings across Apple platforms — for UIKit, AppKit, Core Animation, and **SwiftUI**.
44

55
## Overview
66

7-
ScreenShieldKit provides an easy-to-use API to prevent content from being captured in screenshots or screen recordings. This is especially useful for applications that display sensitive information such as:
7+
ScreenShieldKit hides selected content from screenshots, screen recordings, and other system captures while it stays fully visible on the device. This is useful for screens that display sensitive information such as:
88

9-
- Financial data
9+
- Financial data (balances, card numbers)
1010
- Personal identification information
11+
- One-time passcodes and authentication screens
1112
- Confidential documents
12-
- Authentication screens
13+
14+
The protection is scoped: you protect only the views that contain secrets, and the rest of your UI is captured normally.
1315

1416
## Requirements
1517

16-
- iOS 14.0+ / macOS 11.0+
18+
| Surface | Minimum OS |
19+
| --- | --- |
20+
| UIKit / AppKit / CALayer | iOS 14.0 · macOS 11.0 |
21+
| SwiftUI | iOS 16.0 · macOS 13.0 · tvOS 16.0 · watchOS 11.0 · visionOS 1.0 |
22+
1723
- Swift 5.7+
1824

1925
## Installation
2026

2127
### Swift Package Manager
2228

23-
Add ScreenShieldKit to your project through Xcode's Package Dependencies:
29+
Add ScreenShieldKit through Xcode's **File → Add Packages…** with the URL:
2430

25-
1. In Xcode, select "File" → "Add Packages..."
26-
2. Enter the repository URL: `https://github.com/daangn/ScreenShieldKit.git`
27-
3. Choose the version you want to use
31+
```
32+
https://github.com/daangn/ScreenShieldKit.git
33+
```
2834

29-
Or add it to your `Package.swift` file:
35+
Or add it to your `Package.swift`:
3036

3137
```swift
3238
dependencies: [
@@ -36,31 +42,99 @@ dependencies: [
3642

3743
## Usage
3844

39-
### Basic Usage
45+
### SwiftUI
46+
47+
Apply the `.screenShield(_:)` modifier to any view you want to hide from captures:
48+
49+
```swift
50+
import ScreenShieldKit
51+
import SwiftUI
52+
53+
Text(oneTimeCode)
54+
.font(.system(.title, design: .monospaced))
55+
.screenShield()
56+
```
57+
58+
To protect several views at once, group them and apply the modifier a single time:
59+
60+
```swift
61+
VStack {
62+
AccountBalanceView()
63+
CardNumberView()
64+
}
65+
.screenShield()
66+
```
67+
68+
**Dynamic on/off**
69+
70+
Pass a `Bool` that participates in SwiftUI state to toggle protection at runtime:
71+
72+
```swift
73+
struct CardView: View {
74+
@State private var isSensitive = true
75+
76+
var body: some View {
77+
VStack {
78+
Toggle("Protect content", isOn: $isSensitive)
79+
80+
Text("1234 5678 9012 3456")
81+
.screenShield(isSensitive)
82+
}
83+
}
84+
}
85+
```
86+
87+
Toggling protection preserves the wrapped content's identity: it never inserts or removes views based on the flag, so the content's `@State`, scroll position, focus, and in-flight animations are **not** reset when you switch protection on or off.
88+
89+
### UIKit
4090

4191
```swift
4292
import ScreenShieldKit
4393

44-
// iOS
4594
let view = UIView()
46-
view.setScreenShield(enabled: true) // Enable protection
95+
view.setScreenShield(enabled: true) // Enable protection
96+
view.setScreenShield(enabled: false) // Disable protection
97+
```
4798

48-
// When protection is no longer needed
49-
view.setScreenShield(enabled: false) // Disable protection
99+
### AppKit
100+
101+
```swift
102+
import ScreenShieldKit
50103

51-
// macOS
52104
let view = NSView()
53-
view.setScreenShield(enabled: true) // Enable protection
105+
view.setScreenShield(enabled: true) // The view must have a backing layer
106+
```
107+
108+
### Core Animation
54109

55-
// When protection is no longer needed
56-
view.setScreenShield(enabled: false) // Disable protection
110+
```swift
111+
import ScreenShieldKit
112+
113+
layer.setScreenShield(enabled: true)
57114
```
58115

59-
## Important Notes
116+
## How it works
117+
118+
ScreenShieldKit selects a mechanism per platform and OS version:
119+
120+
| Path | OS | Mechanism |
121+
| --- | --- | --- |
122+
| SwiftUI (native) | iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+ | Activates SwiftUI's native capture-redaction reason in the environment. Fully transparent to layout; no hosting boundary. |
123+
| SwiftUI (legacy) | iOS 16–17 / macOS 13–14 / tvOS 16–17 / visionOS 1 | Hosts the content in a hosting controller and applies the Core Animation capture flag to its backing layer. |
124+
| UIKit / AppKit / CALayer | iOS 14+ / macOS 11+ | Applies the Core Animation capture flag directly to the layer. |
125+
126+
The SwiftUI native path is preferred where available because it composes cleanly with SwiftUI layout and state. The legacy path re-hosts the content, but SwiftUI keeps propagating the ancestor environment across the hosting boundary, so `@Environment` values and `@EnvironmentObject` dependencies still reach the shielded content.
127+
128+
## Important notes
129+
130+
- **Private platform behavior.** ScreenShieldKit relies on private platform behavior that can change between OS releases. When the mechanism is unavailable on a device, the call becomes a safe no-op (it never crashes). **Verify capture protection on every OS version you support** — with screenshots, screen recordings, AirPlay mirroring, and any in-app capture flow that matters for your product.
131+
- **App Store.** The library avoids private selectors and private symbol references: the SwiftUI native path constructs the capture-redaction option value directly, and the Core Animation path resolves its property key at runtime. Even so, using private platform behavior carries review risk you should evaluate for your app.
132+
- **`privacySensitive` interaction (SwiftUI native path).** On the native path (iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+), the shielded subtree is forced non-privacy-sensitive — even when `isProtected` is `false` — so activating the capture-redaction reason never blanks the content on device. Avoid relying on `.privacySensitive()` inside a `.screenShield()` boundary. The legacy path does not apply this, since it hides via the layer flag rather than a redaction reason.
133+
- **macOS is best-effort.** On macOS, layer-level capture hiding may not hide content from every system capture path.
134+
135+
## Sample
60136

61-
- This library uses private APIs which may not be approved for App Store submission
62-
- The protection mechanism may change in future OS versions
63-
- For macOS, the view must have a backing layer for the protection to work
137+
The `Sample/` app has two tabs — a UIKit demo and a SwiftUI demo — both driving protection from a toggle. The sample targets iOS 16 so you can exercise the legacy and native SwiftUI paths on the OS versions you support.
64138

65139
## License
66140

Sample/Sample.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@
251251
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
252252
GCC_WARN_UNUSED_FUNCTION = YES;
253253
GCC_WARN_UNUSED_VARIABLE = YES;
254-
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
254+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
255255
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
256256
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
257257
MTL_FAST_MATH = YES;
@@ -308,7 +308,7 @@
308308
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
309309
GCC_WARN_UNUSED_FUNCTION = YES;
310310
GCC_WARN_UNUSED_VARIABLE = YES;
311-
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
311+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
312312
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
313313
MTL_ENABLE_DEBUG_INFO = NO;
314314
MTL_FAST_MATH = YES;

Sample/Sample/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// AppDelegate.swift
33
// Sample
44
//
5-
// Created by Ray on 5/12/25.
5+
// Created by Kanghoon Oh on 5/12/25.
66
//
77

88
import UIKit

Sample/Sample/SceneDelegate.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// SceneDelegate.swift
33
// Sample
44
//
5-
// Created by Ray on 5/12/25.
5+
// Created by Kanghoon Oh on 5/12/25.
66
//
77

8+
import SwiftUI
89
import UIKit
910

1011
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@@ -17,8 +18,30 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
1718
) {
1819
guard let windowScene = (scene as? UIWindowScene) else { return }
1920
let window = UIWindow(windowScene: windowScene)
20-
window.rootViewController = ViewController()
21+
window.rootViewController = makeRootViewController()
2122
window.makeKeyAndVisible()
2223
self.window = window
2324
}
25+
26+
private func makeRootViewController() -> UIViewController {
27+
let uikitTab = UINavigationController(rootViewController: ViewController())
28+
uikitTab.tabBarItem = UITabBarItem(
29+
title: "UIKit",
30+
image: UIImage(systemName: "square.dashed"),
31+
tag: 0
32+
)
33+
34+
let swiftuiTab = UINavigationController(
35+
rootViewController: UIHostingController(rootView: SwiftUIDemoView())
36+
)
37+
swiftuiTab.tabBarItem = UITabBarItem(
38+
title: "SwiftUI",
39+
image: UIImage(systemName: "swift"),
40+
tag: 1
41+
)
42+
43+
let tabBarController = UITabBarController()
44+
tabBarController.viewControllers = [uikitTab, swiftuiTab]
45+
return tabBarController
46+
}
2447
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// SwiftUIDemoView.swift
3+
// Sample
4+
//
5+
// Created by Kanghoon Oh on 7/12/26.
6+
//
7+
8+
import ScreenShieldKit
9+
import SwiftUI
10+
11+
/// Demonstrates ScreenShieldKit's SwiftUI surface: the ``View/screenShield(_:)`` modifier applied to
12+
/// both a composed card and inline text, with dynamic on/off protection driven by `@State`.
13+
///
14+
/// Take a screenshot or start a screen recording while `Protect content` is on: the shielded views
15+
/// disappear from the capture while staying visible on the device.
16+
struct SwiftUIDemoView: View {
17+
@State private var isProtected = true
18+
19+
var body: some View {
20+
ScrollView {
21+
VStack(spacing: 28) {
22+
Toggle("Protect content", isOn: $isProtected)
23+
24+
// 1) Modifier on a composed view.
25+
VStack(alignment: .leading, spacing: 8) {
26+
sectionTitle(".screenShield(_:) on a card")
27+
balanceCard
28+
.screenShield(isProtected)
29+
}
30+
31+
// 2) Modifier on inline text.
32+
VStack(alignment: .leading, spacing: 8) {
33+
sectionTitle(".screenShield(_:) on inline text")
34+
Text("1234 5678 9012 3456")
35+
.font(.system(.title3, design: .monospaced))
36+
.frame(maxWidth: .infinity)
37+
.padding()
38+
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.12)))
39+
.screenShield(isProtected)
40+
}
41+
42+
Text(
43+
"Take a screenshot or start a screen recording. The shielded content is hidden from the capture but stays visible here. Toggling protection keeps the views' state intact."
44+
)
45+
.font(.footnote)
46+
.foregroundColor(.secondary)
47+
.frame(maxWidth: .infinity, alignment: .leading)
48+
}
49+
.padding()
50+
}
51+
.navigationTitle("SwiftUI")
52+
}
53+
54+
private func sectionTitle(_ text: String) -> some View {
55+
Text(text)
56+
.font(.subheadline.weight(.semibold))
57+
.foregroundColor(.secondary)
58+
}
59+
60+
private var balanceCard: some View {
61+
VStack(alignment: .leading, spacing: 6) {
62+
Text("Account balance")
63+
.font(.caption)
64+
.foregroundColor(.white.opacity(0.8))
65+
Text("$ 128,400.00")
66+
.font(.largeTitle.bold())
67+
.foregroundColor(.white)
68+
}
69+
.frame(maxWidth: .infinity, alignment: .leading)
70+
.padding()
71+
.background(
72+
RoundedRectangle(cornerRadius: 16)
73+
.fill(LinearGradient(
74+
colors: [.blue, .purple],
75+
startPoint: .topLeading,
76+
endPoint: .bottomTrailing
77+
))
78+
)
79+
}
80+
}
81+
82+
#Preview {
83+
NavigationView {
84+
SwiftUIDemoView()
85+
}
86+
}

0 commit comments

Comments
 (0)