自分のアプリで本の写真をクロップして登録する機能を作りたくて実装方法を調べました。
ズーム&スクロールして固定のアスペクト比で画像をクロップしたいだけであり、勉強のためなるべく自分で実装するという方針でやっているので、ライブラリは使いませんでした。
Slackのプロフィール編集画面でアバター画像を登録する機能があるのですが、これが自分のアプリの要件にぴったりでかつ非常に使いやすいのでマネをしました。
ここではその機能の実装方法について紹介しました。
作ったもの
先に、参考にしたSlackの機能を紹介します。
大まかな特徴は以下の通りです。
- 画像の2倍のサイズまで拡大できる、縮小はできない
- クロップ範囲外は半透明のマスクがかかっていて、クロップ範囲がわかりやすい
そして、作った機能はこんな感じです。
上記2つの特徴をしっかりと反映しました。
実装詳細
今回はImageCropViewController
という名前のカスタムビューコントローラとして実装しました。
こんな感じで呼び出せるようにしています。
let image = UIImage(named: "photo")!
let vc = ImageCropViewController(image: image)
vc.delegate = self
let nav = UINavigationController(rootViewController: vc)
present(nav, animated: true)
呼び出す側のビューコントローラにはクロップした画像を受け取るためのデリゲートメソッドを実装します。
extension ViewController: ImageCropViewControllerDelegate {
func didCrop(image: UIImage) {
bookImageButton.setImage(image, for: .normal)
}
}
以下では実装全体を3つに分けて説明します。
- 画像をズームして拡大できるようにする
- クロップ範囲外に半透明のマスクをかける
- 画像をクロップする
画像をズームして拡大できるようにする
ズーム機能の実装にはUIScrollViewを使用します。
まず、何倍までズームさせるかを指定します。
ここでは2倍までズームできるようにしています。
scrollView.maximumZoomScale = 2.0
scrollView.minimumZoomScale = 1.0
次に、ビュー階層を作ります。
ズームしたいビューをスクロールビューの配下に置きます。
// ルートビュー直下にスクロールビューを配置
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
)を返すようにします。
extension ImageCropViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
ここまでで、画像をズームしてスクロールすることができるようになります。
なお、参考にした以下の記事ではStoryBoardの図で説明しているので、こっちのほうがビュー階層や制約がわかりやすいと思います。
Image crop view in iOS
クロップ範囲外に半透明のマスクをかける
今回実装した画面では、以下のように半透明の黒いマスクがかかっていて、クロップされる範囲がわかりやすくなっています。
マスク部分、白い枠線、クロップ部分はまとめて一つのビューで表現しています。
ここではそのカスタムビューの実装について見ていきます。
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
はルートビューと同じサイズの矩形を表すパスです。
この2つのパスを足し合わせ、塗りつぶしルールに.evenOdd
を指定してあげると、上図のようなマスクがかかります。
.evenOdd
については以下の記事がとてもわかりやすいです。
[iOS]CAShapeLayerの二つのfillRuleの違い(修正版)
[3]
白い枠線もCAShapeLayerとUIBezierPathで表現しました。
クロップ領域を囲うパスを作り、サブレイヤーとして追加しました。
[4]
今回はアスペクト比が幅:高=10:7
になるようにしたかったのでこのような実装になっています。
この部分を変えれば任意のアスペクト比でクロップ領域を設定できます。
[5]
クロップ領域のサイズを返すプロパティです。
このプロパティはImageCropViewController
から利用します。
このビューはスクロールビューと同階層に配置します。
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)
])
画像をクロップする
最後に画像をクロップする実装を見ていきます。
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ほどだとしたら、スケール比は約7.2になります。
イメージビューを2倍にズームしていたら幅は828になるので、スケール比は約3.6になります。
この値はあとで画像をクロップする際に使用します。
[3]
イメージビュー内における画像の位置を取得します。
元画像のアスペクト比を維持するためimageView.contentMode = .scaleAspectFit
としているので、以下のように画像は画面いっぱいには広がらず、上下にスペースができています。
このためイメージビューと画像の位置(オリジン)は異なります。
AVMakeRect(aspectRatio:insideRect:)
メソッドを使用することで、イメージビュー内における画像の位置を取得することができます。
この値もあとで画像をクロップする際に使用します。
なお、このメソッドを使用するにはAVFoundationフレームワークをインポートする必要があります。
[4]
cropping(to:)
メソッドで画像をクロップします。
このとき、引数には[2]と[3]を考慮したクロップ領域の位置とサイズを渡します。
cropAreaRect
はイメージビューに対するクロップ領域の位置ですが、前述の通りイメージビューと画像の位置が異なるので、画像に対する位置になるように補正してあげる必要があります。
さらに、クロップ領域のサイズと実際の画像のサイズのスケールが同じになるようにしてあげる必要があります。
[5]
[4]で取得したのはCGImageのインスタンスなので、UIImageに変換してデリゲートに渡します。