Libra Studio Log

開発に関することやゲーム、ガジェットなどについてつらつらと書き記しています

iOSのカメラやアルバムへのアクセス許可などを一元管理するラッパークラス

f:id:daihase:20190809100049p:plain

こんばんは、daihaseです。

今夜もSwiftネタを書いてみます。 iOSアプリでカメラにアクセスしたりアルバムから写真を引っ張ってきたりする際には、カメラへのアクセス許可周りなど考慮しないといけない点がいくつかあります。

iOS7以前はカメラの使用に関しては一切の制限がありませんでした。 つまりアプリを作る側は好きにカメラアクセスのための処理を書き、それを呼び出せる状態だったのですが、iOS8以降は端末設定でその辺の機能を制御、つまりプライバシー設定的なことが可能となりました。

そのためカメラやアルバムアクセス周りを実装する開発者としては、その辺りを考慮しないとユーザーがアクセス制限をかけていたため、カメラを呼び出しても画面が真っ暗、といったことが起こってしまいます。

 

アクセス許可のステータスとしては以下の4つがあります。

 

  • authorized :許可済み(カメラや写真が利用出来る)
  • restricted :利用制限(設定 -> 一般 -> 機能制限 で利用が制限されている
  • denied :明示的拒否(設定 -> プライバシー で利用が制限されている)
  • notDetermined :許可も拒否もしていない状態

 

こうしたステータスをカメラ・写真へアクセスする箇所から毎回確認する処理を記述するのもあれなので、これらのステータス(状態チェック)をPhotoAuthorization.swift内にまとめ、カメラ・写真アクセスのためのメソッドをまとめたクラスをPhotoRequestManager.swiftとしました。

基本的にこの2つのクラスをプロジェクト内に入れておくだけで、あとはカメラや写真にアクセスしたいViewController等で決まった呼び出し方をしてやるだけで使い回せるようになります。

簡単なサンプルを書いて見ましたので参考にしてみてください。

import UIKit

class ViewController: UIViewController {
    private let photoImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))

    override func viewDidLoad() {
        super.viewDidLoad()

        // カメラ起動ボタン生成
        let cameraButton: RoundedButton = {
            let buttonX = (UIScreen.main.bounds.width / 2) - 100
            let buttonY = UIScreen.main.bounds.height - 150
            let button = RoundedButton(frame: CGRect(x: buttonX, y: buttonY, width: 200, height: 40))

            button.setTitleColor(UIColor.white, for: .normal)
            button.setTitle("カメラ起動", for: .normal)
            button.titleLabel?.font = UIFont.systemFont(ofSize: 12)
            button.addTarget(self, action: #selector(self.setImageFromCamera(_:)), for: .touchUpInside)

            return button
        }()

        // フォトライブラリー起動ボタン生成
        let galleryButton: RoundedButton = {
            let buttonX = (UIScreen.main.bounds.width / 2) - 100
            let buttonY = UIScreen.main.bounds.height - 100
            let button = RoundedButton(frame: CGRect(x: buttonX, y: buttonY, width: 200, height: 40))

            button.setTitleColor(UIColor.white, for: .normal)
            button.setTitle("フォトライブラリー起動", for: .normal)
            button.titleLabel?.font = UIFont.systemFont(ofSize: 12)
            button.addTarget(self, action: #selector(self.setImageFromPhotos(_:)), for: .touchUpInside)

            return button
        }()

        // カメラ、フォロライブラリーから取得した画像をセットするためのUIImageView
        photoImageView.contentMode = .scaleAspectFit
        photoImageView.layer.position.x = UIScreen.main.bounds.width / 2
        photoImageView.layer.position.y = (UIScreen.main.bounds.height / 2) - (photoImageView.bounds.size.height / 2)
        photoImageView.layer.borderColor = UIColor.lightGray.cgColor
        photoImageView.layer.borderWidth = 1.0

        view.addSubview(cameraButton)
        view.addSubview(galleryButton)
        view.addSubview(photoImageView)
    }

    // カメラ起動ボタンが押されると呼ばれる
    @objc fileprivate func setImageFromCamera(_ sender: Any) {
        PhotoRequestManager.requestPhotoFromCamera(self){ [weak self] result in
            switch result {
            case .success(let image):
                self?.setImage(image)
            case .faild:
                print("failed")
            case .cancel:
                break
            }
        }
    }

    // フォロライブラリー起動ボタンが押されると呼ばれる
    @objc private func setImageFromPhotos(_ sender: Any) {
        PhotoRequestManager.requestPhotoLibrary(self){ [weak self] result in
            switch result {
            case .success(let image):
                self?.setImage(image)
            case .faild:
                print("failed")
            case .cancel:
                break
            }
        }
    }

    // 取得した画像をセットするメソッド
    private func setImage(_ image: UIImage) {
        photoImageView.image = image
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

final class RoundedButton: UIButton {
    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.backgroundColor = UIColor.darkGray.cgColor
        layer.cornerRadius = frame.size.height / 2
        clipsToBounds = true
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        layer.cornerRadius = bounds.height / 2
    }

    override var isHighlighted: Bool {
        didSet {
            if isHighlighted {
                layer.backgroundColor = UIColor.darkGray.withAlphaComponent(0.8).cgColor
            } else {
                layer.backgroundColor = UIColor.darkGray.cgColor
            }
        }
    }
}

 

説明を簡単にするためにStoryboard等は使わずコードのみで今回は作ります。 カメラとフォトライブラリーを起動するためのボタンを画面にセットし、それぞれタップすると上記で説明したユーザーの端末上のアクセス許可周りをチェックした上での結果を返すメソッドを記述しています。

どちらも基本的にアクセスが許可されていて成功すると選択、または撮影した画像が返ってきて、それが画面へ表示されるようなプロジェクトとなっています。

 

次にカメラや写真へアクセスするためのメソッドをまとめたPhotoRequestManager.swiftをみてみます。

import Foundation
import UIKit

enum PhotoRequestResult {
    case success(UIImage)
    case faild(PhotoAuthorizedErrorType)
    case cancel
}

typealias PhotoResquestCompletion = ((PhotoRequestResult) -> Void)

final class PhotoRequestManager: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    fileprivate static let shared: PhotoRequestManager = PhotoRequestManager()
    fileprivate var completionHandler: (PhotoResquestCompletion)?

    // フォトライブラリ(PhotoLibrary)へアクセス
    static func requestPhotoLibrary(_ parentViewController: UIViewController, completion: PhotoResquestCompletion?) {
        shared.requestPhoto(parentViewController, sourceType: .photoLibrary, completion: completion)
    }

    // カメラ(Camera)へアクセス
    static func requestPhotoFromCamera(_ parentViewController: UIViewController, completion: PhotoResquestCompletion?) {
        shared.requestPhoto(parentViewController, sourceType: .camera, completion: completion)
    }

    // フォトアルバム(SavedPhotosAlbum)へアクセス
    static func requestPhotoFromSavedPhotosAlbum(_ parentViewController: UIViewController, completion: PhotoResquestCompletion?) {
        shared.requestPhoto(parentViewController, sourceType: .savedPhotosAlbum, completion: completion)
    }

    private func requestPhoto(_ parentViewController: UIViewController, sourceType: UIImagePickerControllerSourceType , completion: PhotoResquestCompletion?) {
        if !UIImagePickerController.isSourceTypeAvailable(sourceType) {
            completion?(PhotoRequestResult.faild(.denied))
            return
        }

        let resultBlock: PhotoAuthorizedCompletion = { [unowned self] result in
            switch result {
            case .success:
                let imagePickerController : UIImagePickerController = UIImagePickerController()
                imagePickerController.sourceType = sourceType
                imagePickerController.allowsEditing = true
                imagePickerController.delegate = self

                self.completionHandler = completion

                parentViewController.present(imagePickerController, animated: true, completion: nil)
            case .error(let error):
                completion?(PhotoRequestResult.faild(error))
            }
        }

        switch sourceType {
        case .camera:
            PhotoAuthorization.camera(completion: resultBlock)
        case .photoLibrary, .savedPhotosAlbum:
            PhotoAuthorization.photo(completion: resultBlock)
        }
    }
}

// MARK: - UIImagePickerControllerDelegate
extension PhotoRequestManager {
    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        guard let image = info[UIImagePickerControllerEditedImage] as? UIImage else { return }
        picker.dismiss(animated: true) { [unowned self] in
            self.completionHandler?(PhotoRequestResult.success(image))
            self.completionHandler = nil
        }
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true) { [unowned self] in
            self.completionHandler?(PhotoRequestResult.cancel)
            self.completionHandler = nil
        }
    }
}

 

呼び出し元(ここではViewController)から渡ってきた取得したい写真の取得先を元に処理を分けている感じです。取得先はフォトライブラリ(PhotoLibrary)カメラ(Camera)フォトアルバム(SavedPhotosAlbum)の3種類となります。この3種類の呼び出しタイプによってアクセス許可を見に行き、その結果に応じて処理を再び呼び出し元へ返します。

fileprivate var completionHandler: (PhotoResquestCompletion)? 、これに呼び出し元から渡ってきたクロージャーが格納され、アクセス許可の結果を引数にセットし再び呼び出し元へ返す、というような流れになっています。

UIImagePickerControllerDelegateをこのクラス内にもたせているので、ここで成功時の取得した画像や失敗時の結果を呼び出し元へ返していますね。

 

では最後に、アクセス許可を管理するPhotoAuthorization.swiftを見てみます。

import Foundation
import UIKit
import AVFoundation
import Photos

enum PhotoAuthorizedErrorType {
    // 利用制限
    case restricted
    // 明示的拒否
    case denied
}

enum PhotoAuthorizedResult {
    case success
    case error(PhotoAuthorizedErrorType)
}

typealias PhotoAuthorizedCompletion = ((PhotoAuthorizedResult) -> Void)

final class PhotoAuthorization {
    private init() {}

