在大部分APP(尤其是社交類的,如qq)經常會有更換頭像的場景:點擊用戶加載頭像,加載出系統圖片,用戶點擊選中某張圖片之後,可以對圖片進行放縮和拖動,已更改圓形裁剪框圈定的圖片部分。如下圖即爲qq的頭像選取編輯界面:
圖1.qq照片編輯界面
界面中可以對圖片進行放大、縮小,拖動,白色圓環區域表示點擊確定時將要裁剪的範圍。留意上圖的動畫,qq總是能夠確保圓環完全被圖片所覆蓋,如果拖動或者放縮使得圖片以外的黑色區域進入了圓環,圖片會自動彈回剛好能夠完全覆蓋的狀態,鑑於CSDN上傳圖片2M的限制,上面的gif圖很短,感興趣的同學可以打開QQ自己體驗一把(在修改個人頭像功能中)。
現在我們也要實現一個類似功能的界面,並且是在autolayout環境下,同時支持橫豎屏,這比QQ的圖片選取頁面又複雜了一些:QQ只支持豎屏的情況,不需要考慮橫屏時的情況和橫豎屏切換的問題。下面詳細討論。
一、預期效果
用戶從相冊或者相機中選取/拍攝一張照片,加載到圖片編輯界面,用戶可以拖動、放縮照片,使圓形選取框中截圖到合適的圖像作爲用戶頭像。效果圖如下圖所示:
用戶在拖動、放縮時要保證圓環區域全部被圖片所覆蓋,這樣才能確保裁剪出來的照片剛好能夠撐滿整個圓形區域。同時,因爲我們支持橫屏佈局,因此還要確保豎屏切換橫屏(或者反之)之後,圓環仍在正確的區域。
圖2.豎屏效果
圖3.橫屏效果
整個界面滿足了上述用戶交互需求之外,還要在用戶點擊確定的時候,將圓形區域的圖片裁剪下來,實現圖片編輯的功能。
二、實現細節
2.1基本思路
在實現上,這個頁面可以分爲兩大塊:一塊是scrollview的設置:contentSize、contentInset、zoomScale等等;另一塊是剪切框的實現(白色圓環、外圍半透明蒙層),以及橫豎屏切換時剪切框如何變化等;而這兩塊又不是完全獨立的:scrollview的很多交互都依賴於剪切框:最小放縮不能小於剪切框、移動不能超出剪切框的範圍等。可以認爲,scrollview的屬性依賴於剪切框的屬性。而剪切框在橫屏或者豎屏的時候大小位置是保持不變的,因此,我們很自然的得到這樣一個思路:先確定剪切框,橫豎屏都沒問題了,再通過剪切框確定scrollview。
2.2剪切框的實現
從圖二中可以看出剪切框是一個比較特殊的界面:圓形虛線框內部是完全透明的(clearColor or alpha = 0),而外圍的填充部分則是半透明效果(blackColor and alpha = 0.2),常規的通過view的嵌套設置alpha、backgroundColor和layer.cornerRadius是不行的,因爲view的alpha屬性具有“遺傳性”:父view的alpha將直接作用於所有的子view上去,這時我們就要考慮通過更底層的繪圖方式直接在一個view上完成剪切框的繪製工作。
我們在storyboard中添加一個view(稱之爲:maskView),添加約束使其和scrollview大小、尺寸完全保持一致。將這個view的class改爲TTPhotoMaskView:一個我們定製的view,在其drawRect方法中,繪製剪切框,繪製示意圖如下:
圖4.剪切框繪製
1.繪製兩條封閉的線,一條是方形的,剛好覆蓋整個view的邊界,還一條是圓形的虛線裁剪框;
2.使用奇偶原則對這兩條封閉曲線進行色彩填充,使得方框和圓形框之間的區域填充(黑色,alpha=0.2),而圓形框內部不進行填充(透明)。
具體實現代碼如下:
-(void)drawRect:(CGRect)rect
{
CGFloat width = rect.size.width;
CGFloat height = rect.size.height;
//pickingFieldWidth:圓形框的直徑
CGFloat pickingFieldWidth = width < height ? (width - kWidthGap) : (height - kHeightGap);
CGContextRef contextRef = UIGraphicsGetCurrentContext();
CGContextSaveGState(contextRef);
CGContextSetRGBFillColor(contextRef, 0, 0, 0, 0.35);
CGContextSetLineWidth(contextRef, 3);
//計算圓形框的外切正方形的frame:
self.pickingFieldRect = CGRectMake((width - pickingFieldWidth) / 2, (height - pickingFieldWidth) / 2, pickingFieldWidth, pickingFieldWidth);
//創建圓形框UIBezierPath:
UIBezierPath *pickingFieldPath = [UIBezierPath bezierPathWithOvalInRect:self.pickingFieldRect];
//創建外圍大方框UIBezierPath:
UIBezierPath *bezierPathRect = [UIBezierPath bezierPathWithRect:rect];
//將圓形框path添加到大方框path上去,以便下面用奇偶填充法則進行區域填充:
[bezierPathRect appendPath:pickingFieldPath];
//填充使用奇偶法則
bezierPathRect.usesEvenOddFillRule = YES;
[bezierPathRect fill];
CGContextSetLineWidth(contextRef, 2);
CGContextSetRGBStrokeColor(contextRef, 255, 255, 255, 1);
CGFloat dash[2] = {4,4};
[pickingFieldPath setLineDash:dash count:2 phase:0];
[pickingFieldPath stroke];
CGContextRestoreGState(contextRef);
self.layer.contentsGravity = kCAGravityCenter;
}
現在再來考慮如何處理橫豎屏的問題:我們的剪切框是直接通過UIView的drawRect方法直接手繪上去的,因此無法通過自動佈局(autolayout)對剪切框進行重新佈局。
解決的辦法是在屏幕發生橫豎屏切換的時候重新繪製圓形剪切框。在iOS8中不再使用willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration來獲取屏幕旋轉事件了,iOS8以後的使用新的willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id)coordinator來代替。
因此我們在這個方法中,強制裁剪框重繪(maskview):
#pragma mark - UIContentContainer protocol
- (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id)coordinator
{
[super willTransitionToTraitCollection:newCollection withTransitionCoordinator:coordinator];
[self.maskView setNeedsDisplay];
}
這樣我們的剪切框就順利完成了,接下來我們來設置scrollview,使其滿足我們的交互預期。
2.3 scrollview的設置
首先來看一下整個view的層級結構:scrollview有一個撐滿整個scrollview的imageView作爲scrollview的content view,在scrollView之上蓋着一個剪切框的view(mask view),這三個view都通過約束保持和根view的bounds一致。
圖5.view的層級結構
上面提到,scrollview的各種屬性的設置都要依賴於手繪出的剪切框。而圓形剪切框的位置、大小在每次轉屏之後可能發生變化,因此我們必須要在每次maskView的drawRect方法調用之後都重新調整一下scrollview的屬性。因此我們在maskView中添加一個代理,將這個代理設置爲maskview所在的viewController,每次當重繪發生後就通過代理方法通知viewcontroller調整scrollview的各項屬性:
// TTPhotoMaskView.h
@protocol TTPhotoMaskViewDelegate
- (void)pickingFieldRectChangedTo:(CGRect) rect;
@end
@interface TTPhotoMaskView : UIView
@property (nonatomic, weak) id delegate;
@end
在maskView的drawRect方法中添加:其中pickingFieldRect即爲圓環剪切框的“frame”,包含其相對於maskView的origin和size信息。
if ([self.delegate respondsToSelector:@selector(pickingFieldRectChangedTo:)]) {
[self.delegate pickingFieldRectChangedTo:self.pickingFieldRect];
}
接下來就是在我們的viewController中實現pickingFieldRectChangedTo方法,調整scrollView:
#pragma mark - TTPhotoMaskViewDelegate
- (void)pickingFieldRectChangedTo:(CGRect)rect
{
self.pickingFieldRect = rect;
CGFloat topGap = rect.origin.y;
CGFloat leftGap = rect.origin.x;
self.scrollView.scrollIndicatorInsets = UIEdgeInsetsMake(topGap, leftGap, topGap, leftGap);
//step 1: setup contentInset
self.scrollView.contentInset = UIEdgeInsetsMake(topGap, leftGap, topGap, leftGap);
CGFloat maskCircleWidth = rect.size.width;
CGSize imageSize = self.originImage.size;
//setp 2: setup contentSize:
self.scrollView.contentSize = imageSize;
CGFloat minimunZoomScale = imageSize.width < imageSize.height ? maskCircleWidth / imageSize.width : maskCircleWidth / imageSize.height;
CGFloat maximumZoomScale = 5;
//step 3: setup minimum and maximum zoomScale
self.scrollView.minimumZoomScale = minimunZoomScale;
self.scrollView.maximumZoomScale = maximumZoomScale;
self.scrollView.zoomScale = self.scrollView.zoomScale < minimunZoomScale ? minimunZoomScale : self.scrollView.zoomScale;
//step 4: setup current zoom scale if needed:
if (self.needAdjustScrollViewZoomScale) {
CGFloat temp = self.view.bounds.size.width < self.view.bounds.size.height ? self.view.bounds.size.width : self.view.bounds.size.height;
minimunZoomScale = imageSize.width < imageSize.height ? temp / imageSize.width : temp / imageSize.height;
self.scrollView.zoomScale = minimunZoomScale;
self.needAdjustScrollViewZoomScale = NO;
}
}
下面來詳細解析一下上面每一步設置的作用,首先以一張蘋果官方文檔(Scroll View Programming Guide for iOS)上的圖片來簡單看一下contentSize和contentInset的意義和作用:
圖6.UIScrollView的contentSize和contentInset屬性示意圖
contentSize是你在scrollView中要展示的內容(content)的大小,具體值要根據content的尺寸而定,我們這裏是要完整的無壓縮的展示一個圖片的內容,因此這裏在step 2中將contentSize設爲圖片(image.size)的size同等大小。
contentInset可以理解爲展示內容的上下左右“留白”的間距,默認值爲(0,0,0,0),contentInset所標示的留白加上contentSize纔是一個scrollView所能滑動的全部區域。這裏我們不想讓content(圖片)的滑動區域超出圓形剪切框的位置,可以通過巧妙的講剪切框圓環和view的上下左右邊緣的間距作爲scrollView的contentInset,這就是step 1做的事情,它確保了手指在圖片上拖動的時候圓形剪切框總能填滿圖片的內容。
scrollView對於放大縮小的支持非常簡單,你只需設置放縮的最大和最小倍數,然後在代理函數(UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView中返回要縮放的view即可。這裏主要需要確定的時scrollview的最小縮放尺寸,以滿足當放縮到最小時剛好圖片較短的一個維度(長或者寬)和圓形剪切框相切,這是能夠放縮的最小值,因爲如果再縮小圖片就無法填滿剪切框了:
圖7.放縮到最小時,剪切框必須要和較短的一邊相切
step 4只在viewDidLoad的時候執行,也即第一次進入圖片編輯頁面的時候,需要強制調整一下scrollview的當前zoomScale,使得圖片在一個合適的尺寸顯示出來。
至此,整個功能完成,運行一下程序,看一下效果,達到了預期:
圖8.轉屏效果
圖9.拖動和縮放
三、總結
將圖片加載進scrollview,對其放縮、拖動然後裁剪其中一部分是圖片編輯器的主要功能,看似簡單的功能需求,細究起來卻處處是坑,必須要深入的思考其中的每一個細節,利用好UIView的drawRect方法,結合使用scrollview的特性方能得以實現。
本示例主要有以下兩點值得關注:
1.圓形剪切框的實現,以及在autolayout環境下旋轉屏後剪切框的處理;
2.scrollView的屬性設置,必須要結合所加載圖片的實際尺寸、圓形剪切框的位置和大小信息來動態的調整scrollView的contentSize、contentInset等屬性。