iOS 浏览图片、剪裁图片和旋转图片
source link: http://blog.danthought.com/programming/2020/06/29/ios-photo-crop-rotate/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
本文讲解在 iOS 中如何实现图片的剪裁和旋转,重点在于对 UIScrollView、手势触控、UIView 和 CALayer 的理解。
完整代码:
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)
}
}
在没有触控操作剪裁区域时,需要显示黑色遮罩,但是剪裁区域不需要遮罩,通过 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
}
}
}
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK