MBProgressHUD 原始碼解析

foolish-boy發表於2017-11-15

HUD在iOS中一般特指“透明提示層”,常見的有SVProgressHUDJGProgressHUDToast以及本文將要分析的MBProgressHUD

本文是基於MBProgressHUD 1.0.0分析的。

1.檢視層次

檢視層次
檢視層次

圖中可以看到檢視都是比較簡單的。但並不是所有的檢視都是可見的,由於使用了自動佈局以及intrinsicContentSize,所以label和button有內容時才可見。

2.自定義檢視類

上面的所有檢視除了標準的UILabel和UIButton之外,主要是幾個自定義的檢視類:

  1. MBRoundProgressView 圓形進度框

屬性有:

//進度值
@property (nonatomic, assign) float progress;

//進度條顏色
@property (nonatomic, strong) UIColor *progressTintColor;

//圓形邊框的顏色
@property (nonatomic, strong) UIColor *backgroundTintColor;

//是否是環狀的 
@property (nonatomic, assign, getter = isAnnular) BOOL annular;複製程式碼

annular = false
annular = false
annular = false
annular = false preiOS7
annular = false preiOS7
annular = false preiOS7
annular = true
annular = true
annular = true

這種環形的進度條使用Quartz2D繪製圖。

//獲取當前繪圖上下文
CGContextRef context = UIGraphicsGetCurrentContext();
    BOOL isPreiOS7 = kCFCoreFoundationVersionNumber < kCFCoreFoundationVersionNumber_iOS_7_0;

