如何用UIKit做一个转轮
原文链接:http://www.raywenderlich.com/9864/how-to-create-a-rotating-wheel-control-with-uikit
TranslatedBy @youthpasses
或许有时候你下载了一个非常酷的带有一种新颖的用户界面的应用,并且你会想:它是怎么实现的呢?
一个非常典型的例子就转轮,这在CovertBot和其他应用中被作为一个可由用户进行选择的菜单而被采用。
这个控件很自然的会被采用因为他与我们现实生活中要做选择而使用的控件很相似。例如:
l 轮船的舵会由船长来选择航行的方向
l 设置立体声的声音,你要用一个旋钮
l 你也许会记得我们人类以前用旋转号盘电话机打电话
这些现实让我们意识到我们可以使用转轮,即使是一种虚拟的轮子。
注意:对一个物体感知上的使用也叫做功能可见性affordance,是指在某个领域被作为心理学、设计、和人工智能使用的一种概念。
简单的说,一个转轮意味着它是旋转的。正像实物的轮子,触摸屏上的轮子能够以很多的方式实现。比如,我们可能有一个暂停键(类似旋转号盘电话机),在特定区域内忽视了开始键,只允许一个方向的旋转,等等。
正如你所料,这篇教程将会展现你如何做一个转轮的界面。你可以把这个控件并入到你的ios应用中,让别人去想你是怎么实现它的 :-)
Getting Started(开始)
你将要创建的转轮会被分解成几个可选择的扇区,每个扇区代表菜单上的一项。每隔扇区是一个图片,还有一个箭头指向现在被选中的扇区。
在这篇教程的最后,你的转轮看起来会像这样:
这是从用户的角度来看转轮是怎样工作的:
这个轮子不会停在固定的位置,所以它会旋转到任何方向而不是你想要的方向。当用户的手指离开触摸屏的时候,这个控件会检测当前距离外围圆圈左侧“鸟嘴”最近的扇区,并且会调整轮子的中间对准鸟嘴。被选中的扇区将会有一个稍深色的背景。
尽管这个轮子看起来很容易使用并且没有太多的代码,但是在整片教程中仍有一些很重要的知识。你需要熟悉以下方面:
l 建一个自定义组件
l 检测轻拍并计算轻拍点
l 一些基础的三角函数知识
l 把拖拽的手势转换层旋转
l 使用QuartzCore中的affinetransformations(仿射变换)
Creating the Control Class(创建这个控件类)
第一步是建一个工程和这个控件的基础类
建这个工程只用Single View Application就足够了。打开XCode,选择IOS\Application\SingleView Application 模版建一个新工程。
在下一步的向导中,这样做:
1. 工程名起做“RotaryWheelProject”
2. 设置Classprefix 为“SM”
3. 选择iPhone为模拟设备(不过这个控件也可在iPad上运行)
4. 不选择Use Storyboard
5. 使用自动引用计数
你最后应得到一个空的工程,包含一个委托和一个空的view controller就像下边的这张图片所示:
现在你处在一个十字路口。为了创建你的新组件,你可以扩张两个类中的一个:UIView或UIControl。
UIControl 继承自UIView,并且froman object-oriented perspective,UIControl更容易扩展成大部分特殊的类。唯一的不同是你必须重写这些方法来获得你想要的行为。在这篇教程中,你将会扩展UIControl,但是如果你选择扩展VIView的话,我会提供相关的说明。
现在,我们已经决定了,用IOS\Cocoa Touch\Objective-C class 模版创建一个新的file。类的名字叫做 SMRotrayWheel,继承自UIControl。
在修改SMRotrayWheel类之前,我们首先增加另一个新文件。这个文件用SMRotaryWheel类定义了一个协议。
再一次创建新file,但是这次我们用IOS\Cocoa Touch\Objective-C protocol模版。给这个协议起个名叫SMRotaryProtocol。
协议的定义相当的简单。用下面的代码代替SMRotaryProtocol.h中的内容:
#import <Foundation/Foundation.h>
@protocol SMRotaryProtocol <NSObject>
- (void) wheelDidChangeValue:(NSString *)newValue;
@end |
当用户的手指离开屏幕的时候这个方法就会被调用,它标志着菜单上一个新的选项被选定了。一会你就会看到我们怎么调用它。
现在转移到SMRotaryWheel.h文件,用下面这条重要的语句导入协议:
#import "SMRotaryProtocol.h" |
然后在@end的上面添加下边的这些properties:
@property (weak) id <SMRotaryProtocol> delegate; @property (nonatomic, strong) UIView *container; @property int numberOfSections; |
这些properties保持对委托的追踪,来获知用户什么时间做出一个选择、包含转轮的容器视图、和转轮视图的扇区数量。
在这些properties的下边,添加下面的方法:
- (id) initWithFrame:(CGRect)frame andDelegate:(id)del withSections:(int)sectionsNumber; |
上面的这个方法会在view controller视图控制器中调用来初始化这个转轮组件。
现在来到SMRotaryWheel.m,用下面的代码替换掉其中的内容:
#import "SMRotaryWheel.h" @interface SMRotaryWheel() - (void)drawWheel; @end @implementation SMRotaryWheel @synthesize delegate, container, numberOfSections; - (id) initWithFrame:(CGRect)frame andDelegate:(id)del withSections:(int)sectionsNumber { // 1 - Call super init if ((self = [super initWithFrame:frame])) { // 2 - Set properties self.numberOfSections = sectionsNumber; self.delegate = del; // 3 - Draw wheel [self drawWheel]; } return self; } - (void) drawWheel { } @end |
到这,你已经添加了一个drawWheel私有方法,声明了三个properties,定义了initWithFrame:andDelegate:withSections:方法,这个方法的参数被保存在properties中。drawWheel方法用来绘出轮子。
注意:你还没有实现绘出轮子的功能,但是你马上就会做了!:-)
现在,编译并运行!很不幸,你现在只得到一个空白的屏幕,但是这有利于检查出你在每个编码阶段所遗漏的程序错误。
Laying out the Wheel(设计轮子)
现在我们已经建立了一个基本的控制类,我们可以开始形象化的设计轮子的原理,这样他才能显示在一个圈中。
设计转轮的原理,你需要借助CALayers。它不仅能实现非常酷的效果,也能进行像旋转等的几何操作。如果你对CALayers还不熟,查看CALayers的讲解教程。
要使用layers,你需要导入QuartzCore framework。选择左侧边栏的project然后选择Target,在右侧面板的Build Phases 栏,点击“+”符号,找到QuartzCore并添加。
做完这一步,这个framwork就应该出现在你工程树的某个地方,就像下面这张图片
现在来做设计的初始实现。首先,每一个扇区都是一个UILabel的实例。一会儿,你将会把UIImageView替换为定制的图片。
现在,你用一个简单的红色的背景,这样label的边界就会显示出来,然后你就可以为label填充一个数字。你想要实现的效果类似于在一个圆中舒展开一张卡片,在这种情况下,你希望可骗能完美的填充整个圆圈,所以你需要一个能计算每个label角度的算法。
这是已有的解决方法:
根据anchorPoint属性,每一个CALayer会有一个rotation,这个anchorPoint属性是CGPoint的一个实例,取值范围是(0,0)到(1,1)。anchorPoint是一个layer的中心坐标点,下面的这张图标识了anchorPoint的一些值:
默认情况下,anchorPoint设置为(0.5,0.5),这是layer画面的中心,但是你可以随意改变它。
在这篇教程中,我们希望可以绕圆圈的中心旋转每一个label。要实现这个效果,我们只需为每隔label做以下操作:
1. 设置每个anchorPoint为右边框中点(1.0,0.5)
2. 编辑label的position,以让右边框的中点与圆圈的中心点重合
3. 给label应用一个转换让它旋转到合适的位置
现在让我们绘出轮子吧!但是首先,你必须在SMRotaryWheel.m文件的最上边导入QuartzCore:
#import <QuartzCore/QuartzCore.h> |
然后,用下面的代码替换空的drawWheel方法:
- (void) drawWheel { // 1 container = [[UIView alloc] initWithFrame:self.frame]; // 2 CGFloat angleSize = 2*M_PI/numberOfSections; // 3 for (int i = 0; i < numberOfSections; i++) { // 4 UILabel *im = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; im.backgroundColor = [UIColor redColor]; im.text = [NSString stringWithFormat:@"%i", i]; im.layer.anchorPoint = CGPointMake(1.0f, 0.5f); // 5 im.layer.position = CGPointMake(container.bounds.size.width/2.0, container.bounds.size.height/2.0); im.transform = CGAffineTransformMakeRotation(angleSize * i); im.tag = i; // 6 [container addSubview:im]; } // 7 container.userInteractionEnabled = NO; [self addSubview:container]; } |
让我们一步步来看它的工作原理。
1. 这里我们创建了一个把所有东西都放进去的view。
2. 这是一个弧度为2*PI的圆(一会儿会有更详细的解释),我们根据我们希望显示的扇区的数目来分割弧度。还给出了我们在扇区之间旋转的account,我们一会会用到。
3. 就像刚才解释的那样,我们为每个扇区创建了一个label并设置它的anchorPoint为右边框的中点。
4. 设置anchorPoint为右边框的中点,现在我们要设置anchorPoint要移动到的点的坐标。所以我们设置label的position为容器视图的中心。为了旋转label,我们需要设置label的转换为旋转转换。不要担心,你没有必要知道实现它的函数,你可以用CGAffineTransformMakeRotation方法!我们仅仅用扇区的数目乘以每个扇区的弧度。
5. 把label添加到我们刚才创建的container view中
6. 把这个container添加到主控件(main control)
为了保证这个应用能显示一些界面,我们需要在SMViewController中加入wheel类的实例。在SMViewController.m的最上边加入下面语句:
#import "SMRotaryWheel.h" |
下一步,用下面的代码替换掉viewDidLoad方法:
- (void)viewDidLoad { // 1 - Call super method [super viewDidLoad]; // 2 - Set up rotary wheel SMRotaryWheel *wheel = [[SMRotaryWheel alloc] initWithFrame:CGRectMake(0, 0, 200, 200) andDelegate:self withSections:8]; // 3 - Add wheel to view [self.view addSubview:wheel]; } |
如果你现在运行程序,你会在每一个图片的上层看到红色的圆花饰。你知道你正在进行正确的追踪!
现在是时间来学习下圆圈、角度、PI、度和弧度。知道了这些知识,你才能正确的根据用户的触碰(或拖拽)组件时决定的movements来旋转扇区容器。
A Digression Into Trigonometry(三角函数的介绍)
在学校里我们都学过如何用度表示角度,并且我们都知道一个圆有360度。但是科学家、工程师以及程序语言的设计者使用一种叫做弧度的单位。
你也许会记得上面drawWheel的代码在section#2处使用了表达式2*M_PI来计算圆的大小并分割为几个扇区。这是因为360度精确的等于2*M_PI。使用这个公式,我们可以推算出1弧度等于180/PI,并且1度等于P1/180弧度。
这就给了我们度与弧度的转换公式!但是让我们来形象化的显示它们之间的关联。
上面这张图片显示了一弧度的“长度”,大约等于57.29度。我们说大约是因为PI是一个无限小数。
还有一种解释方法。如果你根据上面图片中红线对圆的周长进行分割并你把它画直为一条直线,这条线会跟圆的半径有相同的长度。
换句话说,如果按一个角度划分的弧的长度等于半径,那么这个角度的大小为1弧度。非常酷!不是么?
另一个重要的情况是,除了半径的长度,一整个圆还有2*PI个弧度。当你把旋转应用到转轮上时会非常有用。你会把圆分割成8个相等的块,所以每个块大约0.78弧度,即2*PI/8。
你会从左侧触摸这个圆,按顺时针方向转,所以0弧度应该在左侧。下面的图片显示了你这个方案中八个扇区的角度和弧度的值。
黑色的小点代表每个扇区在弧度上的中间点。正如你从上边的drawWheel方法中看到的,为了让每一个扇区旋转,你要创建一个仿射转换affine transform(of typerotation)并且把它设置为容器container的一个property。像这样:
CGAffineTransform t = CGAffineTransformRotate(container.transform, newValueInRadians); container.transform = t; |
不幸的是,参数newValueInRadians不是你想要转动到的点,它是要从当前值增加\减去的弧度的值。你不能说“旋转到x弧度”。你必须计算当前值和x的不同,然后加上\减去那部分。
例如,你可以创建一个timer来定期的旋转轮子。第一步让我们在SMRotaryWheel.h中initWithFrame方法定义的下面添加方法的定义:
-(void)rotate; |
然后,在SMRotaryWheel.m中的initWithFrame 方法中的section#3下边加上一下代码:
// 4 - Timer for rotating wheel [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(rotate) userInfo:nil repeats:YES]; |
最后,在SMRotaryWheel.m的最后(在@end的前边)增加rotate方法:
- (void) rotate { CGAffineTransform t = CGAffineTransformRotate(container.transform, -0.78); container.transform = t; } |
这里,我们选择-0.78是因为它是旋转一个扇区所要给与的一个必要弧度值,如上所述,我们有8个扇区。
编译并运行。你会看到轮子没两秒完成一次旋转。这或多或少跟你最后要完成的应用差不多,尽管你还得用到用户的触摸。这就把我们带到了最棘手的部分。
Adding Rotation(添加旋转)
如果你曾经通过代码的方式实现你需要输入的文字,拿它听起来太容易了:
l 当用户轻碰屏幕,存储弧度的“当前值”
l 每当用户用手指拖拽,计算新的弧度值并用仿射转换进行设置
l 当用户的手指离开屏幕,计算当前选择的扇区并用旋转校准轮子的中心
但是正如常言所说,魔鬼都在细节当中。
为了计算轮子所要旋转的角度,你需要把笛卡尔坐标转换为极点坐标。这意味着什么?
当你检测组件上的一个轻碰时,你可以根据一个“参考点”获得它的笛卡尔坐标系中的x和y值,这个参考点往往是组件的左上角。在这个方案中,你处在一个“圆圈的”世界,在这个世界里,极点pole是这个容器container的中心。例如,在下面的图片中我们说用户点在轮子的(30,30)这个点上。
用户触碰的点和x轴(蓝色的线)之间的夹角是多少呢?你需要知道这个值才能计算用户的手指在轮子上拖拽所划过的角度。这就是要加载到容器container上旋转的角度。
你要对这个计算方法抓狂和努力了。计算上面说的角度要用到反三角函数,三角函数的反函数。你猜猜看,这个函数返回一个弧度值,这正好就是你所需要的!
但是还有一点难处理的小细节,就是反三角函数的输入输出都是PI。如果你记得,我们上面提到过,你的角度范围是从0到2PI,这不是不能处理,但是你得在以后的计算中注意此事。否则,屏幕上显示的效果会非常的怪异。
理论讲的够多了,让我们来看看代码!在SMRotaryWheel.h中,添加一个新的属性property:
@property CGAffineTransform startTransform; |
当用户触摸组件时会用来存储转换差。为了在用户触摸组件时保存角度,我们在SMRotaryWheel.m的顶部添加一个float类型的静态变量,就写在@implementation的上面:
static float deltaAngle; |
你也要早先的synthesize这个startTransform属性:
@synthesize startTransform; |
现在,我们要检测用户触摸了。当用户轻拍一个组件的实例时,轻拍事件会被beginTrackingWithTouc:touchwithEvent:event方法处理。在SMRotaryWheel.m中rotate方法的下边重写这个方法:
注意:如果你选择的是扩展UIView而不是UIControl,这个要重写的方法是touchesBegan:touches withEven:event。
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { // 1 - Get touch position CGPoint touchPoint = [touch locationInView:self]; // 2 - Calculate distance from center float dx = touchPoint.x - container.center.x; float dy = touchPoint.y - container.center.y; // 3 - Calculate arctangent value deltaAngle = atan2(dy,dx); // 4 - Save current transform startTransform = container.transform; return YES; } |
第一步你要先找到转轮上触摸点的笛卡尔坐标,然后计算触摸点和容器container中心点的差。最后,你要得到反三角函数值并存储当前的转换差,这样每当用户拖拽轮子时就有一个初始的参考点。
一会你就会看到,你要从这个方法返回YES因为用户拖拽必须被相应。现在在用户触摸开始时你已经保存了这个角度,下一步是根据用户的拖拽计算弧度。
举个例子,我们假设用户触碰组件的点为(50,50),并拖拽到点(260,115)。
你要计算最后这个点的弧度值并从当用户触碰组件时保存的三角形中减去这个值,这个结果就是要传给仿射变换的弧度值。这些会在组件抛出的每一次拖拽事件中实现,在beginTrackingWithTouch方法下边增加这个重写的函数continueTrackingWithTouch:
注意:如果你继承自UIView,需重写的方法是 touchesMoved:withEvent:
- (BOOL)continueTrackingWithTouch:(UITouch*)touch withEvent:(UIEvent*)event { CGPoint pt = [touch locationInView:self]; float dx = pt.x - container.center.x; float dy = pt.y - container.center.y; float ang = atan2(dy,dx); float angleDifference = deltaAngle - ang; container.transform = CGAffineTransformRotate(startTransform, -angleDifference); return YES; } |
你会注意到,弧度的计算非常类似与beginTrackingWithTouch方法中所写代码。你也会注意到参数-angleDifference,它会补偿负象限的值。
最后,不要忘记在initWithFrame方法中section#4出的提示:这样轮子才不会自动旋转。
现在编译并运行,看见了吗?你已经做到这一步了!你现在已经有了一个能工作的转轮的原型,它工作的很好!
尽管还有一些古怪。例如,如果用户轻拍在靠近轮子中心的一个点上,程序会继续,大事旋转会变得有些“跳跃”。这是因为角度的描绘非常的“混乱”,就像下面这张图。
如果用手指划过轮子的中点,“跳跃”会更严重,看下图。
你可以用当前实现的效果验证这个问题。然而代码是工作的,结果也是正确的。
要解决这个问题,就要借助真实的轮子用到的解决方案,就像一个较旧但完好的旋转式拨号盘,拨号盘如果是从较远的地方转到中心点,那么会很难用!你的任务就是忽略太靠近轮子中心的触摸,通过阻止这样的触碰发生时而响应的事件。
要达到这个效果,没有必要使用反三角函数,勾股定理就足够了。但是你需要一个帮助函数,calculateDistanceFromCenter,把它添加在SMRotaryWheel.m的顶部,在drawWheel定义的下边即可:
@interface SMRotaryWheel() ... - (float) calculateDistanceFromCenter:(CGPoint)point; |
在continueTrackingWithTouch方法下面进行实现:
- (float) calculateDistanceFromCenter:(CGPoint)point { CGPoint center = CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2); float dx = point.x - center.x; float dy = point.y - center.y; return sqrt(dx*dx + dy*dy); } |
这仅仅计算出了这个触碰点与中心点的距离。现在在beginTrackingWithTouch:withEvent:方法的section#1处添加代码:
// 1.1 - Get the distance from the center float dist = [self calculateDistanceFromCenter:touchPoint]; // 1.2 - Filter out touches too close to the center if (dist < 40 || dist > 100) { // forcing a tap to be on the ferrule NSLog(@"ignoring tap (%f,%f)", touchPoint.x, touchPoint.y); return NO; } } |
这样,当触碰点与中心点太近的话,这个触摸会被简单的忽略因为你返回了NO,表明组件不会处理此次触摸事件。
注意:如果你选择的继承UIView,你得在touchesMoved:withEvent方法中实现。
你可以根据你轮子的大小自定义可触摸区域的大小,通过调整section#1.2第一行两个值(40 和 100),类似下边这张图片展示的(蓝色区域):
你也许想在continueTrackingWithTouch:withEvent方法中进行同样的检查。
下面是比较困难的一部分。你要实现的是一个“come-to-rest”的效果,就是当用户的手指离开屏幕的时候,轮子会停在当前扇区的中间点。
Building the Sectors(构建扇区)
要让轮子停在当前扇区的中间点,你首先需要把轮子分为几个扇区。当用户的手指离开屏幕时我们要做一下内容:
1. 计算弧度值
2. 基于上一步的弧度值找出扇区
3. 旋转一个弧度到扇区的中间点
举个例子,入股被选中的扇区是zero并且用户只是轻微往上或往下拖拽了轮子,你想让轮子转会到zero扇区的中间点。尽管这有一点棘手,让我们一步一步的来做。
首先,让我们对容器container的世界有一个更好的理解。
在SMRotaryWheel.m中continueTrackingWithTouch方法的顶部下列代码:
CGFloat radians = atan2f(container.transform.b, container.transform.a); NSLog(@"rad is %f", radians); |
这记录了用户的手指拖拽的每个时刻容器container的旋转弧度。你会注意掉如果轮子被顺时针拖拽,弧度会是正值直到弧度值大于PI弧度(180度),或者如果你愿意,当标号为“0”的扇区处在圆中心点水平线以下的象限时,当你超过180度,你会看到负值,就像下面屏幕输出的。
这是你在计算扇区边界时必须考虑的:它们的最大值、中间值和最小值。被选中的扇区必会在最左边的位置,0扇区被初始化的位置。你要找到这个问题的答案:当弧度值是x的时候,哪个扇区是被定位器标识的?
要回答这个问题,你需要逆向思考。下面的图片显示了一个有八个扇区的轮子。
圆圈周围的数值表示每隔扇区的最大和最小弧度值。例如,不论何时只要容器container的弧度值在-0.39和0.39之间,轮子就应停在扇区0的中间点位置。
再者,你必须考虑象限(正或负)来正确的加减角度差。有一个特殊,你必须处理跨两个象限的扇区0和扇区4。对于扇区0,中间点是0弧度,它还算较为简单。然而对于扇区4,中间点是PI或-PI,因为中间点跨越正负象限的分界线。所以,事情变得有点复杂。
你可以从下面这张图片看到,如果有奇数个扇区,那么扇区中间点的弧度值会稍简单点。
为了保证灵活和全面,这篇教程会考虑偶数和奇数个扇区的情况,并提供各自的程序代码。但是首先,我们要定义一个新类来表示扇区,并存储每个扇区弧度的最大值、中间值和最小值。
用IOS\Cocoa Touch\Objective-C class模版创建一个新文件。起名类SMSector,并且继承自NSObject。现在来到SMSector.h文件并用下面的代码替换其中的内容:
@interface SMSector : NSObject @property float minValue; @property float maxValue; @property float midValue; @property int sector; @end |
转移到SMSector.m文件,并用下面的实现代码替换其中的内容:
#import "SMSector.h" @implementation SMSector @synthesize minValue, maxValue, midValue, sector; - (NSString *) description { return [NSString stringWithFormat:@"%i | %f, %f, %f", self.sector, self.minValue, self.midValue, self.maxValue]; } @end |
在SMRotaryWheel.h导入SMSector类:
#import "SMSector.h" |
然后增加一个新属性property,名叫sectors:
@property (nonatomic, strong) NSMutableArray *sectors; |
来到SMRotaryWheel.m并添加两个新的帮助方法定义来创建扇区(在已经有的caculateDistanceFromCenter的下边):
@interface SMRotaryWheel() ... - (void) buildSectorsEven; - (void) buildSectorsOdd; @end |
然后,synthesize这个新的属性property:
@synthesize sectors; |
下一步,在drawWheel方法的最后,添加下面的代码这样当你创建转轮的时候扇区就会被初始化。
// 8 - Initialize sectors sectors = [NSMutableArray arrayWithCapacity:numberOfSections]; if (numberOfSections % 2 == 0) { [self buildSectorsEven]; } else { [self buildSectorsOdd]; } |
让我们开始一个比较简单的情况,当有奇数个扇区时。在SMRotaryWheel.m的底部(@end的上面)添加下面的方法实现代码:
- (void) buildSectorsOdd { // 1 - Define sector length CGFloat fanWidth = M_PI*2/numberOfSections; // 2 - Set initial midpoint CGFloat mid = 0; // 3 - Iterate through all sectors for (int i = 0; i < numberOfSections; i++) { SMSector *sector = [[SMSector alloc] init]; // 4 - Set sector values sector.midValue = mid; sector.minValue = mid - (fanWidth/2); sector.maxValue = mid + (fanWidth/2); sector.sector = i; mid -= fanWidth; if (sector.minValue < - M_PI) { mid = -mid; mid -= fanWidth; } // 5 - Add sector to array [sectors addObject:sector]; NSLog(@"cl is %@", sector); } } |
让我们一步步的分析上面的代码:
首先,我们定义了每隔扇区弧度值的长度(或者叫宽度如果你愿意)。
然后,我们用初始中间点声明了一个变量。既然我们的起始点是0弧度,那它就是我们第一个中间点。
然后我们重复设置每个扇区的最大、中间和最小弧度值。
当计算最小和最大弧度值时,你要加上或减去扇区宽度的一半来得到正确的结果。记得角度变化范围是从-PI到PI,这样才正常,如果一个值超出了PI或-PI,那意味着你改变了象限。你既然是顺时针定位轮子,你就得考虑弧度最小值小于PI的情况,并且改变中间点的标记。
最后,一旦创建一个扇区,我们把这个扇区添加到预先定义的扇区数组中。
现在在SMViewController.m中修改viewDidLoad方法的section#2处设置sections值为3,正如下面代码:
SMRotaryWheel *wheel = [[SMRotaryWheel alloc] initWithFrame:CGRectMake(0, 0, 200, 200) andDelegate:self withSections:3]; |
如果你现在编译并运行,控制台应该显示以下的结果:
这些数值跟上面被分为三部分的轮子的数值是相同的。所以你的计算工作非常精确!
Animating the Selection Centering(旋转到扇区中心)
最后一步是实现校准当前扇区的中心点,让我们温习下这是什么意思。
当用户的手指离开屏幕你必须计算x值,当前的弧度值,并且根据这个值确定选中的扇区。然后你得计算x和扇区中心点的差值,并用它来构建一个仿射变换。
首先在SMRotaryWheel.h中添加一个新属性property来记录当天扇区:
@property int currentSector; |
然后,在SMRotaryWheel.m中synthesize这个新属性property:
@synthesize currentSector; |
要处理手指离开屏幕的事件,你要重写endTrackingWithTouch:withEvent:方法(重写touchedEnded:withEvent:方法如果你扩展UIView)。
在SMRotaryWheel.m,在continueTrackingWithTouch:withEvent:方法的下面添加下列代码来处理奇数个扇区:
- (void)endTrackingWithTouch:(UITouch*)touch withEvent:(UIEvent*)event { // 1 - Get current container rotation in radians CGFloat radians = atan2f(container.transform.b, container.transform.a); // 2 - Initialize new value CGFloat newVal = 0.0; // 3 - Iterate through all the sectors for (SMSector *s in sectors) { // 4 - See if the current sector contains the radian value if (radians > s.minValue && radians < s.maxValue) { // 5 - Set new value newVal = radians - s.midValue; // 6 - Get sector number currentSector = s.sector; break; } } // 7 - Set up animation for final rotation [UIView beginAnimations:nil context:NULL]; [UIView setAnimationDuration:0.2]; CGAffineTransform t = CGAffineTransformRotate(container.transform, -newVal); container.transform = t; [UIView commitAnimations]; } |
这个方法相当的简单。它计算当前的弧度冰雨最小和最大弧度进行比较来确定正确的扇区。然后计算出差值并创建一个新的仿射变换,为了让效果看起来很自然,设置这个旋转动画持续0.2秒。
通过修改SMViewController.m中iewDidLoad的section#2出的代码重新创建了三个扇区,编译、运行……哈哈!它工作了!抓住轮子并按你的意愿拖拽,你会看到当你停止拖拽并把手指抬起时,它选中了右边的扇区。
现在这些代码适合于所有带有奇数个扇区的轮子。要考虑偶数个扇区,你必须重做section#3出的循环类检测异常的情况,在这种情况下,弧度最小值是正的,最大值是负的。用下面的代码替换掉endTrackingWithTouch:withEvent:中的sections#4,#5和#6部分:
// 4 - Check for anomaly (occurs with even number of sectors) if (s.minValue > 0 && s.maxValue < 0) { if (s.maxValue > radians || s.minValue < radians) { // 5 - Find the quadrant (positive or negative) if (radians > 0) { newVal = radians - M_PI; } else { newVal = M_PI + radians; } currentSector = s.sector; } } // 6 - All non-anomalous cases else if (radians > s.minValue && radians < s.maxValue) { newVal = radians - s.midValue; currentSector = s.sector; } |
编译、运行,并体验通过改变扇区数来免费进行的实验吧!
Adding Protocol Notifications(添加协议通告)
所有繁重的工作都被我们跨越,从现在开始现在我们就一路顺风了!
还记得在最开始的时候定义的SMRotaryProtocol吗?如果你为SMRotaryWheel检查initWithFrame:你会发现我们已经设置了委托delegate。所以你为什么不在视图控制器iew controller添加一个label来显示被选中的扇区呢?
在SMViewController.h中用下面的代码替换已有的代码:
#import "SMRotaryProtocol.h" @interface SMViewController : UIViewController<SMRotaryProtocol> @property (nonatomic, strong) UILabel *sectorLabel; @end |
在SMViewController.m中Synthesize这个新属性property:
@synthesize sectorLabel; |
然后用下面的代码替换viewDidLoad方法:
- (void)viewDidLoad { // 1 - Call super method [super viewDidLoad]; // 2 - Create sector label sectorLabel = [[UILabel alloc] initWithFrame:CGRectMake(100, 350, 120, 30)]; sectorLabel.textAlignment = UITextAlignmentCenter; [self.view addSubview:sectorLabel]; // 3 - Set up rotary wheel SMRotaryWheel *wheel = [[SMRotaryWheel alloc] initWithFrame:CGRectMake(0, 0, 200, 200) andDelegate:self withSections:12]; wheel.center = CGPointMake(160, 240); // 4 - Add wheel to view [self.view addSubview:wheel]; } |
为这个协议protocol定义的一个唯一的方法添加在这个文件的底部(@end之前):
- (void) wheelDidChangeValue:(NSString *)newValue { self.sectorLabel.text = newValue; } |
现在来到SMRotaryWheel.m,并在section#3之后添加下面一行代码:
self.currentSector = 0; |
在drawWheel:方法的最后调用这个协议的方法:
// 9 - Call protocol method [self.delegate wheelDidChangeValue:[NSString stringWithFormat:@"value is %i", self.currentSector]]; |
同样,在endTradkingWithTouch:withEvent:方法的最后添加相同的代码。
编译并运行,看到了吗?每次一个扇区被选中时,label就会被更新。
非常酷!我知道现在你想让组件更有吸引力通过添加一些图片。
Adding Graphics(添加图片)
VickiWenderlich非常好心的为我的组件提供了图片,这就是组成组件的图片
继续并下载这些图片(替换掉上面的单张图片),并把他们添加到你的工程中,一点你导入了它们,在SMRotaryWheel.m中(在@implementation 语句的上面)添加两个新的静态变量:
static float minAlphavalue = 0.6; static float maxAlphavalue = 1.0; |
在drawWheel中section#8的前面添加一下代码:
// 7.1 - Add background image UIImageView *bg = [[UIImageView alloc] initWithFrame:self.frame]; bg.image = [UIImage imageNamed:@"bg.png"]; [self addSubview:bg]; |
同样添加一个中心的button,在一个扇区被选中时你可以用来触发一个action,写在上面那些代码的下边:
UIImageView *mask = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 58, 58)]; mask.image =[UIImage imageNamed:@"centerButton.png"] ; mask.center = self.center; mask.center = CGPointMake(mask.center.x, mask.center.y+3); [self addSubview:mask]; |
替换sections#3,#4,#5和#6处的代码,用图片换掉labels,并为每个扇区添加一个icon:
// 3 - Create the sectors for (int i = 0; i < numberOfSections; i++) { // 4 - Create image view UIImageView *im = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"segment.png"]]; im.layer.anchorPoint = CGPointMake(1.0f, 0.5f); im.layer.position = CGPointMake(container.bounds.size.width/2.0-container.frame.origin.x, container.bounds.size.height/2.0-container.frame.origin.y); im.transform = CGAffineTransformMakeRotation(angleSize*i); im.alpha = minAlphavalue; im.tag = i; if (i == 0) { im.alpha = maxAlphavalue; } // 5 - Set sector image UIImageView *sectorImage = [[UIImageView alloc] initWithFrame:CGRectMake(12, 15, 40, 40)]; sectorImage.image = [UIImage imageNamed:[NSString stringWithFormat:@"icon%i.png", i]]; [im addSubview:sectorImage]; // 6 - Add image view to container [container addSubview:im]; } |
为了每一次在需要的时候确定正确的扇区,在SMRotaryWheel.m的顶部添加一个新的帮助方法定义:
@interface SMRotaryWheel() ... - (UIImageView *) getSectorByValue:(int)value; @end |
在SMRotaryWheel.m的最后添加这个方法的实现代码:
- (UIImageView *) getSectorByValue:(int)value { UIImageView *res; NSArray *views = [container subviews]; for (UIImageView *im in views) { if (im.tag == value) res = im; } return res; } |
无论何时用户轻拍转轮,你必须重置当前扇区alpha值为它的最小值。在beginTrackingWithTouch:withEvent中returen YES的上面添加一下代码:
// 5 - Set current sector's alpha value to the minimum value UIImageView *im = [self getSectorByValue:currentSector]; im.alpha = minAlphavalue; |
一旦一个新的扇区被选择,它会显示凸起的状态,所以在endTrackingWithTouch:withEvent:方法的最后添加以下代码:
// 10 - Highlight selected sector UIImageView *im = [self getSectorByValue:currentSector]; im.alpha = maxAlphavalue; |
如果你想要显示icon的名字而不是号码,这有一个返回相应字符串的方法:
- (NSString *) getSectorName:(int)position { NSString *res = @""; switch (position) { case 0: res = @"Circles"; break; case 1: res = @"Flower"; break; case 2: res = @"Monster"; break; case 3: res = @"Person"; break; case 4: res = @"Smile"; break; case 5: res = @"Sun"; break; case 6: res = @"Swirl"; break; case 7: res = @"3 circles"; break; case 8: res = @"Triangle"; break; default: break; } return res; } |
如果你想用上面的这个方法,在SMRotaryWheel.m中替换所有对wheelDidChangeValue:方法的调用:
[self.delegate wheelDidChangeValue:[self getSectorName:currentSector]]; |
你已经完成了。还记得在早先的代码中我们在SMViewController.m中把扇区的数目设置为12么?好吧,我们只有8个icons,所以我们需在SMViewController.m中把扇区数改为8.
做完了!现在当用户的手指拖拽转轮时,所有的扇区会是浅色。当有一个扇区被选中,它会变得很显眼同时其他的扇区颜色仍然为浅色。你可以如你所想扩展这种效果,比如增加倾斜效果或加一个边界。
Where to Go From Here?(何去何从?)
教程中完整代码的例子你可以从my Github page下载。
这是一次关于轮子的完美旅行,不是么?你已经建立了一个玩真的新的组件,创建了一个对用户来说感觉非常自然也很有趣的选择方式。
你还可以完善这个组件,在中间的按钮添加一个选项,让其根据不同扇区的选择显示不同的视图。作为一个附加的练习,如果你喜欢钻研三角函数-谁不是呢:-)-试着把zero点移到圆的上边或右边。
我希望你能从这篇教程得到有用的经验,希望你能反馈你的问题并在讨论区里进行评论!