iOS瘋狂詳解之自動佈局(autolayout)下圖片編輯器的實現

在大部分APP(尤其是社交類的,如qq)經常會有更換頭像的場景:點擊用戶加載頭像,加載出系統圖片,用戶點擊選中某張圖片之後,可以對圖片進行放縮和拖動,已更改圓形裁剪框圈定的圖片部分。如下圖即爲qq的頭像選取編輯界面:

20141012114535375.gif

圖1.qq照片編輯界面

界面中可以對圖片進行放大、縮小,拖動,白色圓環區域表示點擊確定時將要裁剪的範圍。留意上圖的動畫,qq總是能夠確保圓環完全被圖片所覆蓋,如果拖動或者放縮使得圖片以外的黑色區域進入了圓環,圖片會自動彈回剛好能夠完全覆蓋的狀態,鑑於CSDN上傳圖片2M的限制,上面的gif圖很短,感興趣的同學可以打開QQ自己體驗一把(在修改個人頭像功能中)。

現在我們也要實現一個類似功能的界面,並且是在autolayout環境下,同時支持橫豎屏,這比QQ的圖片選取頁面又複雜了一些:QQ只支持豎屏的情況,不需要考慮橫屏時的情況和橫豎屏切換的問題。下面詳細討論。

一、預期效果

用戶從相冊或者相機中選取/拍攝一張照片,加載到圖片編輯界面,用戶可以拖動、放縮照片,使圓形選取框中截圖到合適的圖像作爲用戶頭像。效果圖如下圖所示:

用戶在拖動、放縮時要保證圓環區域全部被圖片所覆蓋,這樣才能確保裁剪出來的照片剛好能夠撐滿整個圓形區域。同時,因爲我們支持橫屏佈局,因此還要確保豎屏切換橫屏(或者反之)之後,圓環仍在正確的區域。

20141012135048671.png

圖2.豎屏效果
20141012135124296.png

圖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方法中,繪製剪切框,繪製示意圖如下:

360桌面截圖20141014103117.jpg

圖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一致。

20141013142547328.png

圖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的意義和作用:

20141013143855807.jpg

圖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的最小縮放尺寸,以滿足當放縮到最小時剛好圖片較短的一個維度(長或者寬)和圓形剪切框相切,這是能夠放縮的最小值,因爲如果再縮小圖片就無法填滿剪切框了:

360桌面截圖20141014104104.jpg

圖7.放縮到最小時,剪切框必須要和較短的一邊相切

step 4只在viewDidLoad的時候執行,也即第一次進入圖片編輯頁面的時候,需要強制調整一下scrollview的當前zoomScale,使得圖片在一個合適的尺寸顯示出來。

至此,整個功能完成,運行一下程序,看一下效果,達到了預期:

20141013152324598.gif

圖8.轉屏效果

20141013152747427.gif

圖9.拖動和縮放

三、總結

將圖片加載進scrollview,對其放縮、拖動然後裁剪其中一部分是圖片編輯器的主要功能,看似簡單的功能需求,細究起來卻處處是坑,必須要深入的思考其中的每一個細節,利用好UIView的drawRect方法,結合使用scrollview的特性方能得以實現。

本示例主要有以下兩點值得關注:

1.圓形剪切框的實現,以及在autolayout環境下旋轉屏後剪切框的處理;

2.scrollView的屬性設置,必須要結合所加載圖片的實際尺寸、圓形剪切框的位置和大小信息來動態的調整scrollView的contentSize、contentInset等屬性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章