if (_annular) {
        // 繪製背景圓形邊框
        CGFloat lineWidth = isPreiOS7 ? 5.f : 2.f;
        UIBezierPath *processBackgroundPath = [UIBezierPath bezierPath];
        ...
        CGFloat radius = (self.bounds.size.width - lineWidth)/2;
        CGFloat startAngle = - ((float)M_PI / 2); // -90 degrees
        CGFloat endAngle = (2 * (float)M_PI) + startAngle;
        //使用addArcWithCenter:radius:startAngle:endAngle:clockwise:
        //繪製貝塞爾曲線
        [processBackgroundPath addArcWithCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
        //使用_backgroundTintColor顏色填充和繪製
        [_backgroundTintColor set];
        //繪製圓環路徑
        [processBackgroundPath stroke];
        // 繪製環形進度條
        UIBezierPath *processPath = [UIBezierPath bezierPath];
        ...
        //每次更新process都會在這裡重繪,計算endAngle
        endAngle = (self.progress * 2 * (float)M_PI) + startAngle;
        //使用addArcWithCenter:radius:startAngle:endAngle:clockwise:
        //繪製圓形貝塞爾曲線
        [processPath addArcWithCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
        //使用_progressTintColor顏色填充和繪製
        [_progressTintColor set];
        //繪製進度條
        [processPath stroke];
    } else {
        //繪製背景圓形邊框
        ...
        //使用_progressTintColor顏色畫線
        [_progressTintColor setStroke];
        //使用_backgroundTintColor顏色填充 iOS7之前才起作用
        [_backgroundTintColor setFill];
        CGContextSetLineWidth(context, lineWidth);
        if (isPreiOS7) {
            //iOS7之前使用CGContextFillEllipseInRect方法
            //圓環內有填充顏色
            CGContextFillEllipseInRect(context, circleRect);
        }
        //iOS7之後使用CGContextStrokeEllipseInRect方法
        //圓環內沒有填充顏色
        CGContextStrokeEllipseInRect(context, circleRect);
        // 90 degrees
        CGFloat startAngle = - ((float)M_PI / 2.f);
        // 繪製環形進度條
        if (isPreiOS7) {
            //iOS7 之前畫的是餅圖
            CGFloat radius = (CGRectGetWidth(self.bounds) / 2.f) - lineWidth;
            CGFloat endAngle = (self.progress * 2.f * (float)M_PI) + startAngle;
            [_progressTintColor setFill];
            //繪製餅圖
            CGContextMoveToPoint(context, center.x, center.y);
            CGContextAddArc(context, center.x, center.y, radius, startAngle, endAngle, 0);
            CGContextClosePath(context);
            CGContextFillPath(context);
        } else {
            //iOS7之後畫的只是圓環線
            UIBezierPath *processPath = [UIBezierPath bezierPath];
            processPath.lineCapStyle = kCGLineCapButt;
            processPath.lineWidth = lineWidth * 2.f;
            CGFloat radius = (CGRectGetWidth(self.bounds) / 2.f) - (processPath.lineWidth / 2.f);
            CGFloat endAngle = (self.progress * 2.f * (float)M_PI) + startAngle;
            ////繪製圓形貝塞爾曲線
            [processPath addArcWithCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
            CGContextSetBlendMode(context, kCGBlendModeCopy);
            [_progressTintColor set];
            [processPath stroke];
        }
}複製程式碼
  • MBBarProgressView 長條形進度框

屬性有:

//進度值
@property (nonatomic, assign) float progress;

//邊框線顏色  預設是白色
@property (nonatomic, strong) UIColor *lineColor;

//內部空白填充顏色 預設無顏色
@property (nonatomic, strong) UIColor *progressRemainingColor;

//進度條顏色 預設白色
@property (nonatomic, strong) UIColor *progressColor;複製程式碼

MBBarProgressView
MBBarProgressView

這裡的繪製也是基於Quartz2D,但是作者寫的不夠完美,馬上會講到。


CGContextRef context = UIGraphicsGetCurrentContext();

CGContextSetLineWidth(context, 2);
//設定填充顏色 和 畫線顏色 ,供下面選用
CGContextSetStrokeColorWithColor(context,[_lineColor CGColor]);
CGContextSetFillColorWithColor(context, [_progressRemainingColor CGColor]);

//畫背景
CGFloat radius = (rect.size.height / 2) - 2;
//左上角的小圓弧
CGContextMoveToPoint(context, 2, rect.size.height/2);
CGContextAddArcToPoint(context, 2, 2, radius + 2, 2, radius);
//上邊的邊界線
CGContextAddLineToPoint(context, rect.size.width - radius - 2, 2);
//右上角的小圓弧
CGContextAddArcToPoint(context, rect.size.width - 2, 2, rect.size.width - 2, rect.size.height / 2, radius);
//右下角的小圓弧
CGContextAddArcToPoint(context, rect.size.width - 2, rect.size.height - 2, rect.size.width - radius - 2, rect.size.height - 2, radius);
//下邊的邊界線
CGContextAddLineToPoint(context, radius + 2, rect.size.height - 2);
//左下角的小圓弧
CGContextAddArcToPoint(context, 2, rect.size.height - 2, 2, rect.size.height/2, radius);
//使用_progressRemainingColor顏色填充 產生兩頭有弧度的中空區域
CGContextFillPath(context);

//繪製邊界線,路徑跟上面完全一樣,只不過最後用的是stroke方法
CGContextMoveToPoint(context, 2, rect.size.height/2);
CGContextAddArcToPoint(context, 2, 2, radius + 2, 2, radius);
CGContextAddLineToPoint(context, rect.size.width - radius - 2, 2);
CGContextAddArcToPoint(context, rect.size.width - 2, 2, rect.size.width - 2, rect.size.height / 2, radius);
CGContextAddArcToPoint(context, rect.size.width - 2, rect.size.height - 2, rect.size.width - radius - 2, rect.size.height - 2, radius);
CGContextAddLineToPoint(context, radius + 2, rect.size.height - 2);
CGContextAddArcToPoint(context, 2, rect.size.height - 2, 2, rect.size.height/2, radius);
CGContextStrokePath(context);

//繪製進度條    
CGContextSetFillColorWithColor(context, [_progressColor CGColor]);
radius = radius - 2;
CGFloat amount = self.progress * rect.size.width;

// 進度條尾部在中間
if (amount >= radius + 4 && amount <= (rect.size.width - radius - 4)) {
    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, 4, radius + 4, 4, radius);
    CGContextAddLineToPoint(context, amount, 4);
    CGContextAddLineToPoint(context, amount, radius + 4);

    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, rect.size.height - 4, radius + 4, rect.size.height - 4, radius);
    CGContextAddLineToPoint(context, amount, rect.size.height - 4);
    CGContextAddLineToPoint(context, amount, radius + 4);

    CGContextFillPath(context);
}

// 進度條右端的圓弧
else if (amount > radius + 4) {
    CGFloat x = amount - (rect.size.width - radius - 4);

    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, 4, radius + 4, 4, radius);
    CGContextAddLineToPoint(context, rect.size.width - radius - 4, 4);
    CGFloat angle = -acos(x/radius);
    if (isnan(angle)) angle = 0;
    CGContextAddArc(context, rect.size.width - radius - 4, rect.size.height/2, radius, M_PI, angle, 0);
    CGContextAddLineToPoint(context, amount, rect.size.height/2);

    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, rect.size.height - 4, radius + 4, rect.size.height - 4, radius);
    CGContextAddLineToPoint(context, rect.size.width - radius - 4, rect.size.height - 4);
    angle = acos(x/radius);
    if (isnan(angle)) angle = 0;
    CGContextAddArc(context, rect.size.width - radius - 4, rect.size.height/2, radius, -M_PI, angle, 1);
    CGContextAddLineToPoint(context, amount, rect.size.height/2);

    CGContextFillPath(context);
}