    static func media(mediaType: String, completion: PhotoAuthorizedCompletion?) {
        let status = AVCaptureDevice.authorizationStatus(for: AVMediaType(rawValue: mediaType))
        switch status {
        case .authorized:
            // 許可済み
            completion?(PhotoAuthorizedResult.success)
        case .restricted:
            // 利用制限(設定 -> 一般 -> 機能制限 で利用が制限されている)
            completion?(PhotoAuthorizedResult.error(.restricted))
        case .denied:
            // 明示的拒否(設定 -> プライバシー で利用が制限されている)
            completion?(PhotoAuthorizedResult.error(.denied))
        case .notDetermined:
            // 許可も拒否もしていない状態
            AVCaptureDevice.requestAccess(for: AVMediaType(rawValue: mediaType)) { granted in
                DispatchQueue.main.async() {
                    if granted {
                        // 許可
                        completion?(PhotoAuthorizedResult.success)
                    } else {
                        // 拒否
                        completion?(PhotoAuthorizedResult.error(.denied))
                    }
                }
            }
        }
    }

    static func camera(completion: PhotoAuthorizedCompletion?) {
        self.media(mediaType: AVMediaType.video.rawValue, completion: completion)
    }

    static func photo(completion: PhotoAuthorizedCompletion?) {
        switch PHPhotoLibrary.authorizationStatus() {
        case .authorized:
             // 許可済み
            completion?(PhotoAuthorizedResult.success)
        case .restricted:
            // 利用制限(設定 -> 一般 -> 機能制限 で利用が制限されている)
            completion?(PhotoAuthorizedResult.error(.restricted))
        case .denied:
            // 明示的拒否(設定 -> プライバシー で利用が制限されている)
            completion?(PhotoAuthorizedResult.error(.denied))
        case .notDetermined:
            // 許可も拒否もしていない状態
            PHPhotoLibrary.requestAuthorization() { status in
                DispatchQueue.main.async() {
                    if status == PHAuthorizationStatus.authorized {
                        // 許可
                        completion?(PhotoAuthorizedResult.success)
                    } else {
                        // 拒否
                        completion?(PhotoAuthorizedResult.error(.denied))
                    }
                }
            }
        }
    }
}

 

カメラ・写真とそれぞれアクセスしてきた際に、ユーザーが設定したアクセス許可設定を見て、その結果に応じて処理を返しています。先ほどのPhotoRequestManagerが今度は呼び出し元になる感じですね。PhotoAuthorizedCompletion型のresultBlockというクロージャーが渡ってくるので、それにアクセス許可設定の結果を引数にセットし再び呼び出し元へ、という流れです。

 

こちらにプロジェクトのサンプルを上げておきます。

github.com

PhotoRequestManager.swift」「PhotoAuthorization.swift」2ファイルをそのまま自プロジェクトに持って行って使っもらえれば、簡単に全アクセス許可周りを一元管理出来るようになるのでよかったら是非〜

では良い開発ライフを〜