iOS 照片裁剪页面的实现

一、实现讲解

本文实现了一个基本的照片裁剪页面,包含取消、还原、保存等操作,无操作时的镂空遮罩展示等。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
        }
    }
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容