// 進度條很短 只畫左端的圓弧
else if (amount < radius + 4 && amount > 0) {
    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, 4, radius + 4, 4, radius);
    CGContextAddLineToPoint(context, radius + 4, rect.size.height/2);

    CGContextMoveToPoint(context, 4, rect.size.height/2);
    CGContextAddArcToPoint(context, 4, rect.size.height - 4, radius + 4, rect.size.height - 4, radius);
    CGContextAddLineToPoint(context, radius + 4, rect.size.height/2);

 CGContextFillPath(context);
}複製程式碼

這裡作者至少有兩個不夠完美的地方:

  1. 繪製邊界線的時候,設定了重複的路徑,僅僅是因為一個子路徑的fill和stroke不可能同時產生效果,誰先呼叫就展示誰的效果。然而作者可能不記得有CGContextDrawPath方法,我們可以完全重複利用子路徑,並註釋CGContextFillPathCGContextStrokePath方法,替換為:

     CGContextDrawPath(context, kCGPathFillStroke);複製程式碼
  2. CGContextAddArcToPoint類似的還有CGContextAddArc方法,區別是前者不僅畫一個圓弧,還會從(x1, y1)' 到(x2, y2)' 畫一條線。所以,用這個方法就沒有必要再用CGContextAddLineToPoint方法去畫線了,顯得多餘。

這兩點我在github上提交issue了,後來被merger到master分支了?

  • MBBackgroundView 背景檢視

屬性有:

//背景風格。 iOS7以後預設的是高斯模糊背景。
//iOS 7(不包括7)之後的模糊圖都是用UIVisualEffectView實現的。
@property (nonatomic) MBProgressHUDBackgroundStyle style;
//背景顏色
@property (nonatomic, strong) UIColor *color;複製程式碼

這個類產生了兩個物件,一個是大的透明的背景,一個是容納所有小檢視的小背景。

流程圖

方法主要就是ShowHide, 下面借用其他地方的一張圖:

859001-fe3f0f393bcc3b9c.png
859001-fe3f0f393bcc3b9c.png

圖片來自網路

初始化的方法都會走到:

- (void)commonInit {
    // Set default values for properties
    ...
    // Default color, depending on the current iOS version
    ...
    // Transparent background
    ...
    // Make it invisible for now
    self.alpha = 0.0f;
    self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    self.layer.allowsGroupOpacity = NO;

    [self setupViews];
    [self updateIndicators];
    [self registerForNotifications];
}複製程式碼

