画像処理
UIImage
Swift
UIImagePickerController
ios11

ジェスチャーを使ってUIImageを正確に切り取る画面を作る

概要

ジェスチャーを使ってUIImageを切り抜く編集画面を実装しました。
CropMovie.gif

なぜ?

UIImagePickerControllerがiOS11以降バグがありallowsEditingでは正確に画像を切り出せなくなったため。
https://forums.developer.apple.com/thread/98274

In iOS 10.x this works fine. However, in iOS 11.x the cropping square is positioned too low in the view, such that the user thinks they are cropping a region lower than the they actually are. The effect is that the cropped image appears to be offset higer from what was apparently cropped.

【bugのデモ動画】
https://www.dropbox.com/s/4csofidjcrc9ah6/UIImagePickerControllerEditedImageOffset.mp4?dl=0

設計

Viewの構成は以下の通り。
スクリーンショット 2018-10-12 14.04.13.png

RootView
├ ScrollView
│ └ ImageView
├ UpperView
├ LowerView
├ FrameView
└ ToolBarView
  ├ 完了Button
  └ キャンセルButton

ポイントは以下の通り
1. UpperView,LowerView,FrameViewは isUserInteractionEnabled = false に設定する
2. ScrollViewはContentInsetとしてTopにUpperView,BottomにLowerView分のスペースを設定する。かつ、imageのサイズがFrameView(切り取る範囲)以下になった場合、その分contentInsetに追加する
3. ScrollViewの contentInsetAdjustmentBehavior = .never にしておく(こいつが結構悪さをします)
4. Viewの重なり部分の範囲をとるために .convert(_:to:) を使います
5. Imageをクロップする時は CGImage.cropping(to:) を使わず、 UIGraphicsBeginImageContext を使って書き出す

これらのポイントについて詳しく説明していきます。

説明

ポイント1,2

1,2を実装することで画面全体をスクロールして画像の位置、スクロール調整ができるようになります。

1.UpperView,LowerView,FrameViewはisUserInteractionEnabledをfalseに設定する。
これによりこの3つはタッチの判定を上の階層のViewに透過するようになり、ScrollViewのタッチできるようになります

upperView.isUserInteractionEnabled = false
lowerView.isUserInteractionEnabled = false
frameView.isUserInteractionEnabled = false

2.ScrollViewはContentInsetとしてTopにUpperView,BottomにLowerView分のスペースを設定する。かつ、imageのサイズがFrameView(切り取る範囲)以下になった場合、その分contentInsetに追加する
こうすることで画面全体をスクロール領域にでき、またズームした際にframeViewより小さくなってしまった場合でも常にframeViewの中央に画像が配置されるようになります。
下の図はheightが小さくなった時しか説明していませんがwidthが小さくなった時も同様です。

private func updateContentInset() {
    let imageViewSize = imageView.frame.size
    let frameViewSize = frameView.bounds.size

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

    scrollView.contentInset = UIEdgeInsets(top: frameView.frame.minY + verticalPadding, left: horizontalPadding, bottom: scrollView.bounds.size.height - frameView.frame.maxY + verticalPadding, right: horizontalPadding)
}

スクリーンショット 2018-10-12 13.25.26.png

ポイント3

画像を拡大する時などにcontendInsetsを自前で計算して処理しているので、contentInsetAdjustmentBehaviorを.automaticにしているとよくわからない処理が走ってたまにずれが生じます。
僕はここに結構引っかかって時間をとられました。

3.ScrollViewのcontentInsetAdjustmentBehaviorは.neverにしておく

if #available(iOS 11.0, *) {
    scrollView.contentInsetAdjustmentBehavior = .never
}

ポイント4

4ではImageView上の座標系をScrollViewの座標系に変換して見えている範囲を取得しています。座標変換系の話はここですると長くなるので、こちらの記事などをみて理解していただけると。

4.Viewの重なり部分の範囲をとるために.convertを使います

let width = frameView.bounds.width
let height = width
let x = scrollView.bounds.origin.x
let y = scrollView.bounds.origin.y + frameView.frame.minY
let cropBounds = CGRect(x: x, y: y, width: width, height: height)
let visibleRect = imageView.convert(cropBounds, from: scrollView)

ポイント5

CGImageはorientation(画像の向き)情報を持っていないので、CGImage.cropping(to:)を使おうとするとorientationが.up(default)の画像以外はorientationの計算が必要になります。その点UIGraphicsBeginImageContextを使って書き出すとorientation情報を保持した状態で画像書き出しができるので余計な計算をしなくてすみます。

5.Imageをクロップする時はCGImage.cropping(to:)を使わず、UIGraphicsBeginImageContextを使って書き出す

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()

実装

CropEditViewController.swift
import Foundation

class CropEditViewController: UIViewController {

    @IBOutlet weak var scrollView: UIScrollView!
    @IBOutlet weak var frameView: UIView! {
        didSet {
            frameView.layer.borderColor = UIColor.Ameba.lightGray.cgColor
            frameView.layer.borderWidth = 1
        }
    }
    @IBOutlet weak var upperView: UIView! 
    @IBOutlet weak var lowerView: UIView!
    private var imageView = UIImageView(image: nil)
    private var image: UIImage?
    private var completion: ((UIImage?) -> Void)?

    override var prefersStatusBarHidden: Bool {
        return true
    }

    static func instantiate(image: UIImage?, completion: @escaping (UIImage?) -> Void) -> CropEditViewController {
        let vc = UIStoryboard(name: "CropEdit", bundle: nil).instantiateInitialViewController() as! CropEditViewController
        vc.image = image
        vc.completion = completion
        return vc
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        updateContentInset()
    }

    private func setup() {
        navigationController?.isNavigationBarHidden = true
        imageView = UIImageView(image: image)
        scrollView.delegate = self

        // ポイント1
        upperView.isUserInteractionEnabled = false
        lowerView.isUserInteractionEnabled = false
        frameView.isUserInteractionEnabled = false

        // ポイント2
        if #available(iOS 11.0, *) {
            scrollView.contentInsetAdjustmentBehavior = .never
        }
        scrollView.addSubview(imageView)
        imageView.image = image

        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)
    }

    @IBAction func didTapConfirmButton() {
        let croppedImage = cropImage()
        completion?(croppedImage)
    }

    @IBAction func didTapCancelButton(_ sender: UIButton) {
        completion?(nil)
    }

    private func cropImage() -> UIImage? {
        guard let image = image else { return nil }

        // ポイント4
        let width = frameView.bounds.width
        let height = width
        let x = scrollView.bounds.origin.x
        let y = scrollView.bounds.origin.y + frameView.frame.minY
        let cropBounds = CGRect(x: x, y: y, width: width, height: height)
        let visibleRect = imageView.convert(cropBounds, from: scrollView)

        // ポイント5
        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()
        return croppedImage
    }

    private func updateContentInset() {
        // ポイント3
        let imageViewSize = imageView.frame.size
        let frameViewSize = frameView.bounds.size

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

        scrollView.contentInset = UIEdgeInsets(top: frameView.frame.minY + verticalPadding, left: horizontalPadding, bottom: scrollView.bounds.size.height - frameView.frame.maxY + verticalPadding, right: horizontalPadding)
    }
}

// MARK: - UIScrollViewDelegate
extension CropEditViewController: UIScrollViewDelegate {
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateContentInset()
    }

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

利用する時は以下のように利用します。

let vc = CropEditViewController.instantiate(image: image) { image in
    // 完了した時の処理 image:切り出されたUIImage
}

おわりに

今回はジェスチャーを作って正確に画像を切り抜く編集画面を作りました。
UIImagePickerのバグ治るまではこちらを使って対応したいとおもいます。
バグがなければこのような実装をする必要はないのですが、おかげでScrollViewのcontentInset周りや座標変換周りの理解が深まったのでそれはそれでよしとします!
もしそのあたりで困っている人にこの記事が役にたてば幸いです。