Created
February 3, 2025 11:45
-
-
Save tornikegomareli/a0536cf98f7826a1041db848a8570ee0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // ProtectionOptions.swift | |
| // OneApp | |
| // | |
| // Created by Tornike Gomareli on 31.01.25. | |
| // | |
| /// A set of options that determine how content should be protected from being captured. | |
| /// | |
| /// This type allows for combining multiple protection strategies: | |
| /// ```swift | |
| /// // Basic screenshot protection | |
| /// myView.protected(from: .screenshots) | |
| /// | |
| /// // Combined protection options (future use) | |
| /// myView.protected(from: [.screenshots, .otherOption]) | |
| /// | |
| /// // All protection | |
| /// myView.protected(from: .all) | |
| /// ``` | |
| public struct ProtectionOptions: OptionSet { | |
| public let rawValue: Int | |
| /// Prevents the view from appearing in screenshots | |
| public static let screenshots = ProtectionOptions(rawValue: 0x01) | |
| /// Hide the view from screen sharing (e.g. AirPlay). | |
| public static let screenSharing = ProtectionOptions(rawValue: 0x02) | |
| /// Hide the view while the app is not active (e.g. while task switching). | |
| public static let taskSwitching_inactive = ProtectionOptions(rawValue: 0x04) | |
| /// Hide the view from screenshots, screen sharing and during inactivity (task switching). | |
| public static let all = ProtectionOptions([.screenshots, .screenSharing, .taskSwitching_inactive]) | |
| public init(rawValue: Int) { | |
| self.rawValue = rawValue | |
| } | |
| } | |
| // | |
| // ScreenshotProtectionPlaceholder.swift | |
| // OneApp | |
| // | |
| // Created by Tornike Gomareli on 31.01.25. | |
| // | |
| import SwiftUI | |
| /// A type that defines different placeholder views to be shown during screenshot protection. | |
| /// | |
| /// Use this enum to specify how the protected content should be masked when a screenshot is attempted: | |
| /// ```swift | |
| /// // Basic protection with no placeholder | |
| /// myView.protected(placeholder: .none) | |
| /// | |
| /// // Protection with black screen | |
| /// myView.protected(placeholder: .blackScreen) | |
| /// | |
| /// // Protection with custom content | |
| /// myView.protected(placeholder: .custom(content: AnyView( | |
| /// Text("Screenshots not allowed") | |
| /// ))) | |
| /// ``` | |
| public enum ScreenshotProtectionPlaceholder { | |
| /// No placeholder view will be shown | |
| case none | |
| /// A black screen will cover the protected content | |
| case blackScreen | |
| /// A custom view will be shown instead of the protected content | |
| case custom(content: AnyView) | |
| /// The view to be displayed based on the selected placeholder type | |
| @ViewBuilder | |
| var view: some View { | |
| switch self { | |
| case .none: | |
| EmptyView() | |
| case .blackScreen: | |
| ZStack { | |
| Color.black | |
| .ignoresSafeArea() | |
| } | |
| case .custom(let content): | |
| content | |
| } | |
| } | |
| } | |
| import UIKit | |
| import SwiftUI | |
| /// A `UIViewController` subclass that hosts a SwiftUI view while preventing screenshots and screen recordings. | |
| /// | |
| /// This controller wraps a `SwiftUI.View` inside a `UIHostingController` and embeds it within a `ScreenshotProtectingView`, | |
| /// which adds protection against screen capture. | |
| /// | |
| /// - Note: The protection mechanism relies on `ScreenshotProtectingView`, which should handle screenshot-blocking logic. | |
| final class ScreenshotProtectingHostingViewController<Content: View>: UIViewController { | |
| /// The SwiftUI content view to be hosted within this view controller. | |
| private let content: () -> Content | |
| /// A wrapper view that provides screenshot protection. | |
| private let wrapperView = ScreenshotProtectingView() | |
| /// Creates a `ScreenshotProtectingHostingViewController` that hosts a SwiftUI view with screenshot protection. | |
| /// | |
| /// - Parameter content: A closure returning the SwiftUI view to be displayed. | |
| init(@ViewBuilder content: @escaping () -> Content) { | |
| self.content = content | |
| super.init(nibName: nil, bundle: nil) | |
| setupUI() | |
| } | |
| /// This initializer is not implemented and will trigger a runtime error if called. | |
| /// | |
| /// - Parameter coder: The coder instance. | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| /// Called when the view is about to appear. Ensures the navigation bar is transparent. | |
| /// | |
| /// - Parameter animated: A Boolean value indicating whether the appearance transition is animated. | |
| override func viewWillAppear(_ animated: Bool) { | |
| super.viewWillAppear(animated) | |
| configureNavigationBarTransparency() | |
| } | |
| /// Configures the navigation bar to have a transparent background. | |
| /// | |
| /// This method updates the navigation bar appearance to remove any background color and shadows, | |
| /// ensuring a clean and modern look. | |
| private func configureNavigationBarTransparency() { | |
| guard let navBar = navigationController?.navigationBar else { return } | |
| let appearance = UINavigationBarAppearance() | |
| appearance.configureWithTransparentBackground() | |
| appearance.shadowColor = .clear | |
| navBar.standardAppearance = appearance | |
| navBar.compactAppearance = appearance | |
| navBar.scrollEdgeAppearance = appearance | |
| navBar.isTranslucent = true | |
| } | |
| /// Sets up the UI, embedding the SwiftUI content inside a `ScreenshotProtectingView`. | |
| /// | |
| /// This method: | |
| /// - Adds `wrapperView` as a subview to the main view. | |
| /// - Embeds the SwiftUI view inside a `UIHostingController`. | |
| /// - Adds constraints to ensure proper layout. | |
| private func setupUI() { | |
| view.addSubview(wrapperView) | |
| wrapperView.translatesAutoresizingMaskIntoConstraints = false | |
| NSLayoutConstraint.activate([ | |
| wrapperView.leadingAnchor.constraint(equalTo: view.leadingAnchor), | |
| wrapperView.trailingAnchor.constraint(equalTo: view.trailingAnchor), | |
| wrapperView.topAnchor.constraint(equalTo: view.topAnchor), | |
| wrapperView.bottomAnchor.constraint(equalTo: view.bottomAnchor) | |
| ]) | |
| let hostVC = UIHostingController(rootView: content()) | |
| hostVC.view.translatesAutoresizingMaskIntoConstraints = false | |
| addChild(hostVC) | |
| wrapperView.setup(contentView: hostVC.view) | |
| hostVC.didMove(toParent: self) | |
| } | |
| } | |
| import UIKit | |
| /// A `UIView` subclass that prevents screenshots and screen recordings. | |
| /// | |
| /// `ScreenshotProtectingView` uses a hidden `UITextField` with `isSecureTextEntry = true` to leverage iOS’s | |
| /// built-in screenshot protection. This view acts as a secure container, ensuring its contents cannot be captured. | |
| /// | |
| /// - Note: This approach relies on iOS internals and may be subject to changes in future updates. | |
| public final class ScreenshotProtectingView: UIView { | |
| /// The content view that will be protected from screenshots. | |
| private var contentView: UIView? | |
| /// A hidden `UITextField` used to enable secure content protection. | |
| private let textField = UITextField() | |
| /// The secure container extracted from the `UITextField`, which prevents screenshots. | |
| private lazy var secureContainer: UIView? = try? getSecureContainer(from: textField) | |
| /// Initializes a `ScreenshotProtectingView` with an optional content view. | |
| /// | |
| /// - Parameter contentView: The view that should be protected from screenshots. | |
| public init(contentView: UIView? = nil) { | |
| self.contentView = contentView | |
| super.init(frame: .zero) | |
| setupUI() | |
| } | |
| required init?(coder aDecoder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| /// Sets up the UI components and enables screenshot protection. | |
| /// | |
| /// This method: | |
| /// - Configures the hidden `UITextField` to enable protection. | |
| /// - Extracts the secure container from the `UITextField`. | |
| /// - Embeds the content view inside the secure container. | |
| private func setupUI() { | |
| textField.backgroundColor = .clear | |
| textField.isUserInteractionEnabled = false | |
| textField.isSecureTextEntry = true | |
| guard let container = secureContainer else { return } | |
| addSubview(container) | |
| container.translatesAutoresizingMaskIntoConstraints = false | |
| NSLayoutConstraint.activate([ | |
| container.leadingAnchor.constraint(equalTo: leadingAnchor), | |
| container.trailingAnchor.constraint(equalTo: trailingAnchor), | |
| container.topAnchor.constraint(equalTo: topAnchor), | |
| container.bottomAnchor.constraint(equalTo: bottomAnchor) | |
| ]) | |
| guard let contentView = contentView else { return } | |
| setup(contentView: contentView) | |
| } | |
| /// Embeds a new content view inside the secure container. | |
| /// | |
| /// - Parameter contentView: The view to be protected from screenshots. | |
| public func setup(contentView: UIView) { | |
| self.contentView?.removeFromSuperview() | |
| self.contentView = contentView | |
| guard let container = secureContainer else { return } | |
| container.addSubview(contentView) | |
| container.isUserInteractionEnabled = isUserInteractionEnabled | |
| contentView.translatesAutoresizingMaskIntoConstraints = false | |
| let bottomConstraint = contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor) | |
| bottomConstraint.priority = .required - 1 | |
| NSLayoutConstraint.activate([ | |
| contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor), | |
| contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor), | |
| contentView.topAnchor.constraint(equalTo: container.topAnchor), | |
| bottomConstraint | |
| ]) | |
| } | |
| /// Retrieves the secure container from a `UITextField`, which blocks screenshots. | |
| /// | |
| /// - Parameter view: A `UITextField` used to extract the secure container. | |
| /// - Throws: An error if the secure container is not found. | |
| /// - Returns: The secure container view that prevents screenshots. | |
| func getSecureContainer(from view: UIView) throws -> UIView { | |
| let containerName: String = "_UITextLayoutCanvasView" | |
| let containers = view.subviews.filter { type(of: $0).description() == containerName } | |
| guard let container = containers.first else { | |
| throw NSError(domain: "YourDomain", code: -1, userInfo: ["ContainerNotFound": containerName]) | |
| } | |
| return container | |
| } | |
| } | |
| import class Combine.AnyCancellable | |
| import SwiftUI | |
| /// A UIViewRepresentable wrapper that provides screenshot protection for SwiftUI views. | |
| /// | |
| /// This view uses private iOS APIs to prevent content from appearing in screenshots | |
| /// by leveraging the secure text entry system. | |
| struct ProtectedView<Content: View>: UIViewControllerRepresentable { | |
| typealias UIViewControllerType = ScreenshotProtectingHostingViewController<Content> | |
| let options: ProtectionOptions | |
| @State private var hostingController: ScreenshotProtectingHostingViewController<Content> | |
| private let content: () -> Content | |
| private var cancellables = Set<AnyCancellable>() | |
| /// Creates a new protected view with the specified options and content | |
| /// - Parameters: | |
| /// - options: The protection options to apply | |
| /// - content: A closure that provides the content to be protected | |
| init(options: ProtectionOptions = .screenshots, content: @escaping () -> Content) { | |
| self.options = options | |
| self.content = content | |
| self.hostingController = ScreenshotProtectingHostingViewController(content: content) | |
| func subscribe(to notification: Notification.Name, shouldHide: @escaping (Notification) -> Bool) { | |
| NotificationCenter.default.publisher(for: notification) | |
| .sink { [weak hostingController] notification in | |
| hostingController?.view.isHidden = shouldHide(notification) | |
| } | |
| .store(in: &cancellables) | |
| } | |
| if options.contains(.taskSwitching_inactive) { | |
| subscribe(to: UIApplication.willResignActiveNotification) { _ in true } | |
| subscribe(to: UIApplication.didBecomeActiveNotification) { _ in false } | |
| } | |
| if options.contains(.screenSharing) { | |
| subscribe(to: UIScreen.capturedDidChangeNotification) { | |
| ($0.object as? UIScreen)?.isCaptured ?? false | |
| } | |
| } | |
| } | |
| func makeUIViewController(context: Context) -> UIViewControllerType { | |
| ScreenshotProtectingHostingViewController(content: content) | |
| } | |
| func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} | |
| } | |
| import SwiftUI | |
| public extension View { | |
| /// Applies screenshot protection to a view with optional placeholder content. | |
| /// | |
| /// Use this modifier to prevent your view's content from appearing in screenshots: | |
| /// ```swift | |
| /// Text("Sensitive Information") | |
| /// .protected(from: .screenshots, placeholder: .blackScreen) | |
| /// ``` | |
| /// | |
| /// - Parameters: | |
| /// - options: The protection options to apply. Defaults to `.screenshots` | |
| /// - placeholder: The content to show when protection is active. Defaults to `.none` | |
| /// - Returns: A view that is protected according to the specified options | |
| func protected( | |
| from options: ProtectionOptions = .screenshots, | |
| placeholder: ScreenshotProtectionPlaceholder = .blackScreen | |
| ) -> some View { | |
| ProtectedView(options: options) { | |
| self | |
| } | |
| .ignoresSafeArea() | |
| .background(placeholder.view) | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment