LoginSignup
8
4

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-10-13

こんにちは :octocat:

カメラで撮った写真やアルバムの画像を正方形で自由に切り抜けるビューの需要があったので自作してみました。
UIKitに既にあるこのような要件を満たすコンポーネントには UIImagePickerController がありますが、これを使って撮影した写真の切り抜き範囲に問題があり、写真の端っこを含めることができないようです。(参考

大事なところ

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

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

UIScrollViewとUIImageViewをコードで生成

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

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

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

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

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

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から切り取ります。
その後、画像生成用のコンテキスト内に描画してあげます。

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

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4