iOS 浏览图片、剪裁图片和旋转图片

本文讲解在 iOS 中如何实现图片的剪裁和旋转,重点在于对 UIScrollView、手势触控、UIView 和 CALayer 的理解。
完整代码:
- PhotoLibraryViewController
- PhotoEditorViewController
- PhotoEditorResizeView
- PhotoEditorFrameView
- PhotoEditorMaskView
剪裁
UIScrollView
先通过浏览如下图片的示例来深入理解下 UIScrollView:
class PreviewPhotoViewController: UIViewController {
var page = 0
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let photo: UIImage
private var fitScale: CGFloat = 1
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(photo: UIImage) {
self.photo = photo
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupScrollView()
imageView.image = photo
imageView.frame = CGRect(x: 0, y: 0, width: photo.size.width, height: photo.size.height)
scrollView.contentSize = photo.size
caculateZoomScale()
centerContents()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
scrollView.zoomScale = fitScale
}
private func setupScrollView() {
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.delegate = self
if #available(iOS 11, *) {
scrollView.contentInsetAdjustmentBehavior = .never
}
scrollView.addSubview(imageView)
view.addSubview(scrollView)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func centerContents() {
let maxFrame = view.bounds
var contentsFrame = imageView.frame
if contentsFrame.size.width < maxFrame.size.width {
contentsFrame.origin.x = maxFrame.minX +
(maxFrame.size.width - contentsFrame.size.width) / 2
} else {
contentsFrame.origin.x = maxFrame.minX
}
if contentsFrame.size.height < maxFrame.size.height {
contentsFrame.origin.y = maxFrame.minY +
(maxFrame.size.height - contentsFrame.size.height) / 2
} else {
contentsFrame.origin.y = maxFrame.minY
}
imageView.frame = contentsFrame
}
private func caculateZoomScale() {
let scaleWidth = view.bounds.size.width / photo.size.width
let scaleHeight = view.bounds.size.height / photo.size.height
fitScale = min(scaleWidth, scaleHeight)
if fitScale < 1 {
scrollView.minimumZoomScale = fitScale
scrollView.maximumZoomScale = 1
} else {
scrollView.minimumZoomScale = fitScale
scrollView.maximumZoomScale = fitScale * 1.5
}
if scrollView.zoomScale != fitScale {
scrollView.zoomScale = fitScale
}
}
}
extension PreviewPhotoViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerContents()
}
}
UIScrollView 的 contentSize
如上的代码,一个 UIImageView 作为 UIScrollView 的子 View,同样放置了一张图片到了 UIImageView,并且将图片的大小设置为 UIScrollView 的 contentSize,也就如下图所示,可显示区域就是 UIScrollView 的 bounds,图片的大小超过了 UIScrollView 的 bounds,也可以看到 contentOffset 是 UIImageView 原点到 UIScrollView 原点的偏移,目前都是正的 x 和 y。
caculateZoomScale()
caculateZoomScale() 方法用于计算出 UIScrollView 的 zoomScale 的范围,这里限定了只计算 aspectFit 模式,后面再细讲。
UIScrollView 的 zoomScale
UIScrollViewDelegate 中实现的代码表明,通过 pinch 手势就可以改变 UIScrollView 的 zoomScale,示例中 UIScrollView 的内容就是 UIImageView,UIScrollView 的 zoomScale 表示缩放程度,其实就是通过改变 UIScrollView 的 contentSize 来实现,contentSize 和 UIImageView 大小一致,所以就会缩放 UIImageView,当 contentSize 都小于等于 UIScrollView 的 bounds,contentOffset 就是 0,centerContents() 方法主要作用就是在这种情况下计算 UIImageView 的 frame,使其居中,如下图所示:
UIScrollView 的 bounds
上面说到可显示区域就是 UIScrollView 的 bounds,如下图中,1 是显示图片最左上角,2 是显示图片最右上角,3 是显示图片最左下角,4 是显示图片最右下角,和 UIScrollView 的 bounds 非常的贴合,没有任何间距。
调整剪裁区域
View 层级关系
scrollView.addSubview(imageView)
view.addSubview(scrollView)
view.addSubview(maskView)
view.addSubview(resizeView)
三个平级的 View:
- UIScrollView 放置图片
- PhotoEditorMaskView 遮罩视图
- PhotoEditorResizeView 剪裁区域控制视图
重要的 CGRect
- bounds - UIScrollView 的 bounds
- maxFrame - 剪裁区域控制视图的最大 frame
- cropRectMaxFrame - 剪裁区域的最大 frame
- cropRect - 剪裁区域
初始化
centerContents() 初始化 UIImageView 和剪裁控制区域的位置,计算和设置 UIScrollView 的 zoomScale 和 contentOffset:
private func centerContents() {
imageView.image = photo
imageView.frame = CGRect(x: 0, y: 0, width: photo.size.width, height: photo.size.height)
scrollView.contentSize = photo.size
caculateZoomScale(cropRect: resizeView.cropRectMaxFrame)
let maxFrame = resizeView.cropRectMaxFrame
var contentsFrame = imageView.frame
if contentsFrame.size.width < maxFrame.size.width {
contentsFrame.origin.x = maxFrame.minX +
(maxFrame.size.width - contentsFrame.size.width) / 2
} else {
contentsFrame.origin.x = maxFrame.minX
}
if contentsFrame.size.height < maxFrame.size.height {
contentsFrame.origin.y = maxFrame.minY +
(maxFrame.size.height - contentsFrame.size.height) / 2
} else {
contentsFrame.origin.y = maxFrame.minY
}
fitBounds = view.bounds.applying(.init(translationX: contentsFrame.origin.x,
y: contentsFrame.origin.y))
imageView.frame = contentsFrame
resizeView.updateFrame(wtih: contentsFrame)
caculateContentInset(cropRect: contentsFrame)
hideMask()
}
触控
上图中的红色区域展示了上和下触控点、左上和右下的触控点,剪裁区域控制视图,一共有 8 个触控控制区域,每个触控点改变的区域大小方式也是不同的:
private enum ThumbPosition {
case unknown
case upLeftCorner
case upSide
case upRightCorner
case rightSide
case downRightCorner
case downSide
case downLeftCorner
case leftSide
}
iOS 上处理触控分几个阶段,第一个是开始触控阶段,第二个是移动阶段,第三个和第四个是结束触控阶段,第二个移动阶段根据移动距离来调整剪裁控制区域:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touchPoint = touches.first?.location(in: self) else { return }
thumbPosition = .unknown
var thumbPositionViewFrame: CGRect = .zero
for (position, area) in caculateThumbAreas() {
if area.contains(touchPoint) {
thumbPosition = position
thumbPositionViewFrame = area
}
}
if isDebug && thumbPosition != .unknown {
thumbPositionView.isHidden = false
thumbPositionView.frame = thumbPositionViewFrame
}
delegate?.resizeViewThumbOn(self)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touchPoint = touches.first?.location(in: self),
let previous = touches.first?.previousLocation(in: self) else {
return
}
let deltaWidth = previous.x - touchPoint.x
let deltaHeight = previous.y - touchPoint.y
let originX = frame.origin.x
let originY = frame.origin.y
let width = frame.size.width
let height = frame.size.height
let originFrame = frame
var finalFrame = originFrame
switch thumbPosition {
case .upLeftCorner:
let scaleX = 1.0 - (-deltaWidth / width)
let scaleY = 1.0 - (-deltaHeight / height)
finalFrame.size.width = width * scaleX
finalFrame.size.height = height * scaleY
finalFrame.origin.x = originX + width - finalFrame.size.width
finalFrame.origin.y = originY + height - finalFrame.size.height
case .upSide:
let scaleY = 1.0 - (-deltaHeight / height)
finalFrame.size.height = height * scaleY
finalFrame.origin.y = originY + height - finalFrame.size.height
case .upRightCorner:
let scaleX = 1.0 - (deltaWidth / width)
let scaleY = 1.0 - (-deltaHeight / height)
finalFrame.size.width = width * scaleX
finalFrame.size.height = height * scaleY
finalFrame.origin.y = originY + height - finalFrame.size.height
case .rightSide:
let scaleX = 1.0 - (deltaWidth / width)
finalFrame.size.width = width * scaleX
case .downRightCorner:
let scaleX = 1.0 - (deltaWidth / width)
let scaleY = 1.0 - (deltaHeight / height)
finalFrame.size.width = width * scaleX
finalFrame.size.height = height * scaleY
case .downSide:
let scaleY = 1.0 - (deltaHeight / height)
finalFrame.size.height = height * scaleY
case .downLeftCorner:
let scaleX = 1.0 - (-deltaWidth / width)
let scaleY = 1.0 - (deltaHeight / height)
finalFrame.size.width = width * scaleX
finalFrame.size.height = height * scaleY
finalFrame.origin.x = originX + width - finalFrame.size.width
case .leftSide:
let scaleX = 1.0 - (-deltaWidth / width)
finalFrame.size.width = width * scaleX
finalFrame.origin.x = originX + width - finalFrame.size.width
case .unknown:
break
}
if finalFrame.maxX <= maxFrame.maxX &&
finalFrame.minX >= maxFrame.minX &&
finalFrame.maxY <= maxFrame.maxY &&
finalFrame.minY >= maxFrame.minY &&
finalFrame.maxX - finalFrame.minX >= minSize &&
finalFrame.maxY - finalFrame.minY >= minSize {
frame = finalFrame
delegate?.resizeView(self, didResize: cropRect)
}
if isDebug && thumbPosition != .unknown {
thumbPositionView.isHidden = true
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
delegate?.resizeViewThumbOff(self)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
delegate?.resizeViewThumbOff(self)
}
还有,非 8 个触控控制区域的地方,仍然可以响应 pinch 和 pan 的手势,通过如下方法来排除这些区域,可以参考 iOS 基础 - 处理屏幕点击事件 了解更多:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if bounds.contains(point) {
for area in caculateThumbAreas().values {
if area.contains(point) {
return true
}
}
}
return false
}
触控回调
根据剪裁区域的调整,改变 UIScrollView 的 zoomScale 和 contentInset:
extension PhotoEditorViewController: PhotoEditorResizeViewDelegate {
func resizeView(_ resizeView: PhotoEditorResizeView, didResize cropRect: CGRect) {
var isEdit = false
if cropRect.height > scrollView.contentSize.height ||
cropRect.width > scrollView.contentSize.width {
isEdit = true
}
caculateZoomScale(cropRect: cropRect, isFit: false, isEdit: isEdit)
caculateContentInset(cropRect: cropRect)
}
}
调整 UIScrollView 的 zoomScale:
private func caculateZoomScale(cropRect: CGRect, isFit: Bool = true, isEdit: Bool = true) {
let scaleWidth = cropRect.size.width / photo.size.width
let scaleHeight = cropRect.size.height / photo.size.height
let scale = isFit ? min(scaleWidth, scaleHeight) : max(scaleWidth, scaleHeight)
if scale < 1 {
scrollView.minimumZoomScale = scale
scrollView.maximumZoomScale = 1
} else {
scrollView.minimumZoomScale = scale
scrollView.maximumZoomScale = scale * 1.5
}
if scrollView.zoomScale != scale && isEdit {
scrollView.zoomScale = scale
}
}
isFit 用于控制如下两种等比缩放模式:aspectFit 和 aspectFill。
isEdit 用于控制是否改变 zoomScale,前面代码的计算,当剪裁区域大于当前 UIScrollView 的 contentSize(也就是 UIImageView 的大小)时,才设置 isEdit 为 true。
调整 UIScrollView 的 contentInset:
private func caculateContentInset(cropRect: CGRect) {
scrollView.contentInset = .init(top: cropRect.minY - fitBounds.minY,
left: cropRect.minX - fitBounds.minX,
bottom: fitBounds.maxY - cropRect.maxY,
right: fitBounds.maxX - cropRect.maxX)
}
注意这里没有使用 UIScrollView 的 bounds 来计算 UIScrollView 的 contentInset,而是在 bounds 的基础上做了一定的偏移:
fitBounds = view.bounds.applying(.init(translationX: contentsFrame.origin.x,
y: contentsFrame.origin.y))
剪裁
计算出正确的区域进行图片剪裁:
@objc private func done() {
let cropRect = resizeView.convert(resizeView.cropRectInResizeView, to: imageView)
guard let correctImage = photo.cgImageCorrectedOrientation(),
let croppedImage = correctImage.cropping(to: cropRect) else {
return
}
let croppedPhoto = UIImage(cgImage: croppedImage)
delegate?.photoEditor(viewController: self, didEdit: croppedPhoto)
}
旋转
旋转就是调整 UIImage.Orientation 来实现的,然后再走一次初始化的流程,可以看下 图像的方向 深入理解下:
@objc private func rotate() {
if let cgImage = photo.cgImage {
switch photoOrientation {
case .up:
photoOrientation = .right
case .right:
photoOrientation = .down
case .down:
photoOrientation = .left
case .left:
photoOrientation = .up
default:
break
}
photo = UIImage(cgImage: cgImage, scale: 1.0, orientation: photoOrientation)
// some kinds of cache can cause frame is not right, so recreate
imageView.removeFromSuperview()
imageView = UIImageView()
scrollView.addSubview(imageView)
centerContents()
delegate?.photoEditor(viewController: self, didRotate: photo)
}
}
Mask
在没有触控操作剪裁区域时,需要显示黑色遮罩,但是剪裁区域不需要遮罩,通过 CALayer 的 mask 属性来实现:
func toggleMask(rect: CGRect?) {
if let rect = rect {
if layer.mask == nil {
backgroundColor = UIColor(white: 0, alpha: 0.8)
let maskLayer = CAShapeLayer()
maskLayer.fillRule = .evenOdd
let path = UIBezierPath(rect: bounds)
path.append(UIBezierPath(rect: rect))
maskLayer.path = path.cgPath
layer.mask = maskLayer
}
} else {
if layer.mask != nil {
backgroundColor = UIColor.clear
layer.mask = nil
}
}
}