(原文: 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组织翻译,本译文权利归译者所有,未经允许禁止转载。)