[Swift]SNS風プロフィール画像設定時、円形に切り抜く方法

# はじめに

SNSのようなプロフィール画像を設定する時に、丸く写真を切り抜きたいことが多いと思います。

ライブラリを使って楽に実装できたので備忘録として残しておこうと思います。

実際の画面はこのような感じです

#### ライブラリから
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112697/0271c8be-b3a8-a4f1-da48-43cbad7041cf.gif" width="200">



#### カメラ撮影から
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112697/35a0816d-3eb3-69b1-f002-ffa09e2e573f.gif" width="200">


# 使用ライブラリ

今回はこちらのライブラリを使用しました。

https://github.com/ruslanskorb/RSKImageCropper

Objective-Cで書かれており、カスタマイズをしないなら十分使えそうです。

Podでインポートしました。

```ruby:Podfile
  pod 'RSKImageCropper'
  pod 'RxSwift'
  pod 'RxCocoa'
 
```
※RxSwiftを使用しています

# 注意点
実機のカメラ機能を試す場合は、Info.plistで下記を追加するのを忘れずに
![スクリーンショット 2022-07-25 12.05.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112697/479ea58f-e78d-198d-94df-645aa2f28004.png)
# ソースコード

### ViewController

```swift:ProfileImageViewController.swift

import UIKit
import RSKImageCropper
import RxSwift

final class ProfileImageViewController: UIViewController {
    
    @IBOutlet weak var nameTextField: UITextField!
    @IBOutlet weak var profileImage: UIImageView!
    @IBOutlet weak var addImageButton: UIButton!
    
    private let viewModel: ViewModelType = ViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        profileImage.layer.cornerRadius = 60
        inputBind()
        outputBind()
    }
    
    private func inputBind() {
        addImageButton.rx.tap.bind { [weak self] in
            let controller = UIAlertController(title: .none, message: .none, preferredStyle: .actionSheet)
            let camera = UIAlertAction(title: "写真を撮る", style: .default, handler: { [weak self]_ in
                self?.addCameraView()
            })
            let library = UIAlertAction(title: "ライブラリから選択", style: .default, handler: { [weak self]_ in
                self?.addImagePickerView()
            })
            let cancel = UIAlertAction(title: "キャンセル", style: .cancel, handler: nil)
            controller.addAction(camera)
            controller.addAction(library)
            controller.addAction(cancel)
            self?.present(controller, animated: true)
        }.disposed(by: disposeBag)
    }
    
    private func outputBind() {
        viewModel.output.successImageSet.bind { [weak self] image in
            self?.dismiss(animated: true, completion: nil)
            self?.setCrop(image: image)
        }
    }
}

//MARK: - RSKImageCropper
extension ProfileImageViewController: RSKImageCropViewControllerDelegate {
    
    private func setCrop(image: UIImage) {
        let imageCropVC = RSKImageCropViewController(image: image, cropMode: .circle)
        imageCropVC.moveAndScaleLabel.text = "切り取り範囲を選択"
        imageCropVC.cancelButton.setTitle("キャンセル", for: .normal)
        imageCropVC.chooseButton.setTitle("完了", for: .normal)
        imageCropVC.delegate = self
        present(imageCropVC, animated: true)
    }
    
    //キャンセルを押した時の処理
     func imageCropViewControllerDidCancelCrop(_ controller: RSKImageCropViewController) {
        dismiss(animated: true, completion: nil)
    }
    
    //完了を押した後の処理
     func imageCropViewController(_ controller: RSKImageCropViewController, didCropImage croppedImage: UIImage, usingCropRect cropRect: CGRect, rotationAngle: CGFloat) {
        dismiss(animated: true)
        profileImage.image = croppedImage
    }
}

//MARK: - UIImagePicker
extension ProfileImageViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate{

    // カメラの利用
    private func addCameraView() {

        // シミュレーターでカメラを使用するとアラート表示させる
        if !UIImagePickerController.isSourceTypeAvailable(.camera) {
            let alertController = UIAlertController.init(title: nil, message: "Device has no camera.", preferredStyle: .alert)
            let okAction = UIAlertAction.init(title: "Alright", style: .default, handler: {(alert: UIAlertAction!) in
            })
            alertController.addAction(okAction)
            self.present(alertController, animated: true, completion: nil)
        } else {
            //imagePickerViewを表示する
            let pickerController = UIImagePickerController()
            pickerController.sourceType = .camera
            pickerController.delegate = self
            self.present(pickerController, animated: true, completion: nil)
        }
    }

    // ライブラリーの利用
    private func addImagePickerView() {
        //imagePickerViewを表示する
        let pickerController = UIImagePickerController()
        pickerController.sourceType = .photoLibrary
        pickerController.delegate = self
        pickerController.modalPresentationStyle = .fullScreen
        self.present(pickerController, animated: true, completion: nil)
    }

    // pickerの選択がキャンセルされた時の処理
     func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
         dismiss(animated: true, completion: nil)
    }
    // 画像が選択(撮影)された時の処理
     func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        guard let selectedImage = info[UIImagePickerController.InfoKey.originalImage] as! UIImage? else {return}
        viewModel.input.setCrop(image: selectedImage)
        
    }
}


```
### ViewModel
```swift: ViewModel.swift
import Foundation
import RxSwift
import RxCocoa

protocol ViewModelInput {
   func setCrop(image: UIImage)
}

protocol ViewModelOutput {
    var successImageSet: PublishRelay<UIImage> { get }
}

protocol ViewModelType {
  var input: ViewModelInput { get }
  var output: ViewModelOutput { get }
}

final class ViewModel: ViewModelInput, ViewModelOutput, ViewModelType {
    var input: ViewModelInput { return self }
    var output: ViewModelOutput { return self }
    
    //input
    func setCrop(image: UIImage) {
        successImageSet.accept(image)
    }
    
    //output
    var successImageSet = PublishRelay<UIImage>()
}

```

# 解説

まず、ViewControllerでViewModelを定義します。
inputとoutputを明確に分けたいので、ViewModelTypeに準拠させます。
```swift:   
private let viewModel: ViewModelType = ViewModel()
private let disposeBag = DisposeBag()
```

そしてカメラとライブラリを起動させるため、extensionでUIImagePickerControllerDelegate, UINavigationControllerDelegateを準拠させます。

```swift:   
extension ProfileImageViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate{

    // カメラの利用
    private func addCameraView() {

        // シミュレーターでカメラを使用するとアラート表示させる
        if !UIImagePickerController.isSourceTypeAvailable(.camera) {
            let alertController = UIAlertController.init(title: nil, message: "Device has no camera.", preferredStyle: .alert)
            let okAction = UIAlertAction.init(title: "Alright", style: .default, handler: {(alert: UIAlertAction!) in
            })
            alertController.addAction(okAction)
            self.present(alertController, animated: true, completion: nil)
        } else {
            //imagePickerViewを表示する
            let pickerController = UIImagePickerController()
            pickerController.sourceType = .camera
            pickerController.delegate = self
            self.present(pickerController, animated: true, completion: nil)
        }
    }

    // ライブラリーの利用
    private func addImagePickerView() {
        //imagePickerViewを表示する
        let pickerController = UIImagePickerController()
        pickerController.sourceType = .photoLibrary
        pickerController.delegate = self
        pickerController.modalPresentationStyle = .fullScreen
        self.present(pickerController, animated: true, completion: nil)
    }

    // pickerの選択がキャンセルされた時の処理
     func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
         dismiss(animated: true, completion: nil)
    }
    // 画像が選択(撮影)された時の処理
     func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        guard let selectedImage = info[UIImagePickerController.InfoKey.originalImage] as! UIImage? else {return}
        viewModel.input.setCrop(image: selectedImage)
        
    }
}
```
シュミレーターでカメラを起動するとクラッシュしてしまうので、シュミレーターで起動した場合はアラートが出るようにしてあります。

そして「アイコン画像を設定」ボタンを押すとカメラorライブラリか選ぶアクションシートを出します。

```swift:   
private func inputBind() {
        addImageButton.rx.tap.bind { [weak self] in
            let controller = UIAlertController(title: .none, message: .none, preferredStyle: .actionSheet)
            let camera = UIAlertAction(title: "写真を撮る", style: .default, handler: { [weak self]_ in
                self?.addCameraView()
            })
            let library = UIAlertAction(title: "ライブラリから選択", style: .default, handler: { [weak self]_ in
                self?.addImagePickerView()
            })
            let cancel = UIAlertAction(title: "キャンセル", style: .cancel, handler: nil)
            controller.addAction(camera)
            controller.addAction(library)
            controller.addAction(cancel)
            self?.present(controller, animated: true)
        }.disposed(by: disposeBag)
    }
```
addCameraView()　
addImagePickerView()
どちらかで分岐させます。

そして写真撮影,ライブラリどちらかで写真が選択された時に呼ばれるコードが下記です。

```swift:   
// 画像が選択(撮影)された時の処理
     func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        guard let selectedImage = info[UIImagePickerController.InfoKey.originalImage] as! UIImage? else {return}
        viewModel.input.setCrop(image: selectedImage)
    }
```
ここでviewModelのsetCropメソッドに,選択された写真を渡しつつ呼び出していますね。

ViewModelを見て見ましょう。

```swift:  
　　　　　　　　//input
    func setCrop(image: UIImage) {
        successImageSet.accept(image)
    }
    
    //output
    var successImageSet = PublishRelay<UIImage>()

```
setCropメソッドで渡ってきたselectedImageをoutputのsuccessImageSetに流しています。

そしてViewControllerのoutputBindでバインディング、受け取ります。

```swift:  
private func outputBind() {
        viewModel.output.successImageSet.bind { [weak self] image in
            self?.dismiss(animated: true, completion: nil)
            self?.setCrop(image: image)
        } 
```
一度画面を閉じて、そこから画像カットをカットするためのメソッドを呼び出して選択された画像を渡し

```swift:  
extension ProfileImageViewController: RSKImageCropViewControllerDelegate {
    
    private func setCrop(image: UIImage) {
        let imageCropVC = RSKImageCropViewController(image: image, cropMode: .circle)
        imageCropVC.moveAndScaleLabel.text = "切り取り範囲を選択"
        imageCropVC.cancelButton.setTitle("キャンセル", for: .normal)
        imageCropVC.chooseButton.setTitle("完了", for: .normal)
        imageCropVC.delegate = self
        present(imageCropVC, animated: true)
    }
    
    //キャンセルを押した時の処理
     func imageCropViewControllerDidCancelCrop(_ controller: RSKImageCropViewController) {
        dismiss(animated: true, completion: nil)
    }
    
    //完了を押した後の処理
     func imageCropViewController(_ controller: RSKImageCropViewController, didCropImage croppedImage: UIImage, usingCropRect cropRect: CGRect, rotationAngle: CGFloat) {
        dismiss(animated: true)
        profileImage.image = croppedImage
    }
}
```

キャンセルが押された時、完了を押した時の処理を記載します。
画面に配置したprofileImageにcroppedImage（カットしたimage）を渡して画面を閉じてあげれば終了です。



最後にリポジトリも上げておきますので、サッと見たい方はこちらから↓

https://github.com/taro-ken/SNSprofileImage-Sample


# 終わりに
RxSwiftを使用したので、若干複雑になってしまいましたが流れは追えたかなと思います。
カットするだけなら全然使えるライブラリではないかなと感じました。

写真を設定した後、firestoreに保存する所まで記載しようと思いましたが長くなりそうだったので次回に書きたいと思います！
修正点などあればご教授いただけると嬉しいです。