一、实现讲解
本文实现了一个基本的照片裁剪页面,包含取消、还原、保存等操作,无操作时的镂空遮罩展示等。Demo 在文末可以参考。
裁剪有两个元素,即照片和裁剪框。交互一般分为三种:
- 裁剪框和图片,都可以移动或缩放;
- 裁剪框保持固定,图片可以移动或缩放;
- 裁剪框可以移动或缩放,图片保持固定;
第一种是苹果相册的原生交互,第二种也相对主流,第三种比较少见。
本文实现的是第二种,即裁剪框保持固定,图片移动或缩放。
可以对代码进行扩展,原理都是对手势的处理和裁剪区域坐标计算。
1.1 图片的移动处理
给 UIImageView 添加 UIPanGestureRecognizer 手势:
imageView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onPan)))
对拖动手势进行处理:
@objc func onPan(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
break
case .changed, .ended:
// 相对于当前照片的偏移量
let translation = recognizer.translation(in: recognizer.view!)
// 修改照片的 center
let newCenter = CGPoint(x: recognizer.view!.center.x + translation.x, y: recognizer.view!.center.y + translation.y)
recognizer.view!.center = newCenter
// 每次拖动都是递增的,所以需要设置归零
recognizer.setTranslation(.zero, in: recognizer.view!)
break
case .cancelled, .possible, .failed:
break
@unknown default:
break
}
}
如此,手势拖动后图片就可以跟着手势移动。
1.2 图片的缩放处理
给 UIImageView 添加 UIPinchGestureRecognizer 手势:
imageView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(onPinch)))
对缩放手势进行处理:
@objc func onPinch(recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
break
case .changed, .ended:
// 缩放 recognizer.scale 倍,如此 frame 就会变化,包括x、y、width、height
recognizer.view!.transform = CGAffineTransformScale(recognizer.view!.transform, recognizer.scale, recognizer.scale)
// 设置归零
recognizer.scale = 1
break
case .cancelled, .possible, .failed:
break
@unknown default:
break
}
}
如此,手势缩放后图片就可以跟着手势缩放。
1.3 图片范围限制的实现
图片可以移动或缩放,会出现在页面任何地方。
一般都会对图片的位置进行限制,比如图片必须包含裁剪框,即裁剪框中必须填满图片的内容,没有空白的地方。
按这个逻辑,图片的初始位置和上一个位置肯定是符合要求的,所以我们可以在位置符合时保存需要的信息,手势结束时进行判断,是否需要恢复到上一个位置。
- 判断图片位置是否符合要求
使用下面的方法,可以判断照片控件 imageView 是否包含裁剪框 frameView。
CGRectContainsRect(imageView.frame, frameView.frame)
- 定义变量保存变动
private var imageViewLastCenter = CGPoint.zero
private var imageViewLastTransform: CGAffineTransform = CGAffineTransform.identity
- 手势触发时处理逻辑
在手势触发 changed 时,如果位置合适则保存到变量中。
if CGRectContainsRect(imageView.frame, frameView.frame) {
imageViewLastCenter = recognizer.view!.center
// or
imageViewLastTransform = recognizer.view!.transform
}
在手势触发 ended 时,如果位置不合适则回滚位置。
if !CGRectContainsRect(imageView.frame, frameView.frame) {
recognizer.view!.center = imageViewLastCenter
// or
recognizer.view!.transform = imageViewLastTransform
}
1.4 还原操作的处理
图片的操作只是修改 center 和 transform,所以只需要还原这两个即可。
imageView.center = view.center
imageView.transform = .identity
imageViewLastCenter = imageView.center
imageViewLastTransform = imageView.transform
1.5 保存操作的处理
- 计算裁剪框相对于照片控件的位置;
- 计算照片和照片控件的比例;
- 计算裁剪区域相对于照片的位置;
- 裁剪照片;
let imageViewFrame = imageView.frame
let imageViewScale = imageView.image!.size.height / imageView.frame.height
let imageViewOriginX = imageViewFrame.minX
let imageViewOriginY = imageViewFrame.minY
let image = imageView.image!
// 裁剪框相对于照片控件的位置
let cropRect = CGRectApplyAffineTransform(frameView.frame, CGAffineTransformMakeTranslation(-imageViewOriginX, -imageViewOriginY));
// 裁剪区域相对于照片的位置
let cropRect1 = CGRectApplyAffineTransform(cropRect, CGAffineTransformMakeScale(imageViewScale, imageViewScale));
let cropRect2 = CGRectApplyAffineTransform(cropRect1, CGAffineTransformMakeScale(image.scale, image.scale));
// 裁剪照片
if let croppedImage = image.cgImage!.cropping(to: cropRect2) {
let image = UIImage(cgImage: croppedImage, scale: image.scale, orientation: image.imageOrientation)
}
1.6 镂空遮罩的实现
iOS 镂空遮罩的实现有几种方式,下面是其中一种。
定义一个 maskView:
private let maskView = UIView()
新建镂空遮罩 layer,设置到 maskView :
let maskRect = xxx
let frameRect = xxx // 镂空区域的范围,一般是裁剪框,所以 maskView 的大小应该等于裁剪框的父控件大小,maskRect 是 maskView.bounds
let path = UIBezierPath(rect: maskRect) // 遮罩区域
let hollowOutPath = UIBezierPath(rect: frameRect) // 镂空区域
path.append(hollowOutPath)
path.usesEvenOddFillRule = true
let shapeLayer = CAShapeLayer()
shapeLayer.path = path.cgPath
shapeLayer.fillRule = CAShapeLayerFillRule.evenOdd
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer.opacity = 0.8
maskView.layer.addSublayer(shapeLayer)
另外在两个手势触发 began 时隐藏遮罩,触发 ended 时显示遮罩。
switch recognizer.state {
case .began:
maskView.isHidden = true
break
case .ended:
maskView.isHidden = false
break
case .changed, .cancelled, .possible, .failed:
break
@unknown default:
break
}
二、扩展
2.1 图片范围限制的优化
上面讲到了对图片范围进行限制,实际体验没有苹果系统相册那么丝滑。
可以参考它优化成,手势结束时,根据手势的信息(如方向等)调整到合适的位置。
2.2 显示人脸位置
可以在手势结束后,进行人脸检测,检测到人脸则对应的位置圈红。
- 定义人脸框
private let faceView = UIView()
- 计算位置并展示
可以参考我的另一篇文章iOS 使用 CoreImage 实现人脸检测,获取到人脸检测的结果,ciImageSize 和 faceBounds。
// frameView 是裁剪框
let x = faceBounds.origin.x / ciImageSize.width * frameView.frame.width
let y = (ciImageSize.height - faceBounds.origin.y - faceBounds.height) / ciImageSize.height * frameView.frame.height
let width = faceBounds.width / ciImageSize.width * frameView.frame.width
let height = faceBounds.height / ciImageSize.height * frameView.frame.height
let tempBounds = CGRect(x: x, y: y, width: width, height: height)
faceView.frame = tempBounds
三、Demo
import UIKit
class CropController: UIViewController {
private var completion: ((_ image: UIImage?) -> Void)?
private let imageView = UIImageView()
private let frameView = UIView()
private let maskView = UIView()
private var imageViewLastCenter = CGPoint.zero
private var imageViewLastTransform: CGAffineTransform = CGAffineTransform.identity
private var hasPlaceImageViewAndSetupMask: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasPlaceImageViewAndSetupMask {
hasPlaceImageViewAndSetupMask = true
// 处理照片位置,等比例拉伸裁剪,类似 scaleAspectFill
// 如果只是设置 contentMode,图片和 frame 的宽高不一样,会影响后面的计算
let widthRatio = imageView.image!.size.width / view.frame.size.width
let heightRatio = imageView.image!.size.height / view.frame.size.height
let min = min(widthRatio, heightRatio)
imageView.frame = CGRectApplyAffineTransform(CGRect(origin: .zero, size: imageView.image!.size), CGAffineTransformMakeScale(1 / min, 1 / min));
imageView.center = view.center
// 设置镂空遮罩
let path = UIBezierPath(rect: view.bounds) // 遮罩区域
let hollowOutPath = UIBezierPath(rect: frameView.frame) // 镂空区域
let shapeLayer = CAShapeLayer()
path.append(hollowOutPath)
path.usesEvenOddFillRule = true
shapeLayer.path = path.cgPath
shapeLayer.fillRule = CAShapeLayerFillRule.evenOdd
shapeLayer.fillColor = UIColor.black.cgColor
shapeLayer.opacity = 0.8
maskView.layer.addSublayer(shapeLayer)
}
}
}
// MARK: - 对外
extension CropController {
static func crop(image: UIImage, onController controller: UIViewController, completion: ((_ image: UIImage?) -> Void)?) {
let vc = CropController()
vc.completion = completion
vc.imageView.image = image
vc.modalPresentationStyle = .fullScreen
controller.present(vc, animated: true)
}
}
// MARK: - 私有
private extension CropController {
func setup() {
view.backgroundColor = .black
// 照片
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
imageView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(onPan)))
imageView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(onPinch)))
view.addSubview(imageView)
frameView.isUserInteractionEnabled = false
frameView.backgroundColor = .clear
frameView.layer.borderColor = UIColor.white.cgColor
frameView.layer.borderWidth = 2.5
view.addSubview(frameView)
frameView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalTo(330)
make.height.equalTo(495)
}
// 镂空遮罩
maskView.isUserInteractionEnabled = false
maskView.backgroundColor = UIColor.clear // 必须透明
view.addSubview(maskView)
maskView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
// 底部按钮区域
let visualView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterialDark))
visualView.layer.cornerRadius = 16
visualView.clipsToBounds = true
view.addSubview(visualView)
visualView.snp.makeConstraints { make in
make.height.equalTo(90)
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(16)
}
let cancelButton = UIButton()
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(.white, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 15)
cancelButton.addTarget(self, action: #selector(onCancel(sender:)), for: .touchUpInside)
let restoreButton = UIButton()
restoreButton.setTitle("还原", for: .normal)
restoreButton.setTitleColor(.white, for: .normal)
restoreButton.titleLabel?.font = .systemFont(ofSize: 15)
restoreButton.addTarget(self, action: #selector(onRestore(sender:)), for: .touchUpInside)
let saveButton = UIButton()
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = .systemFont(ofSize: 15)
saveButton.addTarget(self, action: #selector(onSave(sender:)), for: .touchUpInside)
let buttonStackView = UIStackView()
buttonStackView.alignment = .fill
buttonStackView.distribution = .fillProportionally
buttonStackView.addArrangedSubview(cancelButton)
buttonStackView.addArrangedSubview(restoreButton)
buttonStackView.addArrangedSubview(saveButton)
view.addSubview(buttonStackView)
buttonStackView.snp.makeConstraints { make in
make.top.equalTo(visualView)
make.leading.trailing.equalToSuperview()
make.height.equalTo(58)
}
}
@objc func onPan(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
maskView.isHidden = true
imageViewLastCenter = recognizer.view!.center
break
case .changed, .ended:
let translation = recognizer.translation(in: view)
let newCenter = CGPoint(x: recognizer.view!.center.x + translation.x, y: recognizer.view!.center.y + translation.y)
recognizer.view!.center = newCenter
recognizer.setTranslation(.zero, in: view)
if CGRectContainsRect(imageView.frame, frameView.frame) {
imageViewLastCenter = recognizer.view!.center
} else if .ended == recognizer.state {
recognizer.view!.center = imageViewLastCenter
}
if .ended == recognizer.state {
maskView.isHidden = false
}
break
case .cancelled, .possible, .failed:
break
@unknown default:
break
}
}
@objc func onPinch(recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
maskView.isHidden = true
imageViewLastTransform = recognizer.view!.transform
break
case .changed, .ended:
recognizer.view!.transform = CGAffineTransformScale(recognizer.view!.transform, recognizer.scale, recognizer.scale)
let isFrameViewInsideImageView = CGRectContainsRect(imageView.frame, frameView.frame)
if isFrameViewInsideImageView {
imageViewLastTransform = recognizer.view!.transform
} else if .ended == recognizer.state {
recognizer.view!.transform = imageViewLastTransform
}
recognizer.scale = 1
if .ended == recognizer.state {
maskView.isHidden = false
}
break
case .cancelled, .possible, .failed:
break
@unknown default:
break
}
}
@objc func onCancel(sender: UIButton) {
sender.isEnabled = false
self.dismiss(animated: true, completion: { [weak self] in
self?.completion?(nil)
})
}
@objc func onRestore(sender: UIButton) {
sender.isEnabled = false
imageView.center = view.center
imageView.transform = .identity
imageViewLastCenter = CGPoint.zero
imageViewLastTransform = CGAffineTransform.identity
sender.isEnabled = true
}
@objc func onSave(sender: UIButton) {
sender.isEnabled = false
if CGRectContainsRect(imageView.frame, frameView.frame) {
let imageViewFrame = imageView.frame
let imageViewScale = imageView.image!.size.height / imageView.frame.height
let imageViewOriginX = imageViewFrame.minX
let imageViewOriginY = imageViewFrame.minY
let image = imageView.image!
let cropRect = CGRectApplyAffineTransform(frameView.frame, CGAffineTransformMakeTranslation(-imageViewOriginX, -imageViewOriginY));
let cropRect1 = CGRectApplyAffineTransform(cropRect, CGAffineTransformMakeScale(imageViewScale, imageViewScale));
let cropRect2 = CGRectApplyAffineTransform(cropRect1, CGAffineTransformMakeScale(image.scale, image.scale));
if let croppedImage = image.cgImage!.cropping(to: cropRect2) {
let image = UIImage(cgImage: croppedImage, scale: image.scale, orientation: image.imageOrientation)
// 其他逻辑,如人脸检测等
// ...
sender.isEnabled = true
}
} else {
self.view.toast("裁剪框内存在空白")
sender.isEnabled = true
}
}
}