可以看到除了變數初始化之外,主要就是呼叫了三個方法:

  • setupViews

    生成所有的檢視控制元件。其中有個updateBezelMotionEffects方法,是為了使bezelView可以跟隨螢幕傾斜移動。

  • updateIndicators

更新indicator樣式。每次更新MBProgressHUDMode時都會呼叫。作者用了簡單的if else方式來處理不同的hudModel的indicator樣式

showUsingAnimation:方法中還呼叫了setNSProgressDisplayLinkEnabled:方法:

 - (void)setNSProgressDisplayLinkEnabled:(BOOL)enabled {
    // 使用 CADisplayLink來重新整理progress, 它會以與顯示器的重新整理介面相同的頻率進行繪圖
    if (enabled && self.progressObject) {
        if (!self.progressObjectDisplayLink) {
            self.progressObjectDisplayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateProgressFromProgressObject)];
        }
    } else {
        self.progressObjectDisplayLink = nil;
    }
}複製程式碼

可以去看看CADisplayLink與NSTimer的區別

然後定時地呼叫updateProgressFromProgressObject方法,進而呼叫各種indicator的setProgress方法去重繪。

自動佈局

MBProgressHUD裡用到NSLayoutConstraint來自動佈局,主要涉及到的是updateConstraintsupdatePaddingConstraints方法。

大致流程可以描述為:

  1. 先移除現有的約束設定
  2. bezel始終處於中心位置的約束
  3. 確保邊界最小空間間隔
  4. 確保bezel的最小尺寸
  5. bezel是否正方形的約束
  6. 上下間隔約束
  7. 各subView的約束

其中用到最多的方法就是:

/* Create constraints explicitly.  Constraints are of the form "view1.attr1 = view2.attr2 * multiplier + constant" 
 If your equation does not have a second view and attribute, use nil and NSLayoutAttributeNotAnAttribute.
 */
+(instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 relatedBy:(NSLayoutRelation)relation toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 multiplier:(CGFloat)multiplier constant:(CGFloat)c;複製程式碼

釋義以及很清楚了,就不再解釋了。

動畫

在顯示和隱藏HUD的時候有動畫效果。
ZoomIn,ZoomOut分別理解為拉近鏡頭,拉遠鏡頭
因此MBProgressHUDAnimationZoomIn先把形變縮小到0.5倍,再恢復到原狀,產生放大效果。
反之MBProgressHUDAnimationZoomOut先把形變放大到1.5倍,再恢復原狀,產生縮小效果。
要注意的是,形變的是整個MBProgressHUD,而不是中間可視部分。

動畫用到的transform可以參考CGAffineTransform

三個Timer

轉載自JKnight

@property (nonatomic, weak) NSTimer *graceTimer; //執行一次:在show方法觸發後到HUD真正顯示之前,前提是設定了graceTime,預設為0
@property (nonatomic, weak) NSTimer *minShowTimer;//執行一次:在HUD顯示後到HUD被隱藏之前
@property (nonatomic, weak) NSTimer *hideDelayTimer;//執行一次:在HUD被隱藏的方法觸發後到真正隱藏之前複製程式碼
  • graceTimer:用來推遲HUD的顯示。如果設定了graceTime,那麼HUD會在show方法觸發後的graceTime時間後顯示。它的意義是:如果任務完成所消耗的時間非常短並且短於graceTime,則HUD就不會出現了,避免HUD一閃而過的差體驗。
  • minShowTimer:如果設定了minShowTime,就會在hide方法觸發後判斷任務執行的時間是否短於minShowTime。因此即使任務在minShowTime之前完成了,HUD也不會立即消失,它會在走完minShowTime之後才消失,這應該也是避免HUD一閃而過的情況。
  • hideDelayTimer:用來推遲HUD的隱藏。如果設定了delayTime,那麼在觸發hide方法後HUD也不會立即隱藏,它會在走完delayTime之後才隱藏。

這三者的關係可以由下面這張圖來體現(並沒有包含所有的情況):

859001-c9f49bfcec64dd0e.png
859001-c9f49bfcec64dd0e.png

相關文章