#20 照片編輯App〈1〉裁切

這次的作業研究滿久的,一開始在了解CIFilter的時候,我想起用Spark AR在做社群濾鏡的時候都會利用LUT(色彩查找表),很好奇在Swift的世界是不是可以把LUT的png檔轉換成某種形式的資料,然後當作客製化的filter使用呢?看了Stack Overflow的一些討論還是沒研究出來~人真的不要想越級打怪XD迷途知返的我趕緊回來解主線任務啦!

練習重點

  • PHPickerViewController&UIImagePickerController 選照片&拍照
  • 利用delegate在頁面間傳遞資料
  • 自訂一個繼承scroll view的類別,在不同畫面重複使用,生出工具欄
  • 利用UIPinchGestureRecognizer&UIPanGestureRecognizer 放大、拖曳裁切框
  • 利用Property observers監聽屬性,控制裁切框拖曳範圍
  • 透過CGAffineTransform旋轉、翻轉view
  • 利用UIGraphicsImageRenderer把view變成圖片
  • 利用CIfilter實現濾鏡功能
  • 利用UIActivityViewController儲存編輯好的圖片
首頁畫面是模仿RNI Films這個App做的

程式①定義一個enum儲存長寬比的資料

把raw value設為0開始的整數,是為了對應之後按鈕的tag

程式②自訂裁切框類別

自訂一個裁切框類別,複寫UIView的draw function,之後生成這個類別的東西,就會得到一個客製化裁切框的view~

程式③傳遞資料到裁切圖片的畫面

        @IBSegueAction func goToCrop(_ coder: NSCoder) -> cropPhotoVC? {
let selectedPhoto = editImgView.image
let cropVC = photoEditor.cropPhotoVC(coder: coder)
cropVC?.delegate = self
cropVC?.selectedPhoto = selectedPhoto

return cropVC
}

程式④cropPhotoVC

customEditView是自定義繼承scroll view的類別,要到story board把元件的class改成customEditView,我一開始忘了改就造成閃退

    //自定義繼承scroll view的類別,用來顯示客製化的工具欄
@IBOutlet weak var cropEditView: customEditView!
//顯示圖片的image view
@IBOutlet weak var cropImgView: UIImageView!
//從上一頁傳來的圖片
var selectedPhoto:UIImage?
//裁切框,crop是在②自訂的類別
var cropFrame:crop?
//工具欄按鈕的title
let ratioList = ["原圖", "1:1", "4:3", "3:2", "5:3", "16:9"]
//用來存工具欄每個長寬比的示意圖
var picList:[UIImage] = []
//長寬比的值
var value:CGFloat = 1.0

程式⑤設定圖片、生成裁切框

//設定圖片為上一頁傳來的圖
cropImgView.image = selectedPhoto

我希望載入裁切畫面的時候,加入一個跟「圖片」大小相同的裁切框在圖上
剛才已經在②寫了一個自訂類別,生成一個crop類別的東西,就可以得到裁切框,但是還有一個問題⋯⋯

圖1→圖2→圖3

圖1.裁切框尺寸=圖片大小(image view背景透明)
圖2.裁切框尺寸=圖片大小(image view背景藍色)
圖3.裁切框尺寸=image view的大小(image view背景藍色)

如果設成下面這樣,就會得到圖3

cropFrame = crop(frame: CGRect(x: 0, y: 0, width: cropImgView.frame.width, height: cropImgView.frame.height))

因為content mode是aspect fit,圖片會在容器內按照原本比例縮放,所以圖片的(minX,minY)座標不會是(0,0),長寬也不會跟image view一樣

因此要另外計算圖片的座標跟在image view裡的長寬,我寫成extension,方便其他地方使用

extension UIImageView{

var imgW:CGFloat{(self.image?.size.width)!}
var imgH:CGFloat{(self.image?.size.height)!}
var viewW:CGFloat{self.bounds.width}
var viewH:CGFloat{self.bounds.height}
var scale:CGFloat{min(viewW / imgW, viewH / imgH)}
var offsetX:CGFloat{(viewW - imgW * scale) / 2}
var offsetY:CGFloat{(viewH - imgH * scale) / 2}
var scaledImgW:CGFloat{imgW * scale}
var scaledImgH:CGFloat{imgH * scale}

func getMaxX() -> CGFloat{
return offsetX + imgW * scale
}

func getMaxY() -> CGFloat{
return offsetY + imgH * scale
}
}

【筆記】
1.計算圖片在image view裡縮放的倍率,用min這個函數(官方文件

min(viewW / imgW, viewH / imgH)

代表將括號內的兩個值做比較,回傳較小的值
縮放圖片會以縮放比較多的那邊為主,去調整另一邊的尺寸

2.利用圖片會置中於image view裡的特性(兩者中心點座標相同),計算圖片縮放後在image view裡的座標

//紅色矩形的midX跟藍色矩形的midX相同
(offsetX + offsetX + (imgW * scale)) / 2 = viewW / 2
//推算出以下
offsetX = (viewW - imgW * scale) / 2
//生成裁切框,設定背景為透明,加到cropImgView裡
let cropFrameRect = CGRect(x: cropImgView.offsetX, y: cropImgView.offsetY, width: cropImgView.imgW * cropImgView.scale, height: cropImgView.imgH * cropImgView.scale)
cropFrame = crop(frame: cropFrameRect)

cropFrame?.backgroundColor = .clear
cropImgView.addSubview(cropFrame!)

程式⑥設置工具欄

利用enum設定的raw value跟switch,搭配迴圈把圖片依序加入picList

for i in 0...ratio.allCases.count-1{
let image = ratio(rawValue: i)?.pic
picList.append(image!)
}

在自定義的型別方法中把一閉包當成參數傳入,並且把閉包寫成escaping closure,延長這個閉包的生命週期,讓它在function返回後還能繼續在外部做其他操作(教學參考

這邊是為了在function回傳後繼續用返回值index(按鈕的tag)做其他事,在這一頁的按鈕功能是改變裁切框的比例

cropEditView.setCustomEditView(titleList: ratioList, picList: picList){ [self] (index) -> (Void) inif let aspectRatio = ratio(rawValue: index)?.ratioValue,
let h = cropFrame?.frame.size.height,
let w = cropFrame?.frame.size.width{
guard index == 0 else{value = aspectRatio
cropFrame?.frame.size.width = h / aspectRatio
return
}
let originalRatio:CGFloat = cropImgView.imgH / cropImgView.imgW
cropFrame?.frame.size.height = w * originalRatio
value = originalRatio
}}

相關閱讀:

程式⑦裁切框加上UIPinchGestureRecognizer、UIPanGestureRecognizer

為了實現讓裁切框可以自由按比例放大縮小&位移,在裁切框上加入這兩個手勢

let pinch = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:)))
cropImgView.isUserInteractionEnabled = true
cropImgView.addGestureRecognizer(pinch)

let pan = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
cropFrame!.addGestureRecognizer(pan)

程式⑦-1設定觸發拖曳(Pan)手勢後執行的動作

@objc func didPan(_ gestureRecognizer:UIPanGestureRecognizer){}

這裡研究了好久🙂雖然它不是很完美,但是有做出來差不多的樣子我很開心><

//手勢的偏移量
let translation = gestureRecognizer.translation(in: cropImgView)
//圖片的minX & minY
let offsetX = cropImgView.offsetX
let offsetY = cropImgView.offsetY
//x座標超出的量、y座標超出的量
var overrunX:CGFloat = 0.0
var overrunY:CGFloat = 0.0

我用property observers來監聽裁切框的座標變化

【左|minX】
指派裁切框的origin.x給newX(裁切框新的origin.x)
如果這個新座標小於offsetX(圖片的origin.x)
——裁切框超出圖片左邊
就讓裁切框的新origin.x = 原本的offsetX

var newX:CGFloat = 0.0{
didSet{
if newX < offsetX{newX = offsetX}
}}

【上|minY】
如果newY小於offsetY(圖片的origin.y)
——裁切框超出圖片上方
就讓裁切框的新origin.y = 原本的offsetY

var newY:CGFloat = 0.0{
didSet{
if newY < offsetY{
newY = offsetY}
}}

【右|maxX】
1.裁切框沒有超出圖片(newMaxX小於圖片的maxX):
overrunX(X座標超出的量)就是0
2.裁切框超出圖片(newMaxX大於等於圖片的maxX):
2–1.如果手勢往左移動,就讓overrunX = 0
(這樣裁切框才不會卡在右邊的邊界不能動)
2–2.如果手勢不是往左移動,就讓overrunX = 手勢的移動量
(手勢往右多少,就彈回多少,如此框就會卡在右邊邊界出不去)

var newMaxX:CGFloat = 0.0{
didSet{
if newMaxX < cropImgView.getMaxX(){
overrunX = 0
}else{
if translation.x * -1 > 0{
overrunX = 0
}else{
overrunX = translation.x}
}}}

【下|maxY】
1.裁切框沒有超出圖片(newMaxY小於圖片的maxY):
overrunY(Y座標超出的量)就是0
2.裁切框超出圖片(newMaxY大於等於圖片的maxY):
2–1.如果手勢往下移動,就讓overrunY = 0
(這樣裁切框才不會卡在上面的邊界不能動)
2–2.如果手勢不是往下移動,就讓overrunY = 手勢的移動量
(手勢往上多少,就彈回多少,如此框就會卡在上面邊界出不去)

var newMaxY:CGFloat = 0.0{
didSet{
if newMaxY < cropImgView.getMaxY(){
overrunY = 0
}else{
if translation.y * -1 > 0{
overrunY = 0
}else{
overrunY = translation.y}
}}}

設定裁切框四個座標

newX = (cropFrame?.frame.origin.x)!
newY = (cropFrame?.frame.origin.y)!
newMaxX = overrunX > 0 ? cropImgView.getMaxX() : (cropFrame?.frame.maxX)!
newMaxY = overrunY > 0 ? cropImgView.getMaxY() : (cropFrame?.frame.maxY)!

讓裁切框位移
x座標的位移 = newX + translation.x − overrunX − offsetX
y座標的位移 = newY + translation.y − overrunY − offsetY

cropFrame?.transform = CGAffineTransform(translationX: newX + translation.x - overrunX - offsetX, y: newY + translation.y - overrunY - offsetY)

偏移量會不斷疊加,最後一定要把偏移量歸零

gestureRecognizer.setTranslation(CGPoint.zero, in: cropImgView)

程式⑦-2設定觸發縮放(Pinch)手勢後執行的動作

@objc func didPinch(_ gestureRecognizer:UIPinchGestureRecognizer){
if gestureRecognizer.state == .changed{

let pinchScale = gestureRecognizer.scale
if let cropFrame = cropFrame {
let cropW = cropFrame.frame.size.width
let cropH = cropFrame.frame.size.height

var scaleW:CGFloat{cropW * pinchScale}
var scaleH:CGFloat{cropH * pinchScale}

guard scaleW / value < cropImgView.scaledImgW &&
scaleH * value < cropImgView.scaledImgH else{
return
}
cropFrame.frame.size.width = scaleW / value
cropFrame.frame.size.height = scaleW
}

}
}

程式⑧取消按鈕

不更改,回到編輯頁面

@IBAction func cancel(_ sender: Any) {
self.dismiss(animated: true, completion: nil)
}

程式⑨儲存變更,回到編輯頁面

用UIGraphicsImageRenderer把特定區域的view渲染成圖片再傳回上一頁
首先一定要把裁切框從父視圖裡拿掉,不然渲染出的圖片會有一個框
因為在其他頁面也會使用到UIGraphicsImageRenderer,我用extension把它寫成UIViewController的方法

@IBAction func confirm(_ sender: Any) {

guard let cropFrame = cropFrame else{return}
cropFrame.removeFromSuperview()
let editedImg = render(cgRect: CGRect(x: cropFrame.frame.minX, y: cropFrame.frame.minY, width: cropFrame.frame.width, height: cropFrame.frame.height), imgView: cropImgView)

delegate?.cropPhotoVC(self, didCrop: editedImg)
self.dismiss(animated: true, completion: nil)

}

待解決問題

大功告成,裁切框只能在圖片範圍內移動

不過還有一些問題⋯⋯

切換比例的時候,裁切框超出圖片,以及用力過猛裁切框還是會跑出去XD

觀察不同app裡的裁切功能

1→2→3→4
  1. Apple內建照片編輯器
    裁切框不能拖曳,拉四個角可以縮放,隔線僅在縮放時出現,縮放後會將框內區域放大置中顯示,框外內容變暗,照片能縮放(利用scroll view)
  2. 美圖秀秀
    裁切框可自由拖曳,拉四個角進行縮放,亦可用兩指縮放,框的中央會顯示尺寸,框外內容變暗
  3. VSCO
    裁切框可自由拖曳,拉四個角進行縮放,框外內容變暗
  4. Lightroom
    裁切框可自由拖曳,拉四個角進行縮放,兩指可縮放圖片,框外內容變暗

心得

沒想到第一個裁切部分就寫很長,其他功能只能分批寫了,這次真的花滿多時間在研究,之前課程的影片也反覆看了幾次。沒做好的地方,例如用兩指來縮放裁切框的功能,雖然效果做出來了,實機操作覺得體驗不OK,還是拖曳邊角的方式更好用,之後有時間再改善,緊接著要繼續做其他作業了><

GitHub

--

--

xxinlei
彼得潘的 Swift iOS / Flutter App 開發教室

不務正業的日文筆譯,半個AR Creator,正在用力成為iOS Developer