(原文:Custom Control for iOS Tutorial: A Reusable Knob 作者:Sam Davies 譯者:培子 )
當你的APP需要一些新功能時,自定義UI控件會十分有用,尤其是這些自定義控件可以在其他APP裡面很好的重用。Colin Eberhart寫過一篇很棒的介紹自定義UI控件的教程。這個教程涉及的是一個繼承自UISlider類的自定義控件的生成;該控件的功能是給定一個(滑動)范圍供(用戶滑動)選擇,並返回一個(與滑動位置相對應的)固定值。
本篇基於iOS 7的自定義UI教程在Colin Eberhart那篇的基礎上更深入一步;受調音台旋鈕的啟發,這裡介紹如何制作一個功能類似UISlider的圓形旋轉控件。
UIKit框架裡的UISlider控件就是供用戶在一個給定的范圍內設置一個浮動的值。如果用過iOS設備,你就會知道UISlider控件可以用來設置音量、屏幕亮度,或者其他一些(在一定范圍內浮動)的變量。在這篇教程裡建立的項目將會實現同樣的功能,只不過不是線性滑動的Slider,而是圓形旋轉的Slider,就像之前提到的旋鈕。
行動起來吧!
首先,下載這個項目文件。這是一個簡單的single-view APP,Storyboard裡包含一些控件並捆綁在主視圖控制器。在之後演示旋鈕控件的不同特性時,你會用到這些控件。在正式的編寫代碼之前,我們先構建運行一下APP,對每個控件的呈現有個大致的了解;它看起來應該是下面這個樣子:
首先新建一個旋鈕類,點擊“File\New\File…”,然後選擇“iOS\Cocoa Touch\Objective-C class”。在之後的界面上,把類命名為RWKnobControl,並讓它繼承UIControl。點擊Next,選擇“KnobControl”目錄,最後點擊“Create”。
在為新控件編寫代碼之前,你應該把它添加到視圖中,方便查看。打開RWViewController.m,把下面的代碼導入到文件頂部:
#import "RWKnobControl.h"
然後在@interface 私有擴展區裡,如下添加一個實例變量:
@interface RWViewController () { RWKnobControl *_knobControl; } @end
這個變量是對RWKnobControl的引用
接下來,重寫viewDidLoad,如下:
- (void)viewDidLoad { [super viewDidLoad]; _knobControl = [[RWKnobControl alloc] initWithFrame:self.knobPlaceholder.bounds]; [self.knobPlaceholder addSubview:_knobControl]; }
上面所做的就是創建一個RWKnobControl 實例,並把它加入到故事板視圖裡。knobPlaceholder屬性已經與故事板裡的視圖對象建立了聯接。
打開RWKnobControl.m文件,重寫initWithFrame:方法:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code self.backgroundColor = [UIColor blueColor]; } return self; }
上面的代碼設置了knob控件的背景顏色,這樣你就能清楚的在屏幕上看到它了。
運行你的APP,你會看到下面的內容:
現在,你的APP大體的布局已經搭建完成。
下面開始為你的控件搭建API吧!
我們創建一個自定義控件的初衷就是想獲得一個方便可重用的組件。前期,多花些時間為控件設計一套好的API函數接口是值得的;其他開發者在使用你的控件時,可以直接從控件的API上理解怎麼運用它,而不需要再去看裡面的源代碼。這意味你同樣需要創建一個有關控件的API文檔。
自定義控件的頭文件包含所有可供調用的API函數接口。在這裡,就是RWKnobControl.h。
打開RWKnobControl.h,把下面的代碼添加到@interface和@end之間:
#pragma mark - Knob value /** Contains the current value */ @property (nonatomic, assign) CGFloat value; /** Sets the value the knob should represent, with optional animation of the change. */ - (void)setValue:(CGFloat)value animated:(BOOL)animated; #pragma mark - Value Limits /** The minimum value of the knob. Defaults to 0. */ @property (nonatomic, assign) CGFloat minimumValue; /** The maximum value of the knob. Defaults to 1. */ @property (nonatomic, assign) CGFloat maximumValue; #pragma mark - Knob Behavior /** Contains a Boolean value indicating whether changes in the value of the knob generate continuous update events. The default value is `YES`. */ @property (nonatomic, assign, getter = isContinuous) BOOL continuous;
value,minimumValue 和 maximumValue 是控件的基本操作參數
setValue:animated: 和 continuous 直接參照UISlider控件;因為knob控件實現的功能和UISlider類似,所以API也應保持一致
setValue:animated:可以用程序為你的knob控件賦值,而另外的BOOL參數表示是否動態的改變value屬性的值。
如果continuous設為YES,那麼在值改變時,控件會重復的回調;如果設為NO,那麼只有在用戶結束交互操作時,控件才會執行一次回調。
備注:如果想對方法用不同的名字進行訪問,你最好通過“動作“+”屬性名“的方式來命名你的方法。當前,屬性是Boolean類型(YES / NO),通常getter就是以”is“開頭;而getter獲取的屬性名是continuous,最終的名字就是isContinuous。
因為這篇教程的後面部分會在此基礎上繼續拓展,所以你需要確保這些屬性方法能正確的運行。盡管只有短短的五行代碼,但由於附加了額外的代碼備注,造成了RWKnobControl看上去篇幅很長。這些備注看上去沒多大用處,但是它們在用戶獲取屬性方法時給予相應提示,像下面這樣:
不論對你、你的團隊成員、還是其他人來說,上述的代碼提示能幫助開發者在使用該控件時節省大量的時間!
打開RWKnobControl.m,在initWithFrame:方法下面添加如下代碼:
#pragma mark - API Methods - (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); } } #pragma mark - Property overrides - (void)setValue:(CGFloat)value { // Chain with the animation method version [self setValue:value animated:NO]; }
這裡重寫value的setter方法的目的是把它的值直接傳遞給setValue:animated:方法。該方法目前沒有保證屬性值是介於控件限定的范圍之內。還有你的API文檔應該指定一些默認值。為了實現這些方法,下面更新RWKnobControl.m的initWithFrame:方法:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code self.backgroundColor = [UIColor blueColor]; _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; } return self; }
既然你已經給控件定義好了它的API,下面是時候在視覺設計上下功夫了。
Colin的教程裡用了CoreGraphics和圖片兩種途徑來設置控件外觀。然而,不止上面兩種方法;本篇教程將引入第三種方法來設置控件外觀:CoreAnimation的layer。
每當你使用一個UIView,視圖內容都是繪制在CALayer上的。CALayer能幫助iOS系統優化圖形集渲染。它管理顯示各種視圖內容,並且在執行各種類型的動畫時,擁有令人難以置信的高效率!(Amazing)
Knob控件由兩個CALayer對象組成:一個是滑動軌跡圖層,一個是滑動指針圖層。之後你將看到,這會帶來很棒的動畫表現。
下面的圖闡明了knob控件的基本構造:
上圖中,藍色和紅色正方形分別代表兩個CALayer對象;藍色圖層包含knob控件的滑動軌跡,紅色圖層包含滑動指針。兩個圖層疊在一起就如預期那樣生成了一個可滑動knob的外觀。上述圖層兩種不同的背景顏色僅僅只是為了表征控件兩個不同的圖層——實際創建時不必如此。
使用兩個獨立圖層的原因也是顯而易見:你需要移動指針到一個新的值。你要的做就是旋轉那個包含指針的圖層,即上面圖中的紅色圖層。
對CoreAnimation來說,旋轉圖層是一件資源消耗小且操作簡便的事情。而如果你選擇使用CoreGraphics,重寫drawRect:方法,那麼knob控件在動畫執行的每個階段都會被重復渲染。這樣的操作所消耗的資源是十分巨大的,尤其當knob控件的value值改變引起APP裡的其他動作,甚至會造成動畫卡頓現象。
下面創建一個類,用來編寫控件渲染相關的代碼。
點擊“File\New\File…“選擇”iOS\Cocoa Touch\Objective-C class“,命名為”RWKnobRenderer“並讓它繼承NSObject。單擊”Next“並把文件保存在默認的目錄下。
打開RWKnobRenderer.h文件,在@interface和@end之間添加如下代碼:
#pragma mark - Properties associated with all parts of the renderer @property (nonatomic, strong) UIColor *color; @property (nonatomic, assign) CGFloat lineWidth; #pragma mark - Properties associated with the background track @property (nonatomic, readonly, strong) CAShapeLayer *trackLayer; @property (nonatomic, assign) CGFloat startAngle; @property (nonatomic, assign) CGFloat endAngle; #pragma mark - Properties associated with the pointer element @property (nonatomic, readonly, strong) CAShapeLayer *pointerLayer; @property (nonatomic, assign) CGFloat pointerAngle; @property (nonatomic, assign) CGFloat pointerLength;
和代表兩個圖層的兩個CAShapeLayer屬性一起,這裡的大多數屬性都是用來處理控件的視覺外觀,這些屬性控制了knob控件的整個外觀。
切換到RWknobRenderer.m文件,在@implementation和@end之間添加如下代碼:
- (id)init { self = [super init]; if (self) { _trackLayer = [CAShapeLayer layer]; _trackLayer.fillColor = [UIColor clearColor].CGColor; _pointerLayer = [CAShapeLayer layer]; _pointerLayer.fillColor = [UIColor clearColor].CGColor; } return self; }
這樣就創建了兩個圖層,並把它們設置為透明。構成knob控件的兩個圖形(軌跡、指針)由CAShapeLayer對象生成。CAShapeLayer類是CALayer的子類,它能夠用抗鋸齒和光柵優化來繪制貝塞爾曲線。這使得CAShapeLayer類能極其高效的繪制任意圖形。
接著在init方法後,添加下面兩個方法:
- (void)updateTrackShape { CGPoint center = CGPointMake(CGRectGetWidth(self.trackLayer.bounds)/2, CGRectGetHeight(self.trackLayer.bounds)/2); CGFloat offset = MAX(self.pointerLength, self.lineWidth / 2.f); CGFloat radius = MIN(CGRectGetHeight(self.trackLayer.bounds), CGRectGetWidth(self.trackLayer.bounds)) / 2 - offset; UIBezierPath *ring = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:self.startAngle endAngle:self.endAngle clockwise:YES]; self.trackLayer.path = ring.CGPath; } - (void)updatePointerShape { UIBezierPath *pointer = [UIBezierPath bezierPath]; [pointer moveToPoint:CGPointMake(CGRectGetWidth(self.pointerLayer.bounds) - self.pointerLength - self.lineWidth/2.f, CGRectGetHeight(self.pointerLayer.bounds) / 2.f)]; [pointer addLineToPoint:CGPointMake(CGRectGetWidth(self.pointerLayer.bounds), CGRectGetHeight(self.pointerLayer.bounds) / 2.f)]; self.pointerLayer.path = pointer.CGPath; }
updateTrackShape:方法生成一段弧,需要參數:弧的起始角度、適合圖層大小的弧半徑。創建完成後把它置於trackLayer圖層的中心。只要創建了UIBezierPath對象,你就可以使用它的CGPath屬性賦值給相應CAShapeLayer的path屬性。
在CoreGraphics中,CGPathRef等同於UIBezierPath。考慮到UIBezierPath擁有比較便捷的API函數接口,這裡就使用它來生成路徑,然後把它轉換成CoreGraphics類型。
updatePointerShape:方法為指針圖層生成一個路徑,並把它放置在弧度為0的地方。再次創建一個UIBezierPath對象,然後轉換成CGPathRef,並把它賦值給對應CAShapeLayer對象的path屬性。因為滑動指針就是一條簡單的直線,你所做的就是調用moveToPoint:和addLineToPoint:方法來繪制線條。
當這些屬性中的任何一個被修改時,必須調用這些方法重復繪制這兩個圖層。為了實現這個功能,你需要重寫一些之前定義的屬性setter方法。
在updatePointShape方法添加如下代碼:
- (void)setPointerLength:(CGFloat)pointerLength { if(pointerLength != _pointerLength) { _pointerLength = pointerLength; [self updateTrackShape]; [self updatePointerShape]; } } - (void)setLineWidth:(CGFloat)lineWidth { if(lineWidth != _lineWidth) { _lineWidth = lineWidth; self.trackLayer.lineWidth = lineWidth; self.pointerLayer.lineWidth = lineWidth; [self updateTrackShape]; [self updatePointerShape]; } } - (void)setStartAngle:(CGFloat)startAngle { if(startAngle != _startAngle) { _startAngle = startAngle; [self updateTrackShape]; } } - (void)setEndAngle:(CGFloat)endAngle { if(endAngle != _endAngle) { _endAngle = endAngle; [self updateTrackShape]; } }
setPointerLength:和setLineWidth:方法都會影響軌跡和指針視圖,因此只要有新的值被賦值給相關屬性的時候,就會調用updateTrackShape和updatePointerShape方法重繪視圖。然而,起始角度兩個屬性只英雄弧形軌跡,所以在改變這兩個屬性值的時候,只需要調用updateTrackShape方法就行。
這下ok了,當這些屬性被重新賦值時,knob控件也會適時的更新視圖了。到目前為止,在CAShapeLayer渲染時,color屬性依然沒有調用。下面就來使用它,在setEndAngle:添加如下代碼
- (void)setColor:(UIColor *)color { if(color != _color) { _color = color; self.trackLayer.strokeColor = color.CGColor; self.pointerLayer.strokeColor = color.CGColor; } }
這與重寫其他屬性方法類似;區別是這次knob不需要再重新繪制,而是設置了軌跡和指針的strokeColor。CAShapeLayer對象需要的是CGColorRef對象,所以這裡用了UIColor的CGColor屬性方法獲取該對象。
你也許注意到下面還有兩個更新shape圖層路徑的方法,它們需要一個從未被設置的參數,即shape圖層的bounds屬性.
CAShapeLayer是由alloc-init生成的,到目前它還沒有固定bounds。
在 RWKnobRenderer.h的 @end之前添加如下代碼:
- (void)updateWithBounds:(CGRect)bounds;
切換到RWKnobRenderer.m,在@end後實現上面方法:
- (void)updateWithBounds:(CGRect)bounds { self.trackLayer.bounds = bounds; self.trackLayer.position = CGPointMake(CGRectGetWidth(bounds)/2.0, CGRectGetHeight(bounds)/2.0); [self updateTrackShape]; self.pointerLayer.bounds = self.trackLayer.bounds; self.pointerLayer.position = self.trackLayer.position; [self updatePointerShape]; }
上述方法獲取一個矩形邊界,並重新設定了layer的大小來確保layer處於矩形bounds的中心位置。當改變了一個影響路徑的屬性時,你必須調用上面的update方法。
盡管RWKnobRenderer還沒有全部完成,但我們現在依然可以對knob控件先睹為快啦。切換到RWKnobControl.m,在文件頂部添加如下代碼:
#import "RWKnobRenderer.h"
接著,為該類添加一個RWKnobRenderer實例變量的屬性,如下:
@implementation RWKnobControl { RWKnobRenderer *_knobRenderer; }
接著在該類的@end之後,添加如下代碼:
- (void)createKnobUI { _knobRenderer = [[RWKnobRenderer alloc] init]; [_knobRenderer updateWithBounds:self.bounds]; _knobRenderer.color = self.tintColor; _knobRenderer.startAngle = -M_PI * 11 / 8.0; _knobRenderer.endAngle = M_PI * 3 / 8.0; _knobRenderer.pointerAngle = _knobRenderer.startAngle; [self.layer addSublayer:_knobRenderer.trackLayer]; [self.layer addSublayer:_knobRenderer.pointerLayer]; }
上述方法創建了RWKnobRenderer對象,並通過updateWithBounds方法把RWKnobControl的bounds賦給了它,之後把RWKnobRenderer的兩個layer作為子layer添加到knob控件的layer中。然後臨時給render對象設置了startAngle和endAngle,以便渲染視圖。
現在你依然看不到想要的結果,還差一步,即在knob控件構建時,你需要調用createKnobUI方法。在initWithFrame:方法的_continuous=YES後面加上如下代碼:
[self createKnobUI];
或者把下面這行代碼刪除:
self.backgroundColor =[UIColor blueColor];
現在的initWithFrame:方法應該是下面這個樣子:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; [self createKnobUI]; } return self; }
Build你的APP,knob控件如下:
這當然不是最終成品,只是讓你看下knob控件大體外觀。
目前,開發者沒法更改控件的外觀,因為所有涉及外觀的屬性都被封裝在RWKnobRenderer類中,在knob控件沒有訪問接口。
為了修正這個問題,在RWKnobControl.h@end之後添加如下代碼:
/** Specifies the angle of the start of the knob control track. Defaults to -11π/8 */ @property (nonatomic, assign) CGFloat startAngle; /** Specifies the end angle of the knob control track. Defaults to 3π/8 */ @property (nonatomic, assign) CGFloat endAngle; /** Specifies the width in points of the knob control track. Defaults to 2.0 */ @property (nonatomic, assign) CGFloat lineWidth; /** Specifies the length in points of the pointer on the knob. Defaults to 6.0 */ @property (nonatomic, assign) CGFloat pointerLength;
慣例,我給這些屬性都加了代碼備注,這樣開發者在使用時可以看到相關提示,節省時間成本。這四個屬性與render的相關屬性一一對應。考慮到控件本身不需要存儲這些屬性的變量,這些屬性直接從render那裡獲取值就ok了。
切換到RWKnobControl.m在initWithFrame:之前添加如下代碼:
@dynamic lineWidth; @dynamic startAngle; @dynamic endAngle; @dynamic pointerLength;
@dynamic修飾符告訴編譯器不用考慮這些屬性的存取,因為下面會手動的為這些屬性添加getter和setter方法。為此,在setValue添加如下代碼:
- (CGFloat)lineWidth { return _knobRenderer.lineWidth; } - (void)setLineWidth:(CGFloat)lineWidth { _knobRenderer.lineWidth = lineWidth; } - (CGFloat)startAngle { return _knobRenderer.startAngle; } - (void)setStartAngle:(CGFloat)startAngle { _knobRenderer.startAngle = startAngle; } - (CGFloat)endAngle { return _knobRenderer.endAngle; } - (void)setEndAngle:(CGFloat)endAngle { _knobRenderer.endAngle = endAngle; } - (CGFloat)pointerLength { return _knobRenderer.pointerLength; } - (void)setPointerLength:(CGFloat)pointerLength { _knobRenderer.pointerLength = pointerLength; }
上述代碼看上去有點冗長,但實際上十分簡單易懂,就是通過為上述RWKnobControl四個屬性添加getter、setter方法,把它們與render顯示相關屬性的存取關系一一對應起來。是不是很easy!
因為在knob控件的代碼備注裡,每個屬性都有默認值,那麼作為一名優秀的控件開發者,你應該為這些屬性附上默認值。
更新createKnobUI方法,如下:
- (void)createKnobUI { _knobRenderer = [[RWKnobRenderer alloc] init]; [_knobRenderer updateWithBounds:self.bounds]; _knobRenderer.color = self.tintColor; // Set some defaults _knobRenderer.startAngle = -M_PI * 11 / 8.0; _knobRenderer.endAngle = M_PI * 3 / 8.0; _knobRenderer.pointerAngle = _knobRenderer.startAngle; _knobRenderer.lineWidth = 2.0; _knobRenderer.pointerLength = 6.0; // Add the layers [self.layer addSublayer:_knobRenderer.trackLayer]; [self.layer addSublayer:_knobRenderer.pointerLayer]; }
對比之前的代碼,createKnobUI 僅僅多了兩行設置lineWidth和pointerLength代碼。
Build 你的APP,控件應該是下面這樣:
為了驗證knob控件能否和期望的效果一樣運行,我們在RWViewController.m的viewDidLoad方法裡添加如下代碼:
_knobControl.lineWidth = 4.0; _knobControl.pointerLength = 8.0;
在此運行APP,你會發現控件的軌跡路徑變粗了,指針變長了,如下:
你會注意到控件的API中沒有創建顏色的屬性——那是因為,iOS7的SDK為UIView提供了一個新的屬性:tintColor。實際上,你之前在createKnobUI中用到過這個屬性,這段代碼: _knobRenderer.color = self.tintColor;
因此你會設想在RWViewController的viewDidLoad中添加下面一行代碼就可以更改顏色了:
self.view.tintColor = [UIColor redColor];
假如你真這麼做了,並運行APP,結果會讓你大失所望!然而,UIButton的文字顏色卻改變了,如下:
盡管在UI視圖生成時,設置了renderer的顏色,但是當tintColor改變時,Renderer不會隨之改變。幸運的是,這很好解決!
在RWKnobControl.m的setValue:方法後添加如下代碼:
- (void)tintColorDidChange { _knobRenderer.color = self.tintColor; }
每當你更改一個UIView的tintColor時,渲染器都會調用該UIView的tintColorDidChange方法,以及其沒有手動設置tintColor屬性的子視圖的tintColorDidChangef方法。因此,監聽當前視圖層次的tintColor屬性更新的辦法就是實現它們的tintColorDidChange方法,讓該方法適時更新視圖外觀。
運行APP,你會看見控件的顏色發生了變法,如下:
到目前為止,你的knob控件看上去很不錯,但是它沒有一點實際用處。下一步,你需要繼續完善控件以應對程序交互——即knob控件的屬性值發生改變之時。
此時,當value屬性被直接修改或者調用setValue:animated:時,knob控件的值都會被保存下來。可是這與renderer對象沒有任何聯系,knob控件也沒有再次渲染。
renderer沒有value定義,他負責處理主要是旋轉角度。這就要求你更改RWKnobControl的setValue:animated:方法,讓它能夠把數值轉換成角度,再傳遞給renderer對象。
打開RWKnobControl.m,更改setValue:animated:如下:
- (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); // Now let's update the knob with the correct angle CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat angleForValue = (_value - self.minimumValue) / valueRange * angleRange + self.startAngle; _knobRenderer.pointerAngle = angleForValue; } }
這段代碼實現功能:結合最大值最小值范圍,把給定的值轉換成相應的角度,並把它賦給renderer的pointerAngle屬性。現在暫時忽略animated——後面會解決它。
盡管修改了pointerAngle屬性值,但是這對knob控件依然沒什麼作用。當設置指針角度時,指針所在的圖層應該旋轉對應的角度,讓用戶感覺到指針發生了移動。
在RWKnobRenderer.m的@end後添加如下代碼:
- (void)setPointerAngle:(CGFloat)pointerAngle { self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); }
該方法做了一次簡單旋轉變換,讓指針圖層繞Z軸旋轉給定角度。這裡要注意的是CALayer的transform屬性最好賦值為CATransform3D對象,不要像給UIView賦的CGAffineTransform對象。這意味著你能對圖層做三維變換。
備注:CGAffineTransform 用的是3x3矩陣,而CATransform3D用的是4x4矩陣;因為Z軸變換需要額外的數值。3D轉換的核心算法就是矩陣與矩陣的乘法運算。詳細請參考這篇維基文章。
為了演示這些變換是否起作用,下面把在項目開始時提到的UISlider控件與knob控件聯系起來。當你調整UISlider控件時,knob控件的值發生相應變化。
UISlider控件已經與handleValueChanged:方法建立了聯接,所以你只需要編寫該法的執行內容即可,如下:
- (IBAction)handleValueChanged:(id)sender { _knobControl.value = self.valueSlider.value; }
運行APP,改變UISlider的值,knob控件的指針會移動到相應位置,如下:
有個額外的驚喜——knob控件有動畫,盡管我們沒有編寫任何相關動畫代碼!什麼原因?
答案是你的操作觸發了CoreAnimation的隱式動畫。當你在修改CALayer的一些特定屬性時,比如transform——那麼圖層屬性會平滑的從當前值過度到目標值。一般來說,這種特性很cool,不需要寫任何代碼就實現了動畫。然而,假如需要對knob控件多一點掌控,那你需要自己實現動畫了。
更新setPointerAngle:方法,如下:
- (void)setPointerAngle:(CGFloat)pointerAngle { [CATransaction new]; [CATransaction setDisableActions:YES]; self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); [CATransaction commit]; }
你可以在CATransaction(動畫事務)裡更改屬性值,或者禁用動畫交互,來阻止隱式動畫。
在此運行APP,當你滑動UISlider,knob控件會立即響應。
到目前,設置animated=YES對控件沒有任何影響。為了實現該功能,你需要在renderer裡添加對角度變化的動畫處理。
在RWKnobRenderer.h的@end之前添加如下代碼:
- (void)setPointerAngle:(CGFloat)pointerAngle animated:(BOOL)animated;
在RWKnobRenderer.m的@end前添加如下實現:
- (void)setPointerAngle:(CGFloat)pointerAngle animated:(BOOL)animated { [CATransaction new]; [CATransaction setDisableActions:YES]; self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); if(animated) { // Provide an animation // Key-frame animation to ensure rotates in correct direction CGFloat midAngle = (MAX(pointerAngle, _pointerAngle) - MIN(pointerAngle, _pointerAngle) ) / 2.f + MIN(pointerAngle, _pointerAngle); CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"transform.rotation.z"]; animation.duration = 0.25f; animation.values = @[@(_pointerAngle), @(midAngle), @(pointerAngle)]; animation.keyTimes = @[@(0), @(0.5), @(1.0)]; animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; [self.pointerLayer addAnimation:animation forKey:nil]; } [CATransaction commit]; _pointerAngle = pointerAngle; }
該方法看上去有點復雜,但當你把它分解開來分析時,還是比較易懂的。如果把if(animated)忽略掉,那麼這段代碼與之前的setAngle沒有區別。
這裡的不同之處就是在把animated設置為YES的時候;如果用默認的隱式動畫,而不用顯示動畫,那麼(動畫運行的時候)選擇旋轉角度最小的方向。這意味著角度0.98與0.1之間的動畫不會逆時針旋轉,而是順時針旋轉,經過底部(沒有軌跡的地方),這當然不是你需要的!
為了控制懸著方向,你需要用到關鍵幀動畫。這是一種除了開始和結束狀態之外,還有其他動畫狀態的動畫。
CoreAnimation支持關鍵幀動畫;上述方法中,你創建了一個CAKeyFrameAnimation對象,並把transform.rotation.z設為它的keypath。
接著,設置了3個圖層即將要旋轉的3個角度,起始角度,中間角度,結束角度。然後把這三個值存放在數組中賦給values屬性。接著為指針圖層添加動畫,這樣動畫在提交後就會觸發。
現在,你應該更新下setPointerAngle:方法,如下:
- (void)setPointerAngle:(CGFloat)pointerAngle { [self setPointerAngle:pointerAngle animated:NO]; }
既然renderer現在知道了如何處理控件動畫,那麼你可以更新RWKnobControl.m中的setValue:animated:方法,使用renderer,不是自己的屬性了。
把下面這行代碼:
_knobRenderer.pointerAngle = angleForValue;
替換成:
[_knobRenderer setPointerAngle:angleForValue animated:animated];
為了觀察該功能實現情況,你可以使用視圖上的“Random Value”按鈕。該按鈕會使得slider控件和knob控件移動到一個隨機值,並且用視圖上的UISwitch按鈕設置animate屬性值,來決定是否需要動畫。
更新RWViewController .m中的handleRandomButtonPressed:方法如下:
- (IBAction)handleRandomButtonPressed:(id)sender { // Generate random value CGFloat randomValue = (arc4random() % 101) / 100.f; // Then set it on the two controls [_knobControl setValue:randomValue animated:self.animateSwitch.on]; [self.valueSlider setValue:randomValue animated:self.animateSwitch.on]; }
該方法生成了一個0到1之間的隨機值,並把它賦給slider控件和knob控件。然後檢察animateSwitch的on屬性來決定是否需要動畫。
運行APP,在animate為on的情況下,多次點擊Random Value按鈕,然後再animate為off的情況下,多次點擊Random Value按鈕,仔細觀察不同之處。
KVO就是當NSObject對象的屬性發生變化時,會讓你接收相關通知。盡管KVO不是UI控件做選擇交互的必要條件,但它不失為一個好辦法——它會帶來很多有意思的事情!
為了實現該功能,你需要與視圖上的的label文本框建立關聯,它用來顯示knob控件選擇的值。
打開RWViewController.m,在viewDidLoad的結尾處添加如下代碼:
[_knobControl addObserver:self forKeyPath:@"value" options:0 context:NULL];
這樣使得每當knob控件的value值改變時,RWViewController都會收到通知。
為了能收到通知,在RWViewController.m的@end之前添加如下代碼:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if(object == _knobControl && [keyPath isEqualToString:@"value"]) { self.valueLabel.text = [NSString stringWithFormat:@"%0.2f", _knobControl.value]; } }
該方法首先判斷通知是否來自knob控件的value屬性,然後再更新視圖上的label文本框,顯示當前的value值。
運行APP,滑動UISlider,你會發現label的顯示內容發生了改變,如下:
看上去棒極了!然而,點擊“Random Value”按鈕,盡管slider喝knob控件改變了,但是label文本框裡的內容並沒有改變!為什麼會這樣?
你的APP使用了不同的方法為knob控件賦值。UISlider用的是setValue:屬性方法,而“RandomValue”按鈕使用的是setValue:animated:方法。確實,KVO是NSObject的一部分,但是只有屬性的setter方法才能觸發它,即setValue: 。自己創建的附帶animation參數的方法無法觸發它。
為了解決這個問題,你需要為knob控件的value屬性設置自己的KVO通知。在RWKnobcontrol.m方法@end之後添加如下代碼:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { if ([key isEqualToString:@"value"]) { return NO; } else { return [super automaticallyNotifiesObserversForKey:key]; } }
這個方法是NSObject的,你可以重寫它。代碼對特殊的鍵做了判斷,假如是value鍵,那麼返回NO,我們會手動處理這個鍵值改變。
NSObject有兩個方法會手動觸發鍵值改變的通知:willChangeValueForKey:和didChangeValueForKey:。更新RWKnobControl.m的setValue:animated:方法,如下:
- (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { [self willChangeValueForKey:@"value"]; // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); // Now let's update the knob with the correct angle CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat angleForValue = (_value - self.minimumValue) / valueRange * angleRange + self.startAngle; [_knobRenderer setPointerAngle:angleForValue animated:animated]; [self didChangeValueForKey:@"value"]; } }
上面的方法添加了兩行代碼:一行是調用willChangeValueForKey:另一行是調用didChangeValueForKey:。為knob控件設置value之前,調用willChangeValueForKey:,完成設置之後,調用didChangeValueForKey:。
運行APP,點擊RandomValue 按鈕,你會發現label文字會和預期的一樣,發生了改變。現在knob控件能對兩個改變value的方法發送KVO通知。
到目前為止,knob控件能對程序交互做出很棒的反應,但是這個功能對於一個UI控件來說,不是十分的有用。最後這個部分,你會看到如何為knob控件添加一個用戶自定義的手勢交互。
當你在iOS設備的屏幕上觸摸時,操作系統會給相應的對象,發送一系列UITouch事件。當一個添加了手勢識別的視圖被觸摸時,這些手勢識別對象會接收到觸摸事件。手勢識別對象會判斷給定的觸摸事件序列是否匹配指定類型的事件;假如匹配了,它們會給指定對象發送一個動作消息。
Apple已經提供了一些已經定義好的手勢識別,比如點擊,拖動,縮放。然而,沒有處理knob控件單指旋轉的手勢識別。看上去,只能靠自己來創建這個手勢識別了。
新建一個類,點擊“File\New\File…”,選擇”iOS\Cocoa Touch\Objective-C class”。接著,把類名設為RWRotationGestureRecognizer,並讓它繼承UIPanGestureRecognizer。選擇KnobControl目錄,點擊創建。
這個自定義手勢識別和拖動手勢有點像,它將追蹤單個手指在屏幕上的拖動,並隨時更新觸摸點位置。也就是這個原因,讓它繼承自UIPanGestureRecognizer。
打開RWRotationGestureRecognizer.h,添加如下屬性:
@interface RWRotationGestureRecognizer : UIPanGestureRecognizer @property (nonatomic, assign) CGFloat touchAngle; @end
touchAngle表示的是當前觸摸點與添加該手勢的視圖中心點連線,與水平方向的夾角,如下如:
當繼承UIGestureRecognizer類時,有3個方法比較有用;它們各自代表開始觸摸,移動,結束觸摸。
在RWRotationGestureRecognizer.m文件頭部添加如下的引用:
#import < UIKit/UIGestureRecognizerSubclass.h>
你只對觸摸開始和移動感興趣。在RWRotationGestureRecognizer.m的@implementation和@end之間添加如下代碼:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesBegan:touches withEvent:event]; [self updateTouchAngleWithTouches:touches]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesMoved:touches withEvent:event]; [self updateTouchAngleWithTouches:touches]; }
兩個方法均調用了相應的父方法,然後又均調用了自定義方法。下面在上面兩個方法之後,添加這個自定義方法:
- (void)updateTouchAngleWithTouches:(NSSet *)touches { UITouch *touch = [touches anyObject]; CGPoint touchPoint = [touch locationInView:self.view]; self.touchAngle = [self calculateAngleToPoint:touchPoint]; } - (CGFloat)calculateAngleToPoint:(CGPoint)point { // Offset by the center CGPoint centerOffset = CGPointMake(point.x - CGRectGetMidX(self.view.bounds), point.y - CGRectGetMidY(self.view.bounds)); return atan2(centerOffset.y, centerOffset.x); }
UpdateTouchAngleWithTouches: 獲取觸摸點集合,調用anyObject方法提取一個觸摸點。之後用locationInView:方法將觸摸點的坐標轉化為添加了該手勢的視圖坐標系坐標。接著,調用calculateAngleToPoint:方法更新touchAngle屬性,這個方法使用一些簡單的幾何運算來計算角度,如下:
x和y分別表示控件內觸摸點在水平方向和豎直方向的位置。觸摸角度的正切值就等於h/w,所以為了計算出touchAngle,你需要計算如下兩個長度:
h = y - (view height) / 2 (角度在順時針方向遞增)
w = x - (view width) / 2
calculateAngleToPoint:完美得為你解決了這個計算,並把角度返回給你。
該自定義的手勢識別一次只能處理單個手指觸摸事件。在@implementation之後添加重寫如下方法:
- (id)initWithTarget:(id)target action:(SEL)action { self = [super initWithTarget:target action:action]; if(self) { self.maximumNumberOfTouches = 1; self.minimumNumberOfTouches = 1; } return self; }
這個構造函數把手勢識別的默認手指數設置為1.
在完成創建自定義手勢識別後,你需要把它添加到knob控件中。在RWKnobControl.m文件頂部添加如下引用:
#import "RWRotationGestureRecognizer.h"
為knob控件添加一個自定義手勢識別的變量,更新@implementation,如下:
@implementation RWKnobControl { RWKnobRenderer *_knobRenderer; RWRotationGestureRecognizer *_gestureRecognizer; }
在initWithFrame:的if語句中,添加如下代碼:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; _gestureRecognizer = [[RWRotationGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)]; [self addGestureRecognizer:_gestureRecognizer]; [self createKnobUI]; } return self; }
相比之前,僅僅多了兩行代碼:一行創建了手勢識別對象,並指定在被觸發時它的回調對象,一行是將它添加到視圖中。
依然在RWKnobControl.m,在@end之前添加手勢處理函數:
- (void)handleGesture:(RWRotationGestureRecognizer *)gesture { // 1. Mid-point angle CGFloat midPointAngle = (2 * M_PI + self.startAngle - self.endAngle) / 2 + self.endAngle; // 2. Ensure the angle is within a suitable range CGFloat boundedAngle = gesture.touchAngle; if(boundedAngle > midPointAngle) { boundedAngle -= 2 * M_PI; } else if (boundedAngle < (midPointAngle - 2 * M_PI)) { boundedAngle += 2 * M_PI; } // 3. Bound the angle to within the suitable range boundedAngle = MIN(self.endAngle, MAX(self.startAngle, boundedAngle)); // 4. Convert the angle to a value CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat valueForAngle = (boundedAngle - self.startAngle) / angleRange * valueRange + self.minimumValue; // 5. Set the control to this value self.value = valueForAngle; }
這個方法看上去篇幅很長,十分繁瑣,但是內容確實相當簡單——它提取了自定義手勢識別的角度,把它轉化成knob控件的角度范圍內的對應值,然後賦值給knob控件,最終會導致控件視圖發生更新。浏覽上述加過評論的代碼,你會發現:
計算起始角度和結束角度的中間值,這個值不在knob控件的軌跡上,相反,這個值表示的是控件最大值和最小值之間應該跳過的值
從手勢識別獲取的角度是介於-π和π之間,因為它是用反正切函數算出來的。然而,我們需要的是介於起始角度和結束角度之間的值。因此,創建一個新的boundedAngle變量,通過調試它來確保我們獲得給定范圍內的角度。
更改boundedAngle值,確保它在指定角度范圍內。
把角度轉換成一個對應值,就像你之前在setValue:animated:所做的那樣。
最終,把計算所得值賦給knob控件的value屬性。
運行APP,擺弄擺弄你的knob控件,測試一下手勢識別。當你在控件的周圍滑動時,控件指針會跟隨你的手指。很不是很cool!
當你移動指針時,你會注意到UISlider控件沒有做出對應改變。你可以通過UIControl的目標動作對模式將它們之間建立起關聯。
打開RWViewController.m,在viewDidLoad方法中添加如下代碼:
// Hooks up the knob control [_knobControl addTarget:self action:@selector(handleValueChanged:) forControlEvents:UIControlEventValueChanged];
這是為UIControl對象添加動作偵聽的標准代碼;這裡我們用來偵聽值改變事件。
當前的handleValueChanged:方法只處理valueSlider值得改變。修改一下該方法,如下:
- (IBAction)handleValueChanged:(id)sender { if(sender == self.valueSlider) { _knobControl.value = self.valueSlider.value; } else if(sender == _knobControl) { self.valueSlider.value = _knobControl.value; } }
現在handleValueChanged:方法會判斷方法的sender參數,然後根據結果把一個控件的值賦給另一個。如果用戶改變了knob控件的值,那麼slider控件視圖會做出相應改變,反之亦然。
運行APP,滑動knob控件…….(難道是打開的方式不對?TAT)沒有任何變化。腫麼回事!找到原因了,原來是knob控件本身沒有發送這個動作消息。
搞定它!NOW!
打開RWKnobControl.m,在handleGesture:方法添加如下代碼:
// Notify of value change if (self.continuous) { [self sendActionsForControlEvents:UIControlEventValueChanged]; } else { // Only send an update if the gesture has completed if(_gestureRecognizer.state == UIGestureRecognizerStateEnded || _gestureRecognizer.state == UIGestureRecognizerStateCancelled) { [self sendActionsForControlEvents:UIControlEventValueChanged]; } }
這篇教程的開始部分,你為knob控件添加了continuous屬性,以確保knob控件和UISlider保持一致。這裡是使用這個屬性的唯一地方。如果continuous為YES,那麼伴隨著手勢識別的每次更新通知,knob控件都會發送對應的動作消息,因此調用sendActionsForControlEvents:方法。
如果continuous設置為NO,只有當手勢識別處於結束或者取消狀態時,動作消息才會被發送。因為控件只關注值的改變,所以動作事件的類型是UIControlEventValueChanged。
運行APP,在此滑動knob控件。wow!UISlider控件也隨著knob控件滑動而移動到對應位置。
謝天謝地,終於成功了!
你的knob控件已經功能完全,你可以將它扔到你的App裡面來加強它的UI和交互,不過,這裡還有許多方式來擴展你的knob控件:
為knob控件添加更多的外觀配置參數——比如你可以用一張圖片來當做指針
在knob控件的中心處添加一個label文本框顯示,顯示當前控件的value值
如果用戶首先觸摸的是knob控件指針時,那麼確保用戶只能與knob控件進行交互。
到目前為止,如果我們重設knob控件的尺寸大小,圖層不會重新渲染。你可以通過僅僅幾行代碼,添加這個功能。
這些提議都蠻有趣的,而且可以幫助你提高iOS的各種特性技能,這些技能在這篇教程中都有遇到過。最好的結果就是你能在創建其他控件的過程中,運用到本篇教程所學到的知識。
你可以下載本篇教程的完整工程文件,或者可以訪問GitHub上的內容。GitHub上為每個構建-運行操作的步驟提交了不同的版本,因而你可以隨心所欲的檢查你的代碼。
(本文為CocoaChina組織翻譯,本譯文權利歸譯者所有,未經允許禁止轉載。)