美文网首页Swift编程
iOS 照片裁剪页面的实现

iOS 照片裁剪页面的实现

作者: zackzheng | 来源:发表于2024-03-13 18:02 被阅读0次

    一、实现讲解

    本文实现了一个基本的照片裁剪页面,包含取消、还原、保存等操作,无操作时的镂空遮罩展示等。Demo 在文末可以参考。

    photo_crop.jpg

    裁剪有两个元素,即照片和裁剪框。交互一般分为三种:

    1. 裁剪框和图片,都可以移动或缩放;
    2. 裁剪框保持固定,图片可以移动或缩放;
    3. 裁剪框可以移动或缩放,图片保持固定;

    第一种是苹果相册的原生交互,第二种也相对主流,第三种比较少见。
    本文实现的是第二种,即裁剪框保持固定,图片移动或缩放。
    可以对代码进行扩展,原理都是对手势的处理和裁剪区域坐标计算。

    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 图片范围限制的实现

    图片可以移动或缩放,会出现在页面任何地方。
    一般都会对图片的位置进行限制,比如图片必须包含裁剪框,即裁剪框中必须填满图片的内容,没有空白的地方。

    按这个逻辑,图片的初始位置和上一个位置肯定是符合要求的,所以我们可以在位置符合时保存需要的信息,手势结束时进行判断,是否需要恢复到上一个位置。

    1. 判断图片位置是否符合要求

    使用下面的方法,可以判断照片控件 imageView 是否包含裁剪框 frameView。

    CGRectContainsRect(imageView.frame, frameView.frame)
    
    1. 定义变量保存变动
    private var imageViewLastCenter = CGPoint.zero
    private var imageViewLastTransform: CGAffineTransform = CGAffineTransform.identity
    
    1. 手势触发时处理逻辑

    在手势触发 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 保存操作的处理

    1. 计算裁剪框相对于照片控件的位置;
    2. 计算照片和照片控件的比例;
    3. 计算裁剪区域相对于照片的位置;
    4. 裁剪照片;
    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 显示人脸位置

    可以在手势结束后,进行人脸检测,检测到人脸则对应的位置圈红。

    1. 定义人脸框
    private let faceView = UIView()
    
    1. 计算位置并展示

    可以参考我的另一篇文章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
            }
        }
    }
    
    

    相关文章

      网友评论

        本文标题:iOS 照片裁剪页面的实现

        本文链接:https://www.haomeiwen.com/subject/nlgmzdtx.html