UIScrollViewとUIImageViewでパン、ズームしたりした画像を正方形に切り抜く

こんにちは :octocat:

カメラで撮った写真やアルバムの画像を正方形で自由に切り抜けるビューの需要があったので自作してみました。
UIKitに既にあるこのような要件を満たすコンポーネントには `UIImagePickerController` がありますが、これを使って撮影した写真の切り抜き範囲に問題があり、写真の端っこを含めることができないようです。（[参考](https://stackoverflow.com/questions/12630155/uiimagepicker-allowsediting-stuck-in-center)）

# 大事なところ

* `UIView#convert(_:from:)` メソッドでViewとViewの重なっている範囲を取得できる
* `UIGraphicsBeginImageContext` でコンテキストを作成してUIImageViewの現在表示範囲でクロップ

（下記コード内で一部Extensionでメソッド生やしたりしているところがあります、宣言内容について詳しくはページ最後にあるGitHubへのリンクから参照してください。）

# UIScrollViewとUIImageViewをコードで生成

プロパティの設定がめんどいので設定済みのUIScrollViewのサブクラスを作っておきます

```swift
class CropScrollView: UIScrollView {
    override init(frame: CGRect) {
        super.init(frame: frame)

        backgroundColor = .black
        clipsToBounds = false
        showsVerticalScrollIndicator = false
        showsHorizontalScrollIndicator = false
        layer.borderWidth = 1
        layer.borderColor = UIColor.lightGray.cgColor
    }

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

今回切り取りは正方形固定で行うので、画面上のそれ以外の部分をグレーで覆うビューも作っておきます。

```swift
class CropOverlayView: UIView {
    init() {
        let screenWidth = UIScreen.main.bounds.width
        let screenHeight = UIScreen.main.bounds.height

        super.init(frame: CGRect(x: 0, y: 0, width: screenWidth, height: screenHeight))

        isUserInteractionEnabled = false

        let overlayViewHeight = (screenHeight - screenWidth) / 2

        let upper = UIView(frame: CGRect(x: 0, y: 0, width: screenWidth, height: overlayViewHeight))
        let lower = UIView(frame: CGRect(x: 0, y: screenWidth + overlayViewHeight, width: screenWidth, height: overlayViewHeight))

        upper.isUserInteractionEnabled = false
        lower.isUserInteractionEnabled = false

        upper.backgroundColor = UIColor.ex.cropViewOverlay
        lower.backgroundColor = UIColor.ex.cropViewOverlay

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

ここまで準備ができたら、クロップ用のビューコントローラを作っていきます。

```swift
import UIKit

protocol CropViewControllerDelegate: class {
    func cropViewControllerDidFinishTask(_ image: UIImage)
}

class CropViewController: UIViewController, UIScrollViewDelegate {
    private var scrollView: UIScrollView!
    private var imageView: UIImageView!
    private var image: UIImage!
    weak var delegate: CropViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()

        // ImageViewを準備
        imageView = UIImageView(image: image)

        // ScrollViewを準備
        scrollView = CropScrollView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width))
        scrollView.center = view.center
        scrollView.delegate = self

        // ImageViewとScrollViewをビューに追加
        scrollView.addSubview(imageView)
        view.addSubview(scrollView)

        // 最初は写真全体を表示
        setZoomScale()

        // 切り取り領域ビューの準備
        view.addSubview(CropOverlayView())

        // 決定ボタンを準備
        let confirmButton = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
        confirmButton.setTitle("決定", for: .normal)
        confirmButton.setTitleColor(.white, for: .normal)
        confirmButton.addTarget(self, action: #selector(didTapConfirmButton), for: .touchUpInside)
        view.addSubview(confirmButton)
        confirmButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint(item: confirmButton, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100).isActive = true
        NSLayoutConstraint(item: confirmButton, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 66).isActive = true
        NSLayoutConstraint(item: confirmButton, attribute: .right, relatedBy: .equal, toItem: view, attribute: .right, multiplier: 1, constant: -8).isActive = true
        NSLayoutConstraint(item: confirmButton, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottom, multiplier: 1, constant: -8).isActive = true

        // キャンセルボタンを準備
        let cancelButton = UIButton(frame: CGRect(x: 0, y: 0, width: 44, height: 44))
        cancelButton.setTitle("キャンセル", for: .normal)
        cancelButton.setTitleColor(.white, for: .normal)
        cancelButton.addTarget(self, action: #selector(dismissSelf), for: .touchUpInside)
        view.addSubview(cancelButton)
        cancelButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint(item: cancelButton, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100).isActive = true
        NSLayoutConstraint(item: cancelButton, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 66).isActive = true
        NSLayoutConstraint(item: cancelButton, attribute: .left, relatedBy: .equal, toItem: view, attribute: .left, multiplier: 1, constant: 8).isActive = true
        NSLayoutConstraint(item: cancelButton, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottom, multiplier: 1, constant: -8).isActive = true
    }

    func prepareView(image: UIImage) {
        self.image = image
    }

    func didTapConfirmButton() {
        // TODO
    }

    func dismissSelf() {
        dismiss(animated: true)
    }

    private func setZoomScale() {
        let imageViewSize = imageView.bounds.size
        let scrollViewSize = scrollView.bounds.size
        let widthScale = scrollViewSize.width / imageViewSize.width
        let heightScale = scrollViewSize.height / imageViewSize.height
        
        scrollView.minimumZoomScale = min(widthScale, heightScale)
        scrollView.zoomScale = min(widthScale, heightScale)
    }

    // MARK: - UIScrollViewDelegate

    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        let imageViewSize = imageView.frame.size
        let scrollViewSize = scrollView.bounds.size

        let verticalPadding = imageViewSize.height < scrollViewSize.height ? (scrollViewSize.height - imageViewSize.height) / 2 : 0
        let horizontalPadding = imageViewSize.width < scrollViewSize.width ? (scrollViewSize.width - imageViewSize.width) / 2 : 0

        scrollView.contentInset = UIEdgeInsets(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding)
    }

    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
}
```

これで、 `prepareView(image:)` を使って `UIImage` をなげつけるとその画像を自由にパンしたりズームしたりできる画面ができるはずです。

# クロップ処理

スクロールビューの現在の表示領域を `scrollView.bounds` で取得して、その領域をImageViewから切り取ります。
その後、画像生成用のコンテキスト内に描画してあげます。

```swift
func didTapConfirmButton() {
    // クロップ
    let width = scrollView.bounds.width
    let height = width
    let x = scrollView.bounds.origin.x
    let y = scrollView.bounds.origin.y
    let cropBounds = CGRect(x: x, y: y, width: width, height: height)
    let visibleRect = imageView.convert(cropBounds, from: scrollView)

    // 画像生成
    UIGraphicsBeginImageContext(visibleRect.size)
    let drawRect = CGRect(x: -visibleRect.origin.x, y: -visibleRect.origin.y, width: image!.size.width, height: image!.size.height)
    image!.draw(in: drawRect)
    let croppedImage = UIGraphicsGetImageFromCurrentImageContext()!
    UIGraphicsEndImageContext()

    // 呼び出し元に渡す
    delegate?.cropViewControllerDidFinishTask(croppedImage)
    dismiss(animated: true)
}
```

# Code

今回制作したビューを含めた全コードはこちらに掲載しています。

keisei1092/CropViewControllerPractice
https://github.com/keisei1092/CropViewControllerPractice