この記事はand factory.inc Advent Calendar 2023 22日目の記事です。
昨日は @yuu__uuki さんのSwiftUIでのタップ処理の実装と応用でした。
はじめに
著作権的に慎重に扱うべきコンテンツをモバイルアプリでサービス提供する際に、ユーザーのスクリーンショット操作をどう扱うかという問題は避けて通れないと思います。
たとえば動画サービスの大手Netflixさんのアプリでは、動画再生中にスクショを撮ると下のように作品部分が黒塗りされて作品画像が保存されないような仕組みになっています。
ではこういう仕様を実装するのも簡単だろう、と思ったのですが意外と一筋縄では行かなかったので簡単にまとめることにしました。
NotificationCenterによるスクショ検知
iOSには画面回転などの端末のイベントを検知したり、ある画面から別の画面にイベントを伝播させたりできるNotificationCenter
という仕組みが用意されています。
スクショや画面録画が行われた時に何かしら処理を実行したいとなった時も、まずはこれを試してみる流れになるかと思います。
スクリーンショットを撮ったときはuserDidTakeScreenshotNotification
が、スクリーンレコーディングを開始した時はcapturedDidChangeNotification
が流れてきます。
override func viewDidLoad() {
super.viewDidLoad()
// スクショを監視
NotificationCenter.default.addObserver(
self,
selector: #selector(didTakeScreenCaptured(_:)),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
// スクリーンレコーディング(の状態変化)を監視
NotificationCenter.default.addObserver(
self,
selector: #selector(didStartScreenRecording(_:)),
name: UIApplication.capturedDidChangeNotification,
object: nil
)
}
@objc private func didTakeScreenCaptured(_ notification: Notification) {
blackView.isHidden = UIScreen.main.isCaptured
}
@objc private func didStartScreenRecording(_ notification: Notification) {
blackView.isHidden = UIScreen.main.isCaptured
}
参考:https://qiita.com/takashings/items/3fad553fc97375ec7d9f
これにより画面録画中はコンテンツに目隠しをかけるなどコンテンツの複製をある程度防ぐことはできますが、スクリーンショットに関してはこのイベント通知を受け取るタイミングは撮影が始まった(終わった)後なので静止画コンテンツは保護することができません。
では、スクリーンショットからコンテンツを保護することは不可能なのかというと、必ずしもそうでもないようです。裏技があります。
UITextFieldの_UITextLayoutCanvasView
を応用する
UITextFieldはユーザーからのテキスト入力を受け付けるUIViewです。
世の中の多くのアプリにはパスワード欄など入力しているところを他人にうっかり録画されると困るUIがあるかと思いますが、このUITextFieldの.isSecureTextEntry
プロパティをtrue
にすることで入力中の情報が録画されないようになります。
この手法自体は開発者コミュニティの間では知る人ぞ知るやり方として共有されていたので、本記事はこれらを参考にして書いています。
実装の方針としては、UITextField
を構成するSubviewsの一つ、_UITextLayoutCanvasView
がスクリーンショットや画面録画を回避するUIViewになっているので、このUIViewの中にスクリーンショットで撮られたくないUIViewやCALayerを子ビューとして埋め込むという感じです。
実装例
先人の例を参考に、より具体的なコードを見ていきます。
こちらの記事では既存のUIViewの親CALayerに_UITextLayoutCanvasView
のlayerを追加して、そこに対象UIView自身のCALayerを入れるという方法が取られてます。
UIViewの拡張として定義されているので使い回ししやすく、コスパの良いやり方だと思います。
import UIKit
extension UIView {
func makeSecure() {
DispatchQueue.main.async {
let field = UITextField()
field.isSecureTextEntry = true
self.addSubview(field)
field.translatesAutoresizingMaskIntoConstraints = false
field.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
field.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.layer.superlayer?.addSublayer(field.layer)
field.layer.sublayers?.first?.addSublayer(self.layer)
}
}
}
下の記事ではUITextFieldから_UITextLayoutCanvasView
を取り出して独自のクラスSecureField
を定義し、これをUIViewController.view
にaddSubview
することでスクショ防止機能を実現しています。個人的にはこちらの方が直感的で好みです。
class SecureField : UITextField {
override init(frame: CGRect) {
super.init(frame: .zero)
self.isSecureTextEntry = true
self.translatesAutoresizingMaskIntoConstraints = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
weak var secureContainer: UIView? {
let secureView = self.subviews.filter({ subview in
type(of: subview).description().contains("CanvasView")
}).first
secureView?.translatesAutoresizingMaskIntoConstraints = false
secureView?.isUserInteractionEnabled = true //To enable child view's userInteraction in iOS 13
return secureView
}
override var canBecomeFirstResponder: Bool {false}
override func becomeFirstResponder() -> Bool {false}
}
class SampleViewController: UIViewController {
@IBOutlet weak var containerView: UIView!
private var imageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
imageView.image = UIImage(systemName: "camera")
imageView.contentMode = .scaleAspectFit
guard let secureView = SecureField().secureContainer else {
return
}
secureView.addSubview(imageView)
imageView.matchParent(secureView)
containerView.addSubview(secureView)
secureView.matchParent(containerView)
}
}
こちらをシミュレーターで撮影してみた様子が以下のgifです。
課題
以上で無事にスクショ防止機能が実現できてめでたしめでたし…としたいところですが課題は残っています。
まず、安定して成功確認が取れているOSバージョンが限られているという問題があります。
先程挙げたこちらの記事ではiOS13以下だとマイナーバージョンによって結果が異なるとのことです。
もう一つ問題なのが、そもそも正規のやり方ではないため、今後のOSのアップデート内容いかんによっては突然予想外の挙動になるリスクがあるということです。
とはいえ、基本的にiOS14からiOS17の間で変更点があったという経緯も無いので、万が一動きがおかしくなった時に早急に復旧できるようサーバーサイドやFirebase Remote Configなどからフラグを返すようにするといった工夫をしておけば吉かと思います。
おわりに
うっかり書くタイミングを見失ったのですが、以上をまとめて即席で実装できるOSSライブラリもありましたので、手っ取り早く試してみたい方はこちらを触ってみても良いかもしれません。
明日のAdvent Calenderもお楽しみに!