[iOS] ズーム＆スクロールして画像をクロップできる機能を実装する（Slackのアバター画像登録機能っぽく作ってみた）

[自分のアプリ](https://itunes.apple.com/jp/app/id1450899866)で本の写真をクロップして登録する機能を作りたくて実装方法を調べました。

ズーム＆スクロールして固定のアスペクト比で画像をクロップしたいだけであり、勉強のためなるべく自分で実装するという方針でやっているので、ライブラリは使いませんでした。

Slackのプロフィール編集画面でアバター画像を登録する機能があるのですが、これが自分のアプリの要件にぴったりでかつ非常に使いやすいのでマネをしました。

ここではその機能の実装方法について紹介しました。

## 作ったもの
先に、参考にしたSlackの機能を紹介します。

![crop.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/dced7fe6-3dc8-bbe1-2928-65885a63390b.gif)

大まかな特徴は以下の通りです。

- 画像の２倍のサイズまで拡大できる、縮小はできない
- クロップ範囲外は半透明のマスクがかかっていて、クロップ範囲がわかりやすい

そして、作った機能はこんな感じです。
上記２つの特徴をしっかりと反映しました。

![crop2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/92bfb3d5-9a83-26c7-ca91-f33fdcf98352.gif)

## 実装詳細
今回は`ImageCropViewController`という名前のカスタムビューコントローラとして実装しました。
こんな感じで呼び出せるようにしています。

```swift:ViewController.swift
let image = UIImage(named: "photo")!
let vc = ImageCropViewController(image: image)
vc.delegate = self
let nav = UINavigationController(rootViewController: vc)
present(nav, animated: true)
```

呼び出す側のビューコントローラにはクロップした画像を受け取るためのデリゲートメソッドを実装します。

```swift:ViewController.swift
extension ViewController: ImageCropViewControllerDelegate {
    func didCrop(image: UIImage) {
        bookImageButton.setImage(image, for: .normal)
    }
}
```

以下では実装全体を３つに分けて説明します。

- 画像をズームして拡大できるようにする
- クロップ範囲外に半透明のマスクをかける
- 画像をクロップする

### 画像をズームして拡大できるようにする
ズーム機能の実装にはUIScrollViewを使用します。

まず、何倍までズームさせるかを指定します。
ここでは２倍までズームできるようにしています。

```swift:ImageCropViewController.swift
scrollView.maximumZoomScale = 2.0
scrollView.minimumZoomScale = 1.0
```

次に、ビュー階層を作ります。
ズームしたいビューをスクロールビューの配下に置きます。

```swift:ImageCropViewController.swift
// ルートビュー直下にスクロールビューを配置
view.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
    scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
    scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])

// スクロールビューの下にズーム対象であるイメージビューを配置
scrollView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    imageView.topAnchor.constraint(equalTo: scrollView.topAnchor),
    imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
    imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
    imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
    imageView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
    imageView.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor)
])
```

最後に、スクロールビューのデリゲートメソッドを実装します。
ズーム対象であるイメージビュー(`UIImageView`)を返すようにします。

```swift:ImageCropViewController.swift
extension ImageCropViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }
}
```

ここまでで、画像をズームしてスクロールすることができるようになります。

なお、参考にした以下の記事ではStoryBoardの図で説明しているので、こっちのほうがビュー階層や制約がわかりやすいと思います。
[Image crop view in iOS](https://medium.com/@basselezzeddine/image-crop-view-in-ios-4125c0aafe4d)

### クロップ範囲外に半透明のマスクをかける
今回実装した画面では、以下のように半透明の黒いマスクがかかっていて、クロップされる範囲がわかりやすくなっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/76a0abd3-1268-cb1b-6f42-da2a8d16c200.png)

マスク部分、白い枠線、クロップ部分はまとめて一つのビューで表現しています。
ここではそのカスタムビューの実装について見ていきます。

```swift:CropAreaView.swift
import UIKit

class CropAreaView: UIView {
    private let borderWidth: CGFloat = 1
    private var halfBorderWidth: CGFloat { return borderWidth / 2 }
    private var doubleBorderWidth: CGFloat { return borderWidth * 2 }

    private var borderLayer: CAShapeLayer?

    override init(frame: CGRect) {
        super.init(frame: frame)
        initialize()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        initialize()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        setupCropArea()
    }

    // [1]
    private func initialize() {
        backgroundColor = .black
        alpha = 0.5
        isUserInteractionEnabled = false
    }

    private func setupCropArea() {
        setupCropAreaMask()
        setupBorder()
    }

    // [2]
    private func setupCropAreaMask() {
        let cropAreaPath = UIBezierPath(rect: CGRect(
            x: borderWidth,
            y: cropAreaPositionY + borderWidth,
            width: cropAreaWidth - doubleBorderWidth,
            height: cropAreaHeight - doubleBorderWidth))

        let outsideCropAreaPath = UIBezierPath(rect: CGRect(
            x: 0, y: 0,
            width: bounds.width,
            height: bounds.height))

        cropAreaPath.append(outsideCropAreaPath)

        let cropAreaLayer = CAShapeLayer()
        cropAreaLayer.path = cropAreaPath.cgPath
        cropAreaLayer.fillColor = UIColor.black.cgColor
        cropAreaLayer.fillRule = .evenOdd
        layer.mask = cropAreaLayer
    }

    // [3]
    private func setupBorder() {
        borderLayer?.removeFromSuperlayer()

        let border = CAShapeLayer()
        let borderPath = UIBezierPath(rect: CGRect(
            x: halfBorderWidth,
            y: cropAreaPositionY + halfBorderWidth,
            width: cropAreaWidth - borderWidth,
            height: cropAreaHeight - borderWidth))
        border.path = borderPath.cgPath
        border.lineWidth = borderWidth
        border.strokeColor = UIColor.white.cgColor
        layer.addSublayer(border)

        borderLayer = border
    }

    private var cropAreaWidth: CGFloat {
        return bounds.width
    }

    // [4]
    private var cropAreaHeight: CGFloat {
        return cropAreaWidth * 10 / 7
    }

    private var cropAreaPositionY: CGFloat {
        return (bounds.height - cropAreaHeight) / 2
    }
}

extension CropAreaView {
    // [5]
    var cropAreaRect: CGRect {
        return CGRect(
            x: 0,
            y: cropAreaPositionY,
            width: cropAreaWidth,
            height: cropAreaHeight)
    }
}
```

**[1]**
ビューの背景全体を半透明の黒にします。
また、ズームやスクロール操作を妨げないよう、`isUserInteractionEnabled = false`としてタッチに反応しないようにします。

**[2]**
CAShapeLayerとUIBezierPathを使って半透明の黒のマスクをかけます。

`cropAreaPath`は中央のクロップ領域の矩形、`outsideCropAreaPath`はルートビューと同じサイズの矩形を表すパスです。
この２つのパスを足し合わせ、塗りつぶしルールに`.evenOdd`を指定してあげると、上図のようなマスクがかかります。

`.evenOdd`については以下の記事がとてもわかりやすいです。
[[iOS]CAShapeLayerの二つのfillRuleの違い（修正版）](https://dev.classmethod.jp/smartphone/iphone/fixed-cashapelayer-difference-of-two-fillrule/)

**[3]**
白い枠線もCAShapeLayerとUIBezierPathで表現しました。
クロップ領域を囲うパスを作り、サブレイヤーとして追加しました。

**[4]**
今回はアスペクト比が`幅:高=10:7`になるようにしたかったのでこのような実装になっています。
この部分を変えれば任意のアスペクト比でクロップ領域を設定できます。

**[5]**
クロップ領域のサイズを返すプロパティです。
このプロパティは`ImageCropViewController`から利用します。

このビューはスクロールビューと同階層に配置します。

```swift:ImageCropViewController.swift
view.addSubview(cropAreaView)
cropAreaView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    cropAreaView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
    cropAreaView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
    cropAreaView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
    cropAreaView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])
```

### 画像をクロップする
最後に画像をクロップする実装を見ていきます。

```swift:ImageCropViewController.swift
guard let image = imageView.image else { return }

// [1]
let cropAreaRect = cropAreaView.convert(cropAreaView.cropAreaRect, to: scrollView)

// [2]
let imageViewScale = max(
    image.size.width / imageView.frame.width,
    image.size.height / imageView.frame.height)

// [3]
let imageOriginInImageView = AVMakeRect(aspectRatio: image.size, insideRect: imageView.frame).origin

// [4]
let cropZone = CGRect(
    x: (cropAreaRect.origin.x - imageOriginInImageView.x) * imageViewScale,
    y: (cropAreaRect.origin.y - imageOriginInImageView.y) * imageViewScale,
    width: cropAreaRect.width * imageViewScale,
    height: cropAreaRect.height * imageViewScale)

let croppedCGImage = image.cgImage?.cropping(to: cropZone)

// [5]
if let croppedCGImage = croppedCGImage {
    let croppedImage = UIImage(cgImage: croppedCGImage)
    delegate?.didCrop(image: croppedImage)
}
```

**[1]**
クロップ領域の位置とサイズを取得します。

クロップ領域の位置とサイズは`CropAreaView`の`cropAreaRect`プロパティから取得できますが、イメージビューがズームしてスクロールされている場合に対応するため、座標系をスクロールビューのものに変換しています。

**[2]**
イメージビューに対する画像のスケール比を取得します。

例えばイメージビューの幅は414だとしても、実際の画像の幅のピクセルサイズが3000ほどだとしたら、スケール比は約７.2になります。
イメージビューを２倍にズームしていたら幅は828になるので、スケール比は約3.6になります。
この値はあとで画像をクロップする際に使用します。

**[3]**
イメージビュー内における画像の位置を取得します。

元画像のアスペクト比を維持するため`imageView.contentMode = .scaleAspectFit`としているので、以下のように画像は画面いっぱいには広がらず、上下にスペースができています。
このためイメージビューと画像の位置（オリジン）は異なります。

![貼り付けた画像_2019_05_08_17_48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81715/fa8fb4c9-a7d3-fe3b-9a60-cb06b609c477.png)

`AVMakeRect(aspectRatio:insideRect:)`メソッドを使用することで、イメージビュー内における画像の位置を取得することができます。
この値もあとで画像をクロップする際に使用します。

なお、このメソッドを使用するにはAVFoundationフレームワークをインポートする必要があります。

**[4]**
`cropping(to:)`メソッドで画像をクロップします。
このとき、引数には[2]と[3]を考慮したクロップ領域の位置とサイズを渡します。

`cropAreaRect`はイメージビューに対するクロップ領域の位置ですが、前述の通りイメージビューと画像の位置が異なるので、画像に対する位置になるように補正してあげる必要があります。
さらに、クロップ領域のサイズと実際の画像のサイズのスケールが同じになるようにしてあげる必要があります。

**[5]**
[4]で取得したのはCGImageのインスタンスなので、UIImageに変換してデリゲートに渡します。

## 参考
- [Image crop view in iOS](https://medium.com/@basselezzeddine/image-crop-view-in-ios-4125c0aafe4d)
- [[iOS]CAShapeLayerの二つのfillRuleの違い（修正版）](https://dev.classmethod.jp/smartphone/iphone/fixed-cashapelayer-difference-of-two-fillrule/)
- [How to know the image size after applying aspect fit for the image in an UIImageView](https://stackoverflow.com/questions/6278876/how-to-know-the-image-size-after-applying-aspect-fit-for-the-image-in-an-uiimage)