iOS 27からのScreenCaptureKitで特定のSwiftUIビューを画面録画から除外する方法、の巻

iOS 27からのScreenCaptureKitで特定のSwiftUIビューを画面録画から除外する方法、の巻

はじめに

現行の最新環境であるiOS 27で正式採用されたScreenCaptureKitを使ったアプリ内画面キャプチャを実装しました。

その操作の際に、画面録画中であればそれが分かるように、また、ボタンを押したら録画停止ができるように、「🔴録画中」っていうインジケーター兼ボタンを出すようにしたんです。

なので、保存される画像や動画においても「🔴録画中」って表示されます。

でも、

「撮影しているユーザーに見せる『録画中』インジケーター(ボタン)は画面に出しておきたい。でも、キャプチャされた録画ファイルには映り込んでほしくない」

とふと思ったんですよ、なんでか知らんけど。

でも、ScreenCaptureKitの基本設計は「ウィンドウ単位」または「アプリ単位」での除外しかできず、特定の「View(SwiftUIのコンポーネント)」だけをピンポイントで直接除外する公式APIは用意されていないのでした(2026年9月17日現在で)。

で、ネットを調べたりAIに聞いてみると、「パスワード入力欄等のセキュア情報の表示(UITextField の isSecureTextEntry)を応用するビュー単位の裏技」があることがわかりました。

そこで早速試してみたんですけど、iOS 27のiPhone実機で試すと

デバイスの物理ボタンでスクショ(静止画)を撮ると綺麗に対象Viewが消えるのに、ScreenCaptureKitの画面録画(動画)だとバッチリ映り込んだまま

ということがわかりました。およよ。。。

どうやら、、、


🛑 UITextField のisSecureTextEntryを応用したハックでは、「スクショでは消えるけど、ScreenCaptureKitの画面録画では消えない」

かつては、透明な UITextField のセキュアコンテナの中に隠したいViewを入れ込むだけで、キャプチャした画像や動画に映り込むことを回避できていたそうです。

しかし、どうやら近年のiOSのグラフィック描画システム(RenderServer)は非常に高度に最適化されて、「静止画(物理ボタンでのスクショ保存)」と「動画(ScreenCaptureKitなどによるビデオキャプチャ)」の処理ルートを完全に分離していて、

  • 物理スクショ時: 従来通りセキュアマスクをかけて非表示にする(検証時も綺麗に消える)
  • ScreenCaptureKit録画時: コンテナや背景レイヤーのマスク処理を意図的に「素通り(スルー)」させてビデオデータに流し込む

このため、既存のUITextFieldisSecureTextEntryを応用したハックは、動画キャプチャに対して無力化されています。

と、生成AIが言っていました。


はい、というわけで今回は、

あるViewについて、アプリの表示画面では見えるようにするけど、ScreenCaptureKitを使って録画した動画からは消し去る方法の備忘録です。

なお、他にもっと良い方法があるかもしれません。


💡 解決策:公式仕様の「ウィンドウ分離×自動追従」アプローチ

システムが公式に認めている「ウィンドウ(UIWindow)単位での録画除外」を使うことにしました。

ただし、ボタン1つのために別ウィンドウを自前で管理しようとすると、SwiftUIの HStackSpacer() のサイズ計算と衝突して、レイアウトが画面中央に暴走したり、見えない巨大なレイヤーが画面全体のタップ操作をブロック(フリーズ)したりするバグが多発します(と、生成AIが言っている)。

というわけで、こうするといいらしい

  1. メイン画面のレイアウト(HStack内)には、位置とサイズを測るための「透明な身代わりの型枠(ダミー)」だけを配置する
  2. そのダミーのグローバル座標(画面上の絶対位置)を GeometryReader でリアルタイムに1ポイント単位で計測する
  3. 録画対象外(セキュア設定)にした「独立した小さな透明ウィンドウ」を生成し、計測した位置へピンポイントで重ね合わせる(追従させる)

この手法をとることで、SwiftUIの元のレイアウトを一切破壊せず、操作性も完璧なまま、動画・スクショから消したいViewを消し去ることに成功したのでした。


🛠️ 実装コード

📌 前提1:録画ステート(enum)の定義

本記事のコードでは、録画の状態を管理するために以下のような Equatable に準拠した enum を使用しています。
カウントダウン状態(引数あり)など、適宜カスタマイズしてご使用ください。

enum ScreenRecordingState: Equatable {
    case idle           // 待機中
    case countdown(Int) // 録画開始前のカウントダウン(例: 3, 2, 1)*この記事のコードでは使っていません
    case recording      // 録画中
    case saving         // 動画ファイル保存中
    case failed         // 保存失敗・エラー
}

📌 前提2:録画管理ラッパークラス(ScreenRecorder)の実装

本記事のコードでボタンから呼び出している `screenRecorder?.stopScreenCapture()` の中身となる、録画管理ラッパークラスです。 iOS 27の `ScreenCaptureKit` における、ストリームの安全な非同期停止とリソース破棄(無効化)の標準的な手順を示しています。

//
//  ScreenRecorder.swift
//  Clip_a_doodle_draw
//
//  Created by bu on 2026/09/13.
//

import Foundation
import SwiftUI
import ScreenCaptureKit
import AVFoundation
import OSLog
import Photos


enum ScreenRecordingState: Equatable {
    case idle
    case countdown(Int)
    case recording
    case saving
    case failed
}

@available(iOS 27.0, *)
@MainActor
final class ScreenRecorder: NSObject, ObservableObject {

    private let picker = SCContentSharingPicker.shared

    private var stream: SCStream?
    private var recordingOutput: SCRecordingOutput?
    private var outputURL: URL?

    // 停止完了を待機するための Continuation
    private var stopContinuation: CheckedContinuation<Void, Never>?

    @Published private(set) var recordingState: ScreenRecordingState = .idle
    var onRecordingStateChanged: ((ScreenRecordingState) -> Void)?
    let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppScreenRecorderCode")
    
    override init() {
        super.init()
        picker.add(self)
        picker.isActive = true
    }

    deinit {
        picker.remove(self)
    }

    // MARK: - Start
    func startScreenCapture() {
        guard recordingState == .idle else { return }
        
        var config = SCContentSharingPickerConfiguration()
        config.showsMicrophoneControl = true
        picker.defaultConfiguration = config
        picker.presentForCurrentApplication()
    }

    // MARK: - Stop
    func stopScreenCapture() async {
        guard
              let stream,
              let recordingOutput else { return }
        
        recordingState = .saving
        onRecordingStateChanged?(.saving)

        do {
            // 書き出し完了コールバックを非同期で待つ
            await withCheckedContinuation { continuation in
                self.stopContinuation = continuation
                
                Task {
                    try? await stream.stopCapture()
                    try? stream.removeRecordingOutput(recordingOutput)
                }
            }
        }
    }

    // MARK: - Create recording
    private func startRecording(with filter: SCContentFilter) async {
        do {
            let streamConfiguration = SCStreamConfiguration()
            streamConfiguration.capturesAudio = true

            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("recording_\(Date().timeIntervalSince1970).mp4")

            let recordingConfiguration = SCRecordingOutputConfiguration()
            recordingConfiguration.outputURL = url
            recordingConfiguration.outputFileType = .mp4
            recordingConfiguration.videoCodecType = .h264

            let output = SCRecordingOutput(
                configuration: recordingConfiguration,
                delegate: self
            )

            let stream = SCStream(
                filter: filter,
                configuration: streamConfiguration,
                delegate: nil
            )

            // 1. ストリーム自体にスクリーン出力を追加(必須)
            try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .main)
            
            // マイクが選択されている場合のみ追加
            if filter.isMicrophoneEnabled {
                try stream.addStreamOutput(self, type: .microphone, sampleHandlerQueue: .main)
            }

            // 2. 録画出力を追加
            try stream.addRecordingOutput(output)

            // 強参照で保持
            self.stream = stream
            self.recordingOutput = output
            self.outputURL = url

            try await stream.startCapture()
            logger.notice("録画を開始しました")

        } catch {
            cleanup()
            logger.notice("録画開始エラー: \(error.localizedDescription)")
        }
    }

    private func cleanup() {
        self.stream = nil
        self.recordingOutput = nil
        self.outputURL = nil
        self.recordingState = .idle
        self.onRecordingStateChanged?(.idle)
    }
}

// MARK: - SCStreamOutput (ダミー受取用)
@available(iOS 27.0, *)
extension ScreenRecorder: SCStreamOutput {
    nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
        // SCRecordingOutput が自動処理するため、個別フレーム処理が不要なら空で問題ない
    }
}

// MARK: - SCContentSharingPickerObserver
@available(iOS 27.0, *)
extension ScreenRecorder: SCContentSharingPickerObserver {
    nonisolated func contentSharingPicker(_ picker: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) {
        Task { @MainActor in
            await self.startRecording(with: filter)
        }
    }

    nonisolated func contentSharingPicker(_ picker: SCContentSharingPicker, didCancelFor stream: SCStream?) {
        Task { @MainActor in
            logger.notice("録画をキャンセルしました")
        }
    }

    nonisolated func contentSharingPickerStartDidFailWithError(_ error: any Error) {
        Task { @MainActor in
            logger.notice("録画開始エラー: \(error.localizedDescription)")
        }
    }
}

// MARK: - SCRecordingOutputDelegate
@available(iOS 27.0, *)
extension ScreenRecorder: SCRecordingOutputDelegate {
    nonisolated func recordingOutputDidStartRecording(_ recordingOutput: SCRecordingOutput) {
        Task { @MainActor in
            logger.notice("録画中")
            self.recordingState = .recording
            self.onRecordingStateChanged?(.recording)
        }
    }

    nonisolated
    func recordingOutputDidFinishRecording(
        _ recordingOutput: SCRecordingOutput
    ) {
        Task { @MainActor in
            guard let url = self.outputURL else {
                self.cleanup()
                self.stopContinuation?.resume()
                self.stopContinuation = nil
                return
            }

            logger.notice("録画ファイルが完成しました")

            do {
                let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)

                guard status == .authorized || status == .limited else {
                    logger.notice("写真への保存が許可されていません")
                    self.cleanup()
                    self.stopContinuation?.resume()
                    self.stopContinuation = nil
                    return
                }

                try await PHPhotoLibrary.shared().performChanges {
                    let options = PHAssetResourceCreationOptions()
                    options.shouldMoveFile = true

                    let request = PHAssetCreationRequest.forAsset()
                    request.addResource(
                        with: .video,
                        fileURL: url,
                        options: options
                    )
                }

                logger.notice("録画ファイルを写真に保存しました")

            } catch {
                logger.notice("写真への保存に失敗しました: \(error.localizedDescription)")
            }
            try? await Task.sleep(for: .seconds(1))

            self.cleanup()
            self.stopContinuation?.resume()
            self.stopContinuation = nil
        }
    }

    nonisolated func recordingOutput(_ recordingOutput: SCRecordingOutput, didFailWithError error: any Error) {
        Task { @MainActor in
            logger.notice("録画エラー: \(error.localizedDescription)")
            self.recordingState = .failed
            self.onRecordingStateChanged?(.failed)
            
            try? await Task.sleep(for: .seconds(1.5))
            
            self.cleanup()
            self.stopContinuation?.resume()
            self.stopContinuation = nil
        }
    }
}

1. 別ウィンドウ追従システムを実装する(共通パーツ)

まずは、以下のコードを SecureRecordingWrapper.swift などの名前でプロジェクトに追加します。
呼び出し元の独自のステート(enumなど)を安全に受け取れるよう、ジェネリクスを活用してビルドエラーを防いでいます。

import SwiftUI
import UIKit

// ① 録画中だけ、ボタンを画面外の別レイヤー(別ウィンドウ)に追い出す管理クラス
class SecureWindowController {
    static let shared = SecureWindowController()
    private var window: UIWindow?

    func show<State: Equatable, Recorder>(
        screenRecordingState: Binding<State>,
        screenRecorder: Recorder?,
        rect: CGRect,
        idleState: State,
        recordingState: State,
        savingState: State,
        failedState: State
    ) {
        guard window == nil else { return }
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }

        let textField = UITextField()
        textField.isSecureTextEntry = true
        guard let secureContainer = textField.subviews.first else { return }

        // 録画に写らない特殊なUIWindowを生成
        let secureWindow = UIWindow(windowScene: windowScene)
        secureWindow.frame = rect
        secureWindow.backgroundColor = .clear
        secureWindow.windowLevel = .alert + 1 // メイン画面より必ず上に配置

        // ウィンドウ内に浮かせるボタンにステートを橋渡し
        let buttonView = FloatingStopButton(
            screenRecordingState: screenRecordingState,
            idleState: idleState,
            recordingState: recordingState,
            savingState: savingState,
            failedState: failedState
        )
        let hostingController = UIHostingController(rootView: buttonView)
        hostingController.view.backgroundColor = .clear

        secureContainer.addSubview(hostingController.view)
        hostingController.view.frame = secureContainer.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        secureWindow.addSubview(secureContainer)
        secureContainer.frame = secureWindow.bounds

        secureWindow.isHidden = false
        self.window = secureWindow
    }

    func hide() {
        window?.isHidden = true
        window = nil
    }
}

// ② ウィンドウの中に浮かせる、本物の録画停止ボタン(見た目は元のまま)
struct FloatingStopButton<State: Equatable>: View {
    @Binding var screenRecordingState: State
    var idleState: State
    var recordingState: State
    var savingState: State
    var failedState: State

    var body: some View {
        Button {
            guard screenRecordingState == recordingState else { return }
            // メイン画面へ停止命令を通知する
            NotificationCenter.default.post(name: Notification.Name("StopScreenCapture"), object: nil)
        } label: {
            HStack(spacing: 6) {
                if screenRecordingState == failedState {
                    Image(systemName: "exclamationmark.circle.fill")
                        .frame(width: 12, height: 12)
                        .foregroundStyle(.red)
                } else {
                    Circle()
                        .fill(.red)
                        .frame(width: 8, height: 8)
                }

                VStack(alignment: .leading, spacing: 0) {
                    if screenRecordingState == recordingState {
                        Text("Recording")
                            .font(.caption)
                            .fontWeight(.semibold)
                        Text("Tap to stop")
                            .font(.caption2)
                    } else if screenRecordingState == savingState {
                        Text("Saving...")
                            .font(.caption)
                            .fontWeight(.semibold)
                    } else if screenRecordingState == failedState {
                        Text("Save failed")
                            .font(.caption)
                            .fontWeight(.semibold)
                    } else {
                        EmptyView()
                    }
                }
            }
            .padding(.horizontal, 8)
            .padding(.vertical, screenRecordingState != savingState ? 4 : 6)
            .background(.black.opacity(0.7))
            .foregroundStyle(.white)
            .clipShape(Capsule())
        }
        .buttonStyle(.plain)
    }
}

// ③ 親のHStack内に置いて、サイズと位置を自動計測して別ウィンドウに伝えるダミーModifier
struct SecureRecordingAnchorModifier<State: Equatable, Recorder>: ViewModifier {
    @Binding var screenRecordingState: State
    var screenRecorder: Recorder?

    var idleState: State
    var recordingState: State
    var savingState: State
    var failedState: State

    func body(content: Content) -> some View {
        content
            .opacity(0) // 完全に透明にして「型枠」としてのみ使う
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .onAppear {
                            updateWindowPosition(geometry: geometry)
                        }
                        .onChange(of: screenRecordingState) {
                            if screenRecordingState != idleState {
                                updateWindowPosition(geometry: geometry)
                            } else {
                                SecureWindowController.shared.hide()
                            }
                        }
                }
            )
    }

    private func updateWindowPosition(geometry: GeometryProxy) {
        guard screenRecordingState != idleState else { return }
        let globalFrame = geometry.frame(in: .global)

        DispatchQueue.main.async {
            SecureWindowController.shared.show(
                screenRecordingState: $screenRecordingState,
                screenRecorder: screenRecorder,
                rect: globalFrame,
                idleState: idleState,
                recordingState: recordingState,
                savingState: savingState,
                failedState: failedState
            )
        }
    }
}

// ④ 呼び出し用のView拡張
extension View {
    func hideFromScreenRecording<State: Equatable, Recorder>(
        state: Binding<State>,
        recorder: Recorder?,
        idle: State,
        recording: State,
        saving: State,
        failed: State
    ) -> some View {
        self.modifier(SecureRecordingAnchorModifier(
            screenRecordingState: state,
            screenRecorder: recorder,
            idleState: idle,
            recordingState: recording,
            savingState: saving,
            failedState: failed
        ))
    }
}

2. 親ビュー(メインUI)側での使い方

親ビュー側では、配置されていたボタンの末尾に、定義した各ステートを引数として渡す形で .hideFromScreenRecording(...) を呼び出すだけです。

その前に、


🔄 録画ステートの変更を別ウィンドウに伝える(繋ぎ込み)

メイン画面や録画マネージャー側でステートが変更された際、その通知を受け取って浮いている別ウィンドウを表示・非表示(または更新)させるための繋ぎ込み(コールバック)の実装です。

独自の録画マネージャーなどで以下のようなクロージャを定義しておきます:

var onRecordingStateChanged: ((ScreenRecordingState) -> Void)?

ビューの初期化時や onAppear などの適切なタイミングで、以下のように SecureWindowController へ状態の変化をブリッジ(橋渡し)します。ここでは、画面録画開始ボタンを押したときにこの処理をしています。


// 録画マネージャーの初期化とSecureWindowControllerへのブリッジ
if #available(iOS 27.0, *) {
    Button("🎥 Screen Recording") {
    // iOS 27以降:ScreenCaptureKitを使った録画処理
        if screenRecorder == nil {
            screenRecorder = ScreenRecorder()
                screenRecorder?.onRecordingStateChanged = { state in
                    screenRecordingState = state
                }
        }
        screenRecorder?.startScreenCapture()
    }
} else {
    // iOS 26以下:アップデートを促す非活性ボタン
    Button("🎥 Screen Recording (iOS 27+)") { }
        .disabled(true)
}

それでは、.hideFromScreenRecording(...) をキャプチャー画像(映像)から排除したいviewに付与します

なお、メイン画面のViewには

@available(iOS 27.0, *)
@State private var screenRecorder: ScreenRecorder?
@State private var screenRecordingState: ScreenRecordingState = .idle

を記述しています


// メイン画面のレイアウト(VStack や HStack の中)
if #available(iOS 27.0, *), screenRecordingState != .idle {
    Button {
        guard screenRecordingState == .recording else { return }
        Task {
            await screenRecorder?.stopScreenCapture()
        }
    } label: {
        HStack(spacing: 6) {
            // 元のレイアウトコード(型枠としてそのまま残す)
            Text("Recording")
            // ... 
        }
        .padding(.horizontal, 8)
        .background(.black.opacity(0.7))
        .clipShape(Capsule())
    }
    .buttonStyle(.plain)
    .padding(.trailing, 80) // 元の配置パディング
    .offset(y: -12)         // 元のオフセット位置

    // 🔥 必ず一番最後にModifierを追加!
    // 複雑な配置計算が終わった「正しい位置」を自動計測して別ウィンドウに送ります
    .hideFromScreenRecording(
        state: $screenRecordingState,
        recorder: screenRecorder,
        idle: .idle,
        recording: .recording,
        saving: .saving,
        failed: .failed
    )
}

3. ボタンの停止命令を受け取る処理(受け皿)

別ウィンドウに浮いている本物のボタンがタップされたとき、メイン画面のレコーダーを安全に止めるため、メイン画面の最外殻のビュー(一番外側の大きな ZStack など)の末尾に以下のレシーバーを追加します。

var body: some View {
    ZStack {
    //  ここにコンテンツ
    }
    .onReceive(NotificationCenter.default.publisher(for: Notification.Name("StopScreenCapture"))) 
        { _ in
        Task {
            if #available(iOS 27.0, *) 
            {
            // 通知を受け取って安全に録画を停止)
            await screenRecorder?.stopScreenCapture()
            }
        }
    }
}
    

おわりに

さて、こうなりました。

  • ScreenCaptureKit録画・物理スクショの双方で狙ったViewを消せる
    • ウィンドウ(UIWindow)単位でOSのセキュア保護にかけるため、最新の動画キャプチャエンジンを完全にスルーさせて非表示化できます。
  • SwiftUIのレイアウトが崩れない
    • HStack に配置されているのは透明にした「型枠」となるボタンのため、Spacer() や余白の計算を一切邪魔せず、画面中央に暴走することもない。
  • ユーザー体験(UX)問題なし
    • ユーザーには操作中に録画中を示すインジケーターを見ることができる。また、今回は停止ボタンも兼ねているのでタップして停止する動作も機能する。

以上、iOS 27環境におけるScreenCaptureKitの高度なビデオ描画を、SwiftUIの座標計測とUIKitのマルチウィンドウシステムのドッキングでハックする実装方法でした。

うっかりどなたかのお役に立てば嬉しい。。。

おしまい


※本記事の実装は、国内外のデベロッパーコミュニティによる集合知をベースに、AIアシスタントとの検証・リファクタリングを重ねて完成させた、iOS 27 / ScreenCaptureKit対応 of 検証済みコードです。

💻 動作確認環境

この記事の実装は、以下の環境の実機にて動作検証を行っています。

  • Xcode: Version 27.0
  • iOS: iOS 27.0 (iPhone 14実機)
  • 主なフレームワーク: SwiftUI / ScreenCaptureKit / UIKit
  • 検証日: 2026年9月17日

📝補足

コードの補足です

🧱 1:SecureWindowController(秘密の部屋の管理人のようなもの?)

このクラスの仕事は、「録画に絶対に写らない、独立した透明な板(ウィンドウ)を画面の一番手前に1枚浮かせること」です。

  • textField.isSecureTextEntry = true して、secureContainer を取り出す
    • iOSはパスワードを盗撮から守るため、この設定をしたビュー(箱)を「画面録画(動画キャプチャエンジン)から自動的に除外する」という超強力な特殊フラグをシステムレベルで立てます。
  • secureWindow.windowLevel = .alert + 1
    • メイン画面よりもさらに手前の、誰も邪魔できない「最前面」にこのウィンドウを配置します。
  • secureContainer.addSubview(hostingController.view)
    • 「録画に写らない秘密の箱」の中に、本物の「録画停止ボタン(FloatingStopButton)」を閉じ込めます。

【結果:】 ユーザーの目にはハッキリ見えますが、ScreenCaptureKitの録画機能から見ると、このウィンドウごと存在が完全に無視されます


📐 🎯 2:SecureRecordingAnchorModifier(高性能なGPSセンサーのようなもの)

この部分が、レイアウト崩れのバグを克服するための仕掛けです。
このModifier(修飾子)は、メイン画面の HStack の中で、「本物のボタンの代わりに透明な身代わり(型枠)」として居座ります。

  • .opacity(0)(透明な型枠)
    • 見た目は完全に透明ですが、サイズや余白(.padding(.trailing, 80).offset(y: -12))はそのまま維持します。これにより、SwiftUIの HStackSpacer()「あ、ここにカプセル型のボタンがあるんだな」と正しく認識するため、画面中央への暴走が防げます。
  • geometry.frame(in: .global)(GPS計測みたいなもの)
    • GeometryReader を使って、その透明な型枠が「今、iPhoneの画面全体のどの位置(上から何ピクセル、右から何ピクセル)にあるか」を、1ポイント単位の絶対座標でリアルタイムに計測します。
  • SecureWindowController.shared.show(..., rect: globalFrame)
    • 計測した正確な位置情報を、パーツ1の「管理人」に送ります。管理人は、「はいはい、その位置にピッタリ重なるように、録画に写らないボタンウィンドウを浮かせますよ」 と実行します。

【結果:】 メイン画面のレイアウトは1ミリも崩れず、その上に秘密のボタンが寸分狂わずピタッと重なります。


🔘 3:FloatingStopButtononReceive(遠隔デトネーター)

最後に、浮いている別ウィンドウのボタンが押された時、どうやってメイン画面の録画(ScreenCaptureKit)を止めるかの仕組みです。

  • ウィンドウの中に浮いているボタン(FloatingStopButton)がタップされると、NotificationCenter.default.post(name: Notification.Name("StopScreenCapture"), ...) を実行します。
    • これは、アプリの内部に向けて「誰か録画を止めてくれー」という無線(通知)を一斉に飛ばす処理です。
  • それを、メイン画面の最外殻に仕込んだ .onReceive(...) がキャッチします。
    • 無線を受け取ったメイン画面は、「よし、停止ボタンが押されたな!」 と判断し、
      await screenRecorder?.stopScreenCapture() を実行して安全にストリームを終了します。

💡 仕組みの総まとめ(イメージ図)

  1. メイン画面(SwiftUI):
    「俺は透明なボタンの枠だけをキープしておくよ。位置はここ(例えば右上)ね!」
  2. GPSセンサー(GeometryReader):
    「今ボタンの枠は画面の (x: 200, y: 50) にあるぜ!」
  3. 秘密のウィンドウ(UIKit):
    「了解!じゃあ録画に写らない魔法をかけた本物のボタンを、(x: 200, y: 50) にピッタリ重ねて浮かせるわよ!」
  4. ユーザーがタップ:
    浮いているボタンをポチッと押すと、無線(通知)が飛んで、メイン画面のレコーダーが安全に停止する。

やっていることは「透明な型枠で場所を測って、録画に写らない特殊な別ウィンドウを上にピッタリ重ねているだけ」です。

これにより、iOS 27のScreenCaptureKitでも、レイアウトを一切壊さずに特定の「View(SwiftUIのコンポーネント)」だけをピンポイントで直接除外したかのように見せることができるようになりました。

⚖️ ライセンスについて(コピペ・商用利用自由)

本記事に掲載しているソースコード(SecureRecordingWrapper.swift および周辺コード)は、iOSコミュニティで長年共有されてきた「セキュアコンテナの仕組み」をベースに、SwiftUI(iOS 27 / ScreenCaptureKit)向けに独自に再構築したものです。

本記事で新しく作成したコード表現の著作権については、すべて権利を放棄(CC0 / パブリック・ドメイン相当)しています。

個人利用・商用利用、改変、再配布を問わず、クレジット表記なしで完全に自由にご利用いただけます。