关于UI的攻略,我们轻易就能写出整整一本书。毕竟iOS SDK中值得讨论的类库与模式似乎无穷无尽。最终,我们决定专注于读者会反复遇到、却又记不清以前的解决方式的那些简单的模式与问题,展示针对这些问题的优秀解决方案。 这一章,我们将介绍有关视图切换、网页内容、触摸处理和定制控件的攻略。这些攻略可供读者拿来使用,也可能纯粹为了启发读者去思考如何让自己的代码可以复用于将来的项目。 攻略1 添加基本的启动画面切换 问题 应用程序启动时,从默认图像到实际UI的切换如果很生硬,带给用户的第一印象会很糟糕。我们想让从应用程序的启动图像到初始UI的切换尽可能平滑流畅,但不清楚如何用最简洁的方式实现。 解决方案 iOS app启动的直观体验是这样的: (1) 用户点击app图标; (2) app的默认图像逐渐放大显示到屏幕上; (3) app的初始UI加载到内存; (4) UI显示到屏幕上,取代默认图像。 如果默认图像是品牌的横幅或其他特有的图片,那么由它到实际UI的切换可能会让用户觉得生硬。我们需要一种从启动画面到运行的程序之间的平滑切换。这可以通过许多方式来实现,我们从最简单的一种方式讲起,这应该是一种普遍适用的方式。我们先来处理一个纵向的iPhone程序,接下来看看支持各种方向的iPad版本。初始画面如图1所示。 图1 启动画面与初始UI 最简单的“启动画面切换”是默认图像淡出,而UI同时淡入。这种切换容易实现,成本不高,又能让用户体验截然不同。想想看,这是用户第一眼看到的东西,没有理由不做得平滑顺畅。 为了使默认图像淡出屏幕,首先需要显示一个视图,由它显示这幅图像,然后把这个视图淡出。这很容易,先创建一个可用于任何项目的简单的视图控制器,由它使用定制的启动屏幕图像,并定义一个执行淡出的-hide方法。 BasicSplashScreen/PRPSplashScreen.h @interface PRPSplashScreen : UIViewController {} @property (nonatomic, retain) UIImage *splashImage; @property (nonatomic, assign) BOOL showsStatusBarOnDismissal; @property (nonatomic, assign) IBOutlet id<PRPSplashScreenDelegate> delegate; - (void)hide; @end 接口中有一个delegate属性,声明为id <PRPSplashScreenDelegate>类型。这个PRPSplashScreenDelegate协议定义在独立的头文件中,向相关程序传达启动画面的状态:启动画面何时开始显示、何时开始切换及何时结束切换。 读者肯定在很多地方做过委托所做的事情,但很可能以前没有定义过委托。我们来看看这个协议的定义,请注意@optional关键字,这意味着该委托不要求实现它声明的所有方法。一个对象如果想知道启动画面的状态,可以声明自己遵守PRPSplashScreenDelegate协议,实现一个或多个委托方法,并把自己赋值给启动画面的delegate属性。 BasicSplashScreen/PRPSplashScreenDelegate.h @protocol PRPSplashScreenDelegate <NSObject> @optional - (void)splashScreenDidAppear:(PRPSplashScreen *)splashScreen; - (void)splashScreenWillDisappear:(PRPSplashScreen *)splashScreen; - (void)splashScreenDidDisappear:(PRPSplashScreen *)splashScreen; @end PRPSplashScreen在-loadView方法中构建其视图,这样就不必在每次用到它时都去拖XIB文件了,因而也更便于我们把它应用到项目中。view属性设置为一个以居中的图像充满屏幕的图像视图。 BasicSplashScreen/PRPSplashScreen.m - (void)loadView { UIImageView *iv = [[UIImageView alloc] initWithImage:self.splashImage]; iv.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; iv.contentMode = UIViewContentModeCenter; self.view = iv; [iv release]; } 现在来看看splashImage属性,它是可写的,所以必要时我们可以设置定制的切换图像。但是我们这里只使用Default.png作为启动图像,因为这个攻略的要点是创建平滑流畅的切换。所以,我们写了一个默认加载Default.png的懒初始化方法。如果是从默认的图像做切换,就无需改动这个属性。我们使用+[UIImage imageNamed:]来保证使用适当比例的图像(比如对于Retina显示屏使用Default@2x.png)。 BasicSplashScreen/PRPSplashScreen.m - (UIImage *)splashImage { if (splashImage == nil) { self.splashImage = [UIImage imageNamed:@"Default.png"]; } return splashImage; } 设置启动画面很简单,只是从app的根视图控制器显示一个模态视图控制器而已。我们将在程序启动时,在添加了根视图之后、显示主窗口之前做这件事。时机很重要:根视图控制器在自身的视图没有准备好之前,不会正常显示模态视图控制器。本章的BasicSplashScreen项目中,我们在代码中也设定了一种溶解风格(淡入淡出)的切换。因为启动画面默认使用启动图像,所以不需要我们自己指定。 BasicSplashScreen/iPhone/AppDelegate_iPhone.m - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [self.window addSubview:self.navController.view]; self.splashScreen.showsStatusBarOnDismissal = YES; self.splashScreen.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; [self.navController presentModalViewController:splashScreen animated:NO]; [self.window makeKeyAndVisible]; return YES; } 如果打开MainWindow_iPhone.xib,我们会看到XIB文件中定义了一个PRPSplashScreen对象,如图2所示。在Interface Builder中,这个对象连接到app委托的splashScreen属性。前面的代码引用了这个属性,以显示启动画面。 启动画面在各自的MainWindow XIB文件中作初始化,并连接到应用程序委托的splashScreen属性。app委托也连接到启动画面的delegate属性。 图2 在Interface Builder中连接启动画面 窗口可见之后,启动画面视图控制器会收到标准的UIViewController消息,包括-viewDidAppear:。它意味着要开始画面切换,而且实现起来非常简单。我们首先通知委托,告诉它启动画面视图已经显示了,以便它在必要时为画面切换做准备。首先检查委托是否实现了适当的方法很重要,因为在委托协议中这些方法声明为可选的。向委托发送消息之后,我们发送-hide消息来执行启动画面切换。请注意此处我们使用了performSelector:withObject: afterDelay:,这样可以让UIKit运行循环(run loop)完成viewDidAppear机制。在自身的viewWillAppear:或viewDidAppear:方法中解除(dismiss)视图控制器会导致系统混乱,每个动作需要分离开来,并相互独立。 BasicSplashScreen/PRPSplashScreen.m - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; SEL didAppearSelector = @selector(splashScreenDidAppear:); if ([self.delegate respondsToSelector:didAppearSelector]) { [self.delegate splashScreenDidAppear:self]; } [self performSelector:@selector(hide) withObject:nil afterDelay:0]; } -hide方法首先检查是否要在淡出时显示状态条,然后使用标准的-dismissModalView- ControllerAnimated:方法来执行画面切换。添加这个处理是为了应对启动时不显示状态条而在UI中却显示状态条的情形。要实现这种效果,需在app的Info.plist文件中把UIStatusBar- Hidden设为YES,把启动画面的showsStatusBarOnDismissal属性设为YES。启动画面会负责重新激活状态条,所以我们不必自己在委托方法中来做(如图3所示)。 把UIStatusBarHidden键设为YES以便在启动时隐藏状态条。如果要在主UI中显示状态条,需要把启动画面的showStatusBarOnDismissal属性设为YES。 图3 启动时隐藏状态条 BasicSplashScreen/PRPSplashScreen.m - (void)hide { if (self.showsStatusBarOnDismissal) { UIApplication *app = [UIApplication sharedApplication]; [app setStatusBarHidden:NO withAnimation:UIStatusBarAnimationFade]; } [self dismissModalViewControllerAnimated:YES]; } 启动画面也会通过转发标准的视图控制器方法-viewWillDisappear:和-viewDid- Disappear:,把画面切换的进度通知给委托。app委托使用相应的-splashScreenDid- Disappear:委托方法来删除不再需要的启动画面。 BasicSplashScreen/iPhone/AppDelegate_iPhone.m - (void)splashScreenDidDisappear:(PRPSplashScreen *)splashScreen { self.splashScreen = nil; } 以iPhone为目标环境运行BasicSplashScreen项目,看看从启动画面到UI的画面切换。委托的连接设置是在MainWindow_iPhone.xib和MainWindow_iPad.xib中做的,所以在代码中不必访问delegate属性。我们用来展示本书细节页面的PRPWebViewController类,会在攻略6中再详细讲解。 这个解决方案现在只能执行竖屏的画面切换,对于多数iPhone app来说就够了。而iPad app会经常以横屏和竖屏两种方式运行。因为UIViewController提供了自动旋转行为,而PRPSplashScreen继承了UIViewController,所以支持多种屏幕方向相当简单。我们先创建一个iPad专用的PRPSplashScreen的子类,添加对各种屏幕方向的支持。 BasicSplashScreen/iPad/PRPSplashScreen_iPad.m - (BOOL)shouldAutorotateToInterfaceOrientation: (UIInterfaceOrientation)toInterfaceOrientation { return YES; } 子类中只写这几行代码即可,PRPSplashScreen的所有其他行为都原封不动。 最后一件事是提供一幅新的启动图像。当支持多种启动方向时,要提供默认图像的竖屏和横屏版本,UIKit会替我们选择正确的图像。然而,我们的代码无法知道使用的是哪幅,因此不能为启动画面视图选择正确的图像。为此,可以从UIDevice检测设备的方向或者从UIApplication检测状态条的方向,不过还有更简单的办法。既然我们的目的是让LOGO居中,我们可以制作一幅1024×1024像素的新的启动图像。这个大小满足两种方向的最大屏幕尺寸,而且无论设备如何旋转都能够在充满屏幕的同时保持居中。即使动态旋转发生在画面切换之前,图像仍可以保持居中。我们把这幅图像加到app之中,并通过由PRPSplashScreen定义的splashImage属性把它设置为启动画面的启动图像。 BasicSplashScreen/iPad/AppDelegate_iPad.m - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [self.window addSubview:self.splitViewController.view]; UIImage *splash = [UIImage imageNamed:@"splash_background_ipad.png"]; self.splashScreen.splashImage = splash; self.splashScreen.showsStatusBarOnDismissal = YES; self.splashScreen.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; [self.splitViewController presentModalViewController:splashScreen animated:NO]; [self.window makeKeyAndVisible]; return YES; } 初始化代码的其余部分跟iPhone版本相同。请运行iPad版的BasicSplashScreen,观察横屏与竖屏状态下平滑流畅的画面切换,如图4所示。现在我们创建了一个从有特色的默认图像到app的初始UI之间的基本画面切换,这个效果容易复用,既能用于iPhone,又能用于iPad。 图4 iPad上的多种屏幕方向 攻略2 让启动画面的切换更有吸引力 问题 简洁的启动画面切换很重要,但有时候除了基本的渐变之外,应用更多的技巧可以锦上添花。 解决方案 在攻略1中,我们讨论了启动画面切换的重要性,看到了它给用户带来的完全不同的体验。在第一个攻略里,我们的重点是如何建立清晰简洁的结构来实现切换。渐变切换虽然优雅,但在我们能够使用的切换中它只是最简单的一种。在保持切换平滑流畅的同时,通过结合蒙版(masking)技术与Core Animation还可制作出更有吸引力的效果。 如前面的例子一样,为了把默认图像从屏幕切换走,首先需要一个视图来显示默认图像,然后需要逐渐地移走这个视图,露出下面的主界面视图(如图5所示)。 图5 CircleFromCenter切换过程 本攻略会介绍多个示例,但这些示例使用的都是相同的基本蒙版技巧。我们使用一幅蒙版图像滤掉部分图像,然后对蒙版图像的scale做动画,直至整幅图像完全消失。 我们创建的每个视图都由一个图层(即由图形处理器直接绘制的图形元素)来支撑,图层起到图像存储器的作用,让我们不必重画就能对视图进行操作(移动、缩放或旋转)。我们可以直接修改图层的属性,这是一种进一步修改视图显示的手段。这些属性中有一个是mask属性,通过它可以设定第二图层,第二图层的alpha通道将与第一图层的图像作蒙版。图像的alpha通道指定了那些区域的透明程度,从0(透明)到1(不透明)。当图层蒙版添加到视图之后,蒙版图像的不透明部分将显示原始图像,但是透明或部分透明的区域会显示出它下面的视图(参见图6)。 图6 CircleFromCenter变换所用的蒙版 我们使用预先定义的图像来创建蒙版图层的内容,这些图像各自有不同的不透明区域,以产生所要的效果。然后进行增大蒙版图层比例的动画,实际上是增大它的尺寸,以充满整个视图并将其渲染为透明。 蒙版图层的anchorPoint(定位点)至关重要。当我们使用变换(transform)改变图层的比例时,拉伸效果将以anchorPoint为中心,所以anchorPoint要跟我们的蒙版图像的透明区域的中心相一致。这样会产生蒙版图像的透明区域在扩大的效果,导致逐渐露出下面的视图(参见图7)。 图7 ClearFromCenter变换所用的蒙版 在viewDidLoad方法中,我们增加了Default.png的副本,这会产生原来的启动画面没有消失的印象。为了避免使用UIImageView,我们直接填充视图图层的contents,同时设置缩放因子来匹配设备。把contentMode设为UIViewContentModeBottom以避免这幅替代图像被状态条抵消,这会让图像与屏幕下端对齐。 SplashScreenReveal/PRPSplashScreenViewController.m - (void)viewDidLoad { self.view.layer.contentsScale = [[UIScreen mainScreen] scale]; self.view.layer.contents = (id)self.splashImage.CGImage; self.view.contentMode = UIViewContentModeBottom; if (self.transition == 0) self.transition = ClearFromRight; } 在viewDidAppear:方法中,我们使用了一个switch语句把枚举值Enum匹配到切换种类。每种切换只需要调整两个元素:蒙版图像和对应的anchorPoint。performSelector:withObject: afterDelay:方法在这里很有用,因为通过它可产生一个延迟,之后再激活animate方法,开始画面切换。 SplashScreenReveal/PRPSplashScreenViewController.m - (void)viewDidAppear:(BOOL)animated { if ([self.delegate respondsToSelector:@selector(splashScreenDidAppear:)]) { [self.delegate splashScreenDidAppear:self]; } switch (self.transition) { case CircleFromCenter: self.maskImageName = @"mask"; self.anchor = CGPointMake(0.5, 0.5); break; case ClearFromCenter: self.maskImageName = @"wideMask"; self.anchor = CGPointMake(0.5, 0.5); break; case ClearFromLeft: self.maskImageName = @"leftStripMask"; self.anchor = CGPointMake(0.0, 0.5); break; case ClearFromRight: self.maskImageName = @"RightStripMask"; self.anchor = CGPointMake(1.0, 0.5); break; case ClearFromTop: self.maskImageName = @"TopStripMask"; self.anchor = CGPointMake(0.5, 0.0); break; case ClearFromBottom: self.maskImageName = @"BottomStripMask"; self.anchor = CGPointMake(0.5, 1.0); break; default: return; } [self performSelector:@selector(animate) withObject:nil afterDelay:self.delay]; } 画面切换中唯一活动的部分是蒙版图层的动画。我们需要增大比例,实际是扩大图层,直到把蒙版的透明部分拉伸到覆盖整个视图。此处使用的toValue包含了一点儿容差系数,目的是为了让蒙版足够大,能够完成显露。如果打算对蒙版图像做较大修改,可能需要对计算作调整。 SplashScreenReveal/PRPSplashScreenViewController.m - (void)animate { if ([self.delegate respondsToSelector:@selector(splashScreenWillDisappear:)]) { [self.delegate splashScreenWillDisappear:self]; } [self setMaskLayerwithanchor]; CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"transform.scale"]; anim.duration = DURATION; anim.toValue = [NSNumber numberWithInt:self.view.bounds.size.height/8]; anim.fillMode = kCAFillModeBoth; anim.removedOnCompletion = NO; anim.delegate = self; [self.view.layer.mask addAnimation:anim forKey:@"scale" ]; } 为了产生所要的效果,我们需要在setMaskLayerwithanchor方法中创建蒙版图层,将其contents设置为适当的蒙版图像,并设置正确的定位点以保证蒙版的不透明区域的种子点与定位点一致。 SplashScreenReveal/PRPSplashScreenViewController.m - (void)setMaskLayerwithanchor { CALayer *maskLayer = [CALayer layer]; maskLayer.anchorPoint = self.anchor; maskLayer.frame = self.view.superview.frame; maskLayer.contents = (id)self.maskImage.CGImage; self.view.layer.mask = maskLayer; } 我们需要根据选定的枚举值,为所需的切换取出正确的蒙版图像进行设定。 SplashScreenReveal/PRPSplashScreenViewController.m - (UIImage *)maskImage { if (maskImage != nil) [maskImage release]; NSString *defaultPath = [[NSBundle mainBundle] pathForResource:self.maskImageName ofType:@"png"]; maskImage = [[UIImage alloc] initWithContentsOfFile:defaultPath]; return maskImage; } 当动画过程完成了对蒙版图层的拉伸之后,animationDidStop委托会被调用。被切换的视图现在看上去已经消失了,所以我们要做的只是把它从SuperView中删除,并通知委托切换已经完成。 SplashScreenReveal/PRPSplashScreenViewController.m - (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag { self.view.layer.mask = nil; [self.view removeFromSuperview]; if ([self.delegate respondsToSelector:@selector(splashScreenDidDisappear:)]) { [self.delegate splashScreenDidDisappear:self]; } } 逐渐露出的切换为我们的应用程序的开场增色不少,而且易于扩展以提供更多选项。蒙版的透明区域的形状在缩放过程中保持不变,因此其他的形状(比如星形)会产生有趣的效果。我们甚至可以使用更为复杂的形状,比如脸状,但是可能需要添加额外的渐变动画来消除残留的可见部分。 攻略3 为定制的通知视图添加动画 问题 有时,当程序发生改变时(比如后台任务完成了),我们需要发出通知。苹果公司提供的通知机制(如UIAlertView)通常是模式化的,有时不太理想,因为通知会把用户的注意力从主程序夺走,必须点击才能关闭。如何才能创建一种非模式化的机制,既能引起注意又可以置之不理呢? 解决方案 我们需要一种足够通用,而且不怎么依赖app画面布局的解决方案。有几种技巧可供选择,但本攻略将使用滑入画面的UIView,在点击后关闭,或者在一定时间之后自动消失。使用滑动动画应该既能引起用户注意,又可以让他不予理睬,只要动画不占掉太多的屏幕就行。 我们创建UIView的一个子类SlideInView,这里用到两种方法。第一种是使用showWithTimer:inView:from:,用于控制外观与时间;第二种是使用viewWithImage,这是一个用UIImage来实例化视图的类方法。我们也可以在Interface Builder中创建视图,这样可以做出带有标签(label)与图像的更为动态的通知。通过使用标签,我们仅仅修改其中的文本就能重用SlideInView(参见图8)。 图8 SlideInView示例程序 SlideInView/SlideInView.m + (id)viewWithImage:(UIImage *)SlideInImage { SlideInView *SlideIn = [[[SlideInView alloc] init] autorelease]; SlideIn.imageSize = SlideInImage.size; SlideIn.layer.bounds = CGRectMake(0, 0, SlideIn.imageSize.width, SlideIn.imageSize.height); SlideIn.layer.anchorPoint = CGPointMake(0, 0); SlideIn.layer.position = CGPointMake(-SlideIn.imageSize.width, 0); SlideIn.layer.contents = (id)SlideInImage.CGImage; return SlideIn; } 类方法viewWithImage:实例化视图并设置其下面的层的contents属性指向UIImage。视图的位置设置为屏幕之外,但是还需要根据动画的方向与位置再做调整。 SlideInView/SlideInView.m - (void)awakeFromNib { self.imageSize = self.frame.size; self.layer.bounds = CGRectMake(0, 0, self.imageSize.width, self.imageSize.height); self.layer.anchorPoint = CGPointMake(0, 0); self.layer.position = CGPointMake(-self.imageSize.width, 0); } awakeFromNib()方法在SlideInView的实例解包之后会被调用,所以我们只需要保证视图位于屏幕之外。 SlideInView/SlideInView.m switch (side) { // 对齐视图并设定调整值 case SlideInViewTop: self.adjustY = self.imageSize.height; fromPos = CGPointMake(view.frame.size.width/2-self.imageSize.width/2, -self.imageSize.height); break; case SlideInViewBot: self.adjustY = -self.imageSize.height; fromPos = CGPointMake(view.frame.size.width/2-self.imageSize.width/2, view.bounds.size.height); break; case SlideInViewLeft: self.adjustX = self.imageSize.width; fromPos = CGPointMake(-self.imageSize.width, view.frame.size.height/2-self.imageSize.height/2); break; case SlideInViewRight: self.adjustX = -self.imageSize.width; fromPos = CGPointMake(view.bounds.size.width, view.frame.size.height/2-self.imageSize.height/2); break; default: return; } showWithTimer:inView:from:bounce:方法有3个参数:目标视图、表示从哪一侧滑入的枚举值,以及向滑动动画添加附加反弹要素的选项。我们根据这个枚举值设定adjustX与adjustY的值,用于计算动画的终点,同时还设定fromPos的值,让视图的起始位置位于指定方向的屏幕之外。 SlideInView/SlideInView.m CGPoint toPos = fromPos; CGPoint bouncePos = fromPos; bouncePos.x += (adjustX*1.2); bouncePos.y += (adjustY*1.2); toPos.x += adjustX; toPos.y += adjustY; CAKeyframeAnimation *keyFrame = [CAKeyframeAnimation animationWithKeyPath:@"position"]; keyFrame.values = [NSArray arrayWithObjects: [NSValue valueWithCGPoint:fromPos], [NSValue valueWithCGPoint:bouncePos], [NSValue valueWithCGPoint:toPos], [NSValue valueWithCGPoint:bouncePos], [NSValue valueWithCGPoint:toPos], nil]; keyFrame.keyTimes = [NSArray arrayWithObjects: [NSNumber numberWithFloat:0], [NSNumber numberWithFloat:.18], [NSNumber numberWithFloat:.5], [NSNumber numberWithFloat:.75], [NSNumber numberWithFloat:1], nil]; bounce选项导致使用关键帧动画,添加必要的附加values与keyTimes,在适当方向上产生轻微反弹的效果。关键帧动画是产生非标准动画曲线的一种强大而灵活的技术。keyTimes是代表在整个动画时间中所占比例的单位值,跟位置的值相对应。 SlideInView/SlideInView.m CABasicAnimation *basic = [CABasicAnimation animationWithKeyPath:@"position"]; basic.fromValue = [NSValue valueWithCGPoint:fromPos]; basic.toValue = [NSValue valueWithCGPoint:toPos]; self.layer.position = toPos; [self.layer addAnimation:basic forKey:@"basic"]; 如果bounce选项设为NO,我们对层应用较简单的CABasicAnimation,实现到指定位置的滑动。 SlideInView/SlideInView.m popInTimer = [NSTimer scheduledTimerWithTimeInterval:timer target:self selector:@selector(popIn) userInfo:nil repeats:NO]; 因为想让通知自动消失,所以我们添加了一个NSTimer对象,在指定时间之后调用popIn方法。 SlideInView/SlideInView.m [UIView beginAnimations:@"slideIn" context:nil]; self.frame = CGRectOffset(self.frame, -adjustX, -adjustY); [UIView commitAnimations]; 关闭视图时无需考虑使用哪种动画样式——我们可以使用一个UIView的动画块把视图移出屏幕。只需使用先前计算出的调整变量的负值,来保证从正确的方向进行移出屏幕的动画。 SlideInView/SlideInView.m - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [popInTimer invalidate]; [self popIn]; } 对SlideInView的单击(会触发touchesBegan:withEvent委托方法),就足以取消定时器并触发滑动回去的动画。 MainViewController类展示了SlideInView对象的4种可能方向的用法。IBSlideIn实例由Interface Builder创建,并示范了如何使用多元素的视图创建更有趣而且可能更具活力的通知。 我们可以轻松地修改这一技巧,来实现各种切换效果,比如淡入淡出、翻转,或者在视图本身使用附加的动画以增强通知的视觉效果。 攻略4 创建可重用的开关按钮 问题 我们想要创建定制的按钮,可以在“开”与“关”的状态间切换,但是UISwitch不太符合我们的设计。我们要求能够重用这个按钮,不必在每个视图控制器中都去编写状态管理的代码。 解决方案 UIButton类相当通用,通过少量的定制,就能够容易地实现这个功能。作为UIControl的子类,UIButton支持包括高亮、启用和选中在内的多种状态。我们可以为这些状态设置自定义的图像、文本与文本颜色。这种灵活性足够我们用来向标准的UIButton添加开关支持了。 我们来看看都需要做什么。我们需要3幅按钮图像:普通(或“关”)、选中(或“开”),还有较暗的“按下”状态。这3种状态的外观如图9所示。为了使支持这3种状态的过程更容易,乃至能自动化,我们要声明一个子类。这个PRPToggleButton类会替我们处理所有状态与图像的管理工作,这样控制器的代码就不会因为处理每次的按钮按下被图像名称和文本颜色搞乱。我们甚至可以在Interface Builder(IB)中创建按钮,这样就可以为每个状态设置图像、文本和颜色。 PRPToggleButton的“关”、“开”和“高亮”(手指按下)状态的演示。 图9 基于图像的开关按钮 子类的声明非常简单:声明了一个布尔型属性,控制按钮是否在点击时自动切换状态;还声明了一个便利方法,设置与管理各种按钮状态图像。 ToggleButton/Classes/PRPToggleButton.h @interface PRPToggleButton : UIButton {} // 默认设为YES @property (nonatomic, getter=isOn) BOOL on; @property (nonatomic, getter=isAutotoggleEnabled) BOOL autotoggleEnabled; + (id)buttonWithOnImage:(UIImage *)onImage offImage:(UIImage *)offImage highlightedImage:(UIImage *)highlightedImage; - (BOOL)toggle; @end 因为开关按钮往往是基于图像的,所以我们创建一个便利方法,来减少对-setBack- groundImage:forState:的过多调用。这样就能减轻控制器代码所做的工作,减少潜在的程序错误。这个方法把“开”与“关”的图像保存到属性中,以便根据相应的按钮状态进行使用。 ToggleButton/Classes/PRPToggleButton.m + (id)buttonWithOnImage:(UIImage *)onImage offImage:(UIImage *)offImage highlightedImage:(UIImage *)highlightedImage { PRPToggleButton *button; button = [self buttonWithType:UIButtonTypeCustom]; button.onImage = onImage; button.offImage = offImage; [button setBackgroundImage:offImage forState:UIControlStateNormal]; [button setBackgroundImage:highlightedImage forState:UIControlStateHighlighted]; button.autotoggleEnabled = YES; return button; } 请注意自动切换行为被显式地设为YES,因为布尔型实例变量的默认值为NO。 我们通过窥视UIControl的标准构造来追踪触摸事件,以实现自动切换。这样做是为了找出何时按钮收到适当的点击,而内置的控制逻辑不受影响。 ToggleButton/Classes/PRPToggleButton.m - (void)endTrackinWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { [super endTrackingWithTouch:touch withEvent:event]; if (self.touchInside && self.autotoggleEnabled) { [self toggle]; } } -toggle方法很方便地就翻转了按钮的on属性,真正的工作是在-setOn访问方法里完成的,它根据开/关状态切换默认的背景图像。 ToggleButton/Classes/PRPToggleButton.m - (BOOL)toggle { self.on = !self.on; return self.on; } ToggleButton/Classes/PRPToggleButton.m - (void)setOn:(BOOL)onBool { if (on != onBool) { on = onBool; [self setBackgroundImage:(on ? self.onImage : self.offImage) forState:UIControlStateNormal]; } } 给这个类增加对IB的支持非常简单。由于IB使用存档器从nib文件加载对象,+button- WithOnImage:OffImage:中的代码不会执行。所以,我们实现了-awakeFromNib方法来正确地初始化自动切换行为。 ToggleButton/Classes/PRPToggleButton.m - (void)awakeFromNib { self.autotoggleEnabled = YES; self.onImage = [self backgroundImageForState:UIControlStateSelected]; self.offImage = [self backgroundImageForState:UIControlStateNormal]; [self setBackgroundImage:nil forState:UIControlStateSelected]; } 现在在Interface Builder中使用开关按钮易如反掌。只需把Button对象从库中拖动到父视图,选择Identity Inspector,输入PRPToggleButton作为按钮的类。如果在输入类名时没有自动补全为这个类,可能是类没有找到。到底是不是这样,一执行项目就清楚了:如果在控制台显示“Unknown class PRPToggleButton in Interface Builder file”,那么要么是编译后的程序中少了这个类,要么是类名跟我们在Custom Class中输入的不一致,我们需要检查两处的拼写。图10显示了Xcode 4中的XIB配置。 PRPToggleButton使用标准按钮的状态和背景图像来管理其开/关状态,所以我们可以直接在IB中设置这些值。记住要在Identity Inspector中设定定制类,否则得到的就是个普通的UIButton。 图10 在Interface Builder中使用PRPToggleButton 配置好类身份之后,我们选择Attributes Inspector,分别为Selected、Default和Highlighted状态配置“开”、“关”和“高亮”图像。这里有个小技巧,PRPToggleButton只是针对控件的Normal状态切换两幅图像,但我们在IB中需要有个位置来保存“开”的图像。我们临时使用Selected状态,之后在-awakeFromNib中将其复原。请再次阅读代码看看它是怎么做的。 执行应用程序,请注意切换是自动处理的。只有在我们的应用程序的逻辑需要对状态的改变做响应时,才需要添加目标与动作。我们可以随时检查按钮的on属性,获知其开关状态。 好啦!现在我们有了内置的可重用的开关支持,可用于任何程序,既适用于代码又适用于nib。如果只是使用标准的UIButton,那就得在每个想要实现这个功能的视图控制器中加入大量管理代码。把这些逻辑抽出来放到定制按钮中,我们的控制器代码就简洁多了。 ToggleButton/Classes/ToggleButtonViewController.m self.toggleButton = [PRPToggleButton buttonWithOnImage:self.buttonOnImage offImage:self.buttonOffImage highlightedImage:highlightedImage]; CGFloat buttonWidth = self.buttonOnImage.size.width; CGFloat buttonHeight = self.buttonOffImage.size.height; self.toggleButton.frame = CGRectMake(kButtonX, 100.0, buttonWidth, buttonHeight); [self.view addSubview:toggleButton]; 响应PRPToggleButton的点击跟其他按钮相同:只需在代码或IB中添加目标/动作,然后在检查on属性的值之后做想做的事情就行了。 ToggleButton/Classes/ToggleButtonViewController.m - (IBAction)toggleButtonTapped:(id)sender { if ([sender isOn]) { NSLog(@"Toggle button was activated!"); } else { NSLog(@"Toggle button was deactivated!"); } } 请读者一定要看看跟此代码在一起的-[ToggleButtonViewController viewDidLoad]和-[ToggleButtonViewController plainButtonTapped:]中基于UIButton的实现,看看我们节省了多少工作量。使用PRPToggleButton不仅减少了工作量,而且控制器的角色更加清晰了:动作代码只是去响应状态改变,而不是去管理它。 攻略5 形成带彩色纹理的圆角视图 问题 我们使用的UIView的子类、按钮和标签显得有些呆板,我们想添加一些纹理作为背景,最好带有圆角和边界线。 解决方案 iOS中所有UIView都是层支持视图(layer-backed),就是说视图或子视图建立在自己的基于硬件的图层之上。这对性能非常有利,因为我们不必重画就能对视图进行移动、缩放或变换。但我们也可以直接操作视图下面的图层的属性,更进一步地访问视图的内部工作方式。 每个UIView或其子类都公开有layer属性,这是对底层图层的只读引用,但那个图层的所有属性都是可以修改的。这里我们关心的CALayer属性包括backgroundColor、borderWidth、borderColor和cornerRadius。任何UIView的子类,只要对CALayer设置这些属性中的任何一个,都会对视图的显示有直接影响(参见图11)。 图11 带纹理的圆角视图 仅仅通过设置图层的backgroundColor并不能得到想要的带有纹理、圆角和边界线的外观。我们需要使用UIColor的类方法colorWithPatternImage:,它可以从任何图像创建重复图案。但我们一定要认真挑选图像,否则重复部分的接缝会很明显。为了避免这一问题,我们可以使用更大的,也许尺寸更接近目标视图的图像。如果我们把图案用于backgroundColor属性,这一点尤为重要,因为实际上是在设置视图的背景图像。这用起来很容易,因为它仍然是一个UIColor对象,所以任何接受UIColor对象的方法或属性都会接受图案图像。 创建好一系列图案颜色之后,我们实例化一个普通的UIButton对象。然后修改所需的图层属性,设置cornerRadius得到一个8点宽边框的圆角矩形,borderColor与backgroundColor使用图案颜色,以达到所需的效果。 为TouchDown和TouchUpInside事件设置目标/动作对,在按钮按下时为borderColor和cornerRadius属性设定不同的值,能够给用户以清晰的反馈。 RoundedView/Classes/RoundedViewViewController.m // 用UIImages定义带纹理的颜色 thickColor = [UIColor colorWithPatternImage: [UIImage imageNamed:@"thickColorGradient.png"]]; UIColor *grayGradient = [UIColor colorWithPatternImage: [UIImage imageNamed:@"grayGradient.png"]]; UIColor *steelColor = [UIColor colorWithPatternImage: [UIImage imageNamed:@"simpleSteel.png"]]; UIColor *steelTexture = [UIColor colorWithPatternImage: [UIImage imageNamed:@"steelTexture.png"]]; UIColor *woodTexture = [UIColor colorWithPatternImage: [UIImage imageNamed:@"woodTexture.png"]]; CGRect buttonFrame = CGRectMake(60, 60, 200,80); UIButton *roundButton = [[UIButton alloc] initWithFrame:buttonFrame]; roundButton.layer.borderWidth = 8; roundButton.layer.borderColor = thickColor.CGColor; roundButton.layer.backgroundColor = grayGradient.CGColor; roundButton.layer.cornerRadius = roundButton.bounds.size.height/4; [self.view addSubview:roundButton]; [roundButton addTarget:self action:@selector(buttonPressed:) forControlEvents:UIControlEventTouchDown]; [roundButton addTarget:self action:@selector(buttonReleased:) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside]; 这里我们使用的是一个带有UILabel子视图的UIView。像先前的按钮一样操作视图的图层,给标签产生有趣的背景。 RoundedView/Classes/RoundedViewViewController.m UILabel *labelA = [self centeredLabel:buttonFrame label:@"Colorful"]; labelA.font = [UIFont fontWithName:@"MarkerFelt-Thin" size:36]; labelA.textColor = thickColor; [roundButton addSubview:labelA]; CGRect viewFrame = CGRectMake(30, 210, 260, 50); UIView *steelView = [[UIView alloc] initWithFrame:viewFrame]; steelView.layer.borderWidth = 5; steelView.layer.borderColor = steelColor.CGColor; steelView.layer.backgroundColor = steelTexture.CGColor; steelView.layer.cornerRadius = steelView.bounds.size.height/4; [self.view addSubview:steelView]; UILabel *labelB = [self centeredLabel:viewFrame label:@"Brushed Steel"]; labelB.font = [UIFont fontWithName:@"TrebuchetMS-Bold" size:28]; labelB.textColor = steelColor; [steelView addSubview:labelB]; 我们可以更进一步地直接修改UILabel的图层的属性,来达到相同的效果。 RoundedView/Classes/RoundedViewViewController.m CGRect labelFrame = CGRectMake(10, 340, 300, 40); UILabel *label = [self centeredLabel:labelFrame label:@"A Much Longer Label"]; label.frame = labelFrame; label.font = [UIFont fontWithName:@"Thonburi-Bold" size:24]; label.textColor = steelColor; label.shadowColor = [UIColor blackColor]; label.layer.borderWidth = 4; label.layer.borderColor = steelColor.CGColor; label.layer.backgroundColor = woodTexture.CGColor; label.layer.cornerRadius = label.frame.size.height/2; [self.view addSubview:label]; 由CALayer类公开的其他属性不能用于UIView类,所以有必要读一下iOS的文档,看看都能得到什么有趣的效果。 攻略6 组装可重用的网页视图 问题 如果只是打开URL而不跳到Safari,苹果商店里有些一流的个性化本地app也还是要不时地依靠网页内容。UIWebView是个易用而优秀的类,但即便只显示一个网页,也需要写相当多的支持代码。 解决方案 我们可以制作既能模式化显示,也能作为导航栈的一部分来显示的基本网页视图,把它应用于多个项目就能够节省时间和工作量。控制器从调用代码得到URL,并在视图加载时自动加载内容。它在加载页面时,会显示一个活动指示器视图,当内容可以显示时再执行平滑的切换。我们调用这个控制器,仅仅使用几行代码来显示它,然后返回,继续重要的工作。图12显示了在活动中的这一视图。 我们的网页视图控制器最初显示一个活动指示器,当网页内容加载完毕后淡入切换到网页内容。 图12 可重用的网页视图控制器 PRPWebViewController类创建一个基本的、可改变大小的根视图,包含一个用于显示网页内容的UIWebView,并且创建一个很大的白色UIActivityIndicatorView来告诉用户网页内容正在加载。我们在代码中创建这个层次结构,就不用在每次重用这个类的时候都带着一个xib文件了。 活动指示器在加载时在主视图里居中,而且设置了所有自动调整页边空白大小的标志位,以保证在主视图改变大小或旋转后它都能保持居中。 SmartWebView/PRPWebViewController.m activityIndicator.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleBottomMargin | UIViewAutoresizingFlexibleLeftMargin; CGRect aiFrame = self.activityIndicator.frame; CGFloat originX = (self.view.bounds.size.width - aiFrame.size.width) / 2; CGFloat originY = (self.view.bounds.size.height - aiFrame.size.height) / 2; aiFrame.origin.x = floorl(originX); aiFrame.origin.y = floorl(originY); self.activityIndicator.frame = aiFrame; 我们对计算出的原点取整,以避免出现会让视图模糊的非整数坐标。 我们的控制器实现了标准的UIWebViewDelegate方法,来检测请求何时结束。如果加载成功,就隐藏活动指示器,淡入到网页视图。这样在用户等待内容的显示时会有较为平滑的切换。控制器会还从加载的HTML中取出title元素,设置到自己的导航标题。 SmartWebView/PRPWebViewController.m - (void)webViewDidFinishLoad:(UIWebView *)wv { [self.activityIndicator stopAnimating]; [self fadeWebViewIn]; if (self.title == nil) { NSString *docTitle = [self.webView stringByEvaluatingJavaScriptFromString:@"document.title;"]; if ([docTitle length] > 0) { self.navigationItem.title = docTitle; } } SEL sel_didFinishLoading = @selector(webControllerDidFinishLoading:); if ([self.delegate respondsToSelector:sel_didFinishLoading]) { [self.delegate webControllerDidFinishLoading:self]; } } 何时网页视图真正结束加载 根据所加载的内容,UIWebView有时可能有点难以预料。如果请求的页面包含iframe或者动态内容,我们的代码可能会收到多个webViewDidFinishLoad:消息。因为每个用例可能对“结束”有不同的定义,所以本攻略里不对这些多次的回调作任何监视。读者可以修改这个类来满足自己的特定需要。 请注意如果视图控制器的title属性已经设定,代码就不再改动它。所以如果读者使用PRPWebViewController,并且想要静态的而不是根据网页内容生成的导航标题,那么只需在创建时设定视图控制器的title属性即可。 此外还公开了一个backgroundColor属性,以便定制加载时的视图外观。 SmartWebView/PRPWebViewController.m - (void)setBackgroundColor:(UIColor *)color { if (backgroundColor != color) { [backgroundColor release]; backgroundColor = [color retain]; [self resetBackgroundColor]; } } 为什么要给背景色创建一个特殊的属性呢?为什么不直接设置视图的背景?因为,根据设定的时机,直接设置可能会导致视图过早加载。resetBackgroundColor方法仅在视图加载完毕后设置颜色。从setBackgroundColor:与ViewDidLoad调用这一方法既实现了调用方(caller)的目的,又符合UIKit的懒加载机制。 SmartWebView/PRPWebViewController.m - (void)resetBackgroundColor { if ([self isViewLoaded]) { UIColor *bgColor = self.backgroundColor; if (bgColor == nil) { bgColor = [UIColor whiteColor]; } self.view.backgroundColor = bgColor; } } 还有一个便利的BOOL型属性,用以生成系统的“完成”按钮,这在模式显示控制器的时候会用得到。工具条按钮是一种一直需要创建却相当繁琐的东西,所以在这里我们为可重用的控制器打包一个按钮。 SmartWebView/PRPWebViewController.m - (void)setShowsDoneButton:(BOOL)shows { if (showsDoneButton != shows) { showsDoneButton = shows; if (showsDoneButton) { UIBarButtonItem *done = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(doneButtonTapped:)]; self.navigationItem.rightBarButtonItem = done; [done release]; } else { self.navigationItem.rightBarButtonItem = nil; } } } 要替我们完成这些工作,PRPWebViewController需要作为网页视图的委托。但是如果我们的代码需要处理加载失败或需要知道何时网页加载结束,该怎么办呢?委托是一种“一对一”的关系,所以我们不能简单地从PRPWebViewController盗用UIWebViewDelegate的角色,否则将破坏其功能。所以在这里,我们声明一个新的PRPWebViewControllerDelegate委托,把相关的事件转发给有关各方。 SmartWebView/PRPWebViewControllerDelegate.h @class PRPWebViewController; @protocol PRPWebViewControllerDelegate <NSObject> @optional - (void)webControllerDidFinishLoading:(PRPWebViewController *)controller; - (void)webController:(PRPWebViewController *)controller didFailLoadWithError:(NSError *)error; - (BOOL)webController:(PRPWebViewController *)controller shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)orientation; @end 自动旋转的方法让我们可以根据自己的UI来指示控制器的行为。所有这些协议方法都是可选的:任何一个都不是非要实现。PRPWebView仍然可以独立工作。 现在我们有了一个独立工作的网页视图,在内容加载之后会平滑地切换过去。为了在app中包含网页内容,我们要做的只是生成一个PRPWebViewController,设置URL,然后显示它。 攻略7 定制滑动条与进度条 问题 也许标准的UISlider与UIProgressView的外观跟你的app的其他部分不一致。但在Interface Builder中只能调整宽度,那么怎么做才能改变这些元素的外观呢? 解决方案 如果不满足于基本的外观,就需要深入代码,探寻若干图像属性,理解可拉伸UIImage的用法。 UISlider有以下一系列属性在Interface Builder中是看不到的:currentMaximumTrackImage、currentMinimumTrackImage和currentThumbImage。这些属性带来了很大的灵活性,让我们可以为控件指定其他图像。要充分利用这些属性,就需要理解可拉伸的UIImage是如何工作的(参见图13)。 图13 定制滑动条的演示画面 跟其他图像一样,我们从图像文件创建可拉伸的UIImage,但必须同时设置leftCapWidth与topCapHeight的值。我们通过调用stretchableImageWithLeftCapWidth:topCapHeight方法,定义不被拉伸部分的长度。如果图像宽100点,而我们把leftCapWidth定义为49,那么第50个点将是被拉伸(或者说被复制)的点,而剩下的50个点将保持不变。如果再把图像长度设为200,那么将插入拉伸点的150个副本以填满图像。可以看到,我们需要精心挑选图像与拉伸点,使其拉伸之后仍能正确显示。请看示例代码中的图像。图像看起来形状怪异,但拉伸之后可以得到我们想要的样子。 CustomSlider/Classes/CustomSliderViewController.m UIImage* sunImage = [UIImage imageNamed:@"sun.png"]; [customSlider setThumbImage:sunImage forState:UIControlStateNormal]; 我们可以使用thumbImage属性为滑动条上可拖动的元素设置新的图像。这个例子中,跟默认的白色圆点比起来,太阳的图像显得格外醒目,而它也同时起到隐藏两段滑轨图像接缝的作用。 CustomSlider/Classes/CustomSliderViewController.m customProgress.userInteractionEnabled = NO; UIImage* sliderPoint = [UIImage imageNamed:@"sliderPoint.png"]; [customProgress setThumbImage:sliderPoint forState:UIControlStateNormal]; UIImage *leftStretch = [[UIImage imageNamed:@"leftImage.png"] stretchableImageWithLeftCapWidth:10.0 topCapHeight:0.0]; [customProgress setMinimumTrackImage:leftStretch forState:UIControlStateNormal]; UIImage *rightStretch = [[UIImage imageNamed:@"rightImage.png"] stretchableImageWithLeftCapWidth:10.0 topCapHeight:0.0]; [customProgress setMaximumTrackImage:rightStretch forState:UIControlStateNormal]; 这里我们没有创建真正的UIProgressView,而是使用部分禁用的滑动条来实现同样的效果,这样做可以使用与UISlider相同的设计技巧。这里有个技巧,我们使用的滑块图像小得多,而且实际起到了低端滑轨图像(mininum track image) 的尾端的作用。由于userInteraction- Enabled属性设为了NO,而且没有能看见的可拖动元素,滑动条看上去就是特殊风格的进度条。 演示版的app包括了一个定时器,点击屏幕上端的按钮就会启动,可以展示通过修改UISlider的value属性来创建动态进度条有多么容易。 攻略8 打造自己的手势识别器 问题 苹果公司提供了一系列基本的手势识别器,可是如果我们有进一步的要求,想识别更加复杂的手势怎么办呢? 解决方案 苹果公司从iOS 3.2开始引入了手势识别器,这对触摸识别的需求来说是最佳的解决方案。手势识别器很好用,我们不再需要编写冗长代码来管理触摸输入的各个阶段。对于基本的手势,如点击(tap)、捏夹(pinch)、旋转(rotate)、轻拂(swipe)、拖拽(pan)以及长按(long press),都有可供使用的识别器。但如果想进一步识别更加复杂的手势,比如划圆圈,就需要构建自己的手势识别器。 我们从抽象类UIGestureRecognizer派生一个新的PRPCircleGestureRecognizer类,但需要包含UIKit/UIGestureRecognizerSubclass.h,因为这个文件声明了我们所需的额外的方法与属性。我们还需要决定让手势识别器是单次的(discrete)还是连续的(continuous)。单次的识别器仅当手势被完全识别时才触发委托动作,而连续的手势识别器对于其认为有效的每个触摸事件都会触发委托动作。 如何选择合适的识别器类型,取决于我们想如何识别圆。每个触摸点都必须接近圆周,当然允许有一定偏差。可惜圆的圆心和半径我们都不知道,因此圆周的位置也不知道。要解决这个问题,就必须保存每个触摸点的位置,直到手势结束,这样我们才能用手势的极值点来算出直径,并确定圆心的位置和半径。所以圆手势识别器只能是单次的,因为只有当用户的手势结束之后才能对触摸点作检验。 基类处理所有触摸并对委托动作做必要的回调,因此在我们实现的每个委托方法中,必须包含对Super方法的调用。基类所监视的用于管理识别过程的基本状态机同样非常重要。对于单次的识别器,state属性只能设置为以下状态之一 : UIGestureRecognizerStatePossible UIGestureRecognizerStateRecognized UIGestureRecognizerStateFailed UIGestureRecognizerStatePossible是初始状态,表明识别过程在执行当中。如果识别成功,那么state属性将设为UIGestureRecognizerStateRecognized,然后会调用委托动作选择器。如果过程中发现有任何一个触摸点位于算出的圆的范围之外,state属性将设为UIGestureRecognizerStateFailed,同时触发对reset方法的调用,用以对过程重新初始化并等待新的触摸序列。 CircleGestureRecognizer/PRPCircleGestureRecognizer.m - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesBegan:touches withEvent:event]; if ([self numberOfTouches] != 1) { self.state = UIGestureRecognizerStateFailed; return; } self.points = [NSMutableArray array]; CGPoint touchPoint = [[touches anyObject] locationInView:self.view]; lowX = touchPoint; lowY = lowX; if (self.deviation == 0) self.deviation = 0.4; moved = NO; } touchesBegan:withEvent:方法起到初始器的作用,并实例化保存触摸点的可变数组。然后添加第一个触摸点,并把lowX和lowY设为当前点,以后就可以用它们进行最长线段的计算。如果deviation属性还没有设定,就赋以默认值。 CircleGestureRecognizer/PRPCircleGestureRecognizer.m - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesMoved:touches withEvent:event]; if ([self numberOfTouches] != 1) { self.state = UIGestureRecognizerStateFailed; } if (self.state == UIGestureRecognizerStateFailed) return; CGPoint touchPoint = [[touches anyObject] locationInView:self.view]; if (touchPoint.x > highX.x) highX = touchPoint; else if (touchPoint.x < lowX.x) lowX = touchPoint; if (touchPoint.y > highY.y) highY = touchPoint; else if (touchPoint.y < lowY.y) lowY = touchPoint; [self.points addObject:[NSValue valueWithCGPoint:touchPoint]]; moved = YES; } 对于追踪到的每个点,都会调用touchesMoved:withEvent:方法。不允许多点触摸,如果检测到多点触摸,state属性就设为UIGestureRecognizerStateFailed。在这个阶段,因为圆周还不确定,无法对触摸点作检验,所以把它添加到points数组。要计算直径,需要确定x轴与y轴方向的边界点。如果触摸点超出了现有的边界点,就把边界点的值重置为触摸点。为了避免把单一点识别为圆,我们把moved的布尔值设置为YES,这表示touchesMoved:withEvent:方法至少调用了一次。 CircleGestureRecognizer/PRPCircleGestureRecognizer.m - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesEnded:touches withEvent:event]; if (self.state == UIGestureRecognizerStatePossible) { if (moved && [self recognizeCircle]) { self.state = UIGestureRecognizerStateRecognized; } else { self.state = UIGestureRecognizerStateFailed; } } } touchesEnded:withEvent:方法只需要确认触摸点移动过,然后调用recognizeCircle方法执行实际的检验。 CircleGestureRecognizer/PRPCircleGestureRecognizer.m - (BOOL) recognizeCircle { CGFloat tempRadius; CGPoint tempCenter; CGFloat xLength = distanceBetweenPoints(highX, lowX); CGFloat yLength = distanceBetweenPoints(highY, lowY); if (xLength > yLength) { tempRadius = xLength/2; tempCenter = CGPointMake(lowX.x + (highX.x-lowX.x)/2, lowX.y + (highX.y-lowX.y)/2); } else { tempRadius = yLength/2; tempCenter = CGPointMake(lowY.x + (highY.x-lowY.x)/2, lowY.y + (highY.y-lowY.y)/2); } CGFloat deviant = tempRadius * self.deviation; CGFloat endDistance = distanceBetweenPoints([[self.points objectAtIndex:0] CGPointValue], [[self.points lastObject] CGPointValue]); if (endDistance > deviant*2) { return NO; } for (NSValue *pointValue in self.points) { CGPoint point = [pointValue CGPointValue]; CGFloat pointRadius = distanceBetweenPoints(point, tempCenter); if (abs(pointRadius - tempRadius) > deviant) { return NO; } } self.radius = tempRadius; self.center = tempCenter; return YES; } recognizeCircle方法计算存储在边界点变量lowX、highX、lowY和highY中的触摸点之间的距离,取最长的距离为直径。确定了直径之后,就很容易算出圆心与半径,然后根据半径与deviation属性算出偏差值。为保证识别的是整圆,触摸的第一点与最后一点不应距离太远(偏差值的两倍);如果距离太远,state属性会设置为UIGestureRecognizerStateFailed。points数组中的每个点都会被检验,方法是保证点与圆心的距离在半径加减偏差之间。如果所有的点都通过了检验,就设置radius与center属性,然后返回YES,表示成功。这之后touchesEnded: withEvent:方法把state属性设置为UIGestureRecognizerStateRecognized。 成功的时候,基类的代码会调用在手势识别器实例化时指定的委托动作选择器,在这里就是mainViewController.m中的circleFound方法。在我们的例子中,会在与识别器关联的UIView中根据半径大小与识别的圆的位置绘制一张笑脸。 尽管本章的代码仅限于识别圆形手势,但读者对它稍作修改,还可以识别其他类型的手势。 攻略9 创建独立的警告视图 问题 UIAlertView类提供易用且一致的接口,向用户展现重要的信息。然而此类视图中对用户输入的响应逻辑却很麻烦,并且容易出错。如果能有一种UIAlertView功能独立、易于使用,且易于从控制器代码中与之交互,那该有多好啊! 解决方案 UIKit的库中提供了苹果公司设计的丰富的控件,可用于任何app,UIAlertView就是个很好的例子:我们可以使用苹果公司设计的带有标题栏、消息文本和按钮的对话框,它还能将屏幕变暗,把用户的注意力吸引到警告视图。 创建警告相当容易:我们把它初始化然后调用show,其他的事情苹果公司都做好了。如果我们只是向用户显示一条消息,而无需用户采取行动,那么这是很简单的流程。然而,如果我们向用户提供了选项,并且需要响应这些选项,那就有些工作要做了:要把代码设置为警告视图的委托,再实现若干UIAlertViewDelegate协议的方法,比如-alertView:clickedButtonAtIndex:。 复杂而危险的事情正是隐藏在这些委托方法之中。我们必须判定用户点击了哪个按钮,才能相应地做出响应。但怎么做最好呢?我们有下面两种选择: 用硬编码(hard-coded)的方式对按钮的索引作比较或switch; 向警告视图发送-buttonTitleAtIndex消息,然后比较字符串。 传递给我们的委托方法的buttonIndex没什么用处,因为我们最初是以可变参数的形式向标准的-initWithTitle:message:…方法传递按钮标题。也许你已在别处把索引定义为常量,但是这样就在代码中引入了不必要的耦合。 第2种方案(比较按钮的标题)风险小一些:假如我们把字符串定义为全局的,或者使用本地化字符串,即使在按钮标题重新排列之后,代码也仍然能正常工作。 不管哪种方案,都会给代码重构带来困难,而且在每次要显示警告视图时都需要不少的准备工作。如果视图控制器需要根据情况显示多个警告,代码会非常难看:问题就不只是“哪个按钮”,而是“哪个警告的哪个按钮”了。 PRPAlertView/ScrapCode.m - (void)alertView:(UIAlertView *)alertView willDismissWithButtonIndex:(NSInteger)buttonIndex { NSString *buttonTitle = [alertView buttonTitleAtIndex:buttonIndex]; if (alertView == self.offlineAlertView) { if ([buttonTitle isEqualToString:PRPOKTitle]) { // ... } else if ([buttonTitle isEqualToString:PRPCancelTitle]) { // ... } } else if (alertView == self.serverErrorAlertView) { if ([buttonTitle isEqualToString:PRPTryAgainTitle]) { // ... } } } 在Cocoa Touch中委托模型已非常完善,但通过使用块还能让整个过程更加漂亮。我们将创建一个PRPAlertView子类,来简化显示警告的过程。以后我们就可以重用这个组件,它用一个方法就实现了通常需要三四个方法来完成的事情。通过块,我们就可以避免使用委托代码,在创建时定义每个按钮所需的行为,而不是在以后定义,因为到那时就搞不清是哪个警告的哪个按钮了。 这个子类的接口非常简单。我们没有进行初始化或者定义委托,而是使用类方法来直接显示警告。第一个方法接受“取消”按钮或者默认按钮的标题,一个其他按钮的标题,以及在每个按钮被点击时将被调用的块。我们还定义了简单的块类型(没有返回值、没有参数),这样代码的可读性会更好。 PRPAlertView/PRPAlertView/PRPAlertView.h + (void)showWithTitle:(NSString *)title message:(NSString *)message cancelTitle:(NSString *)cancelTitle cancelBlock:(PRPAlertBlock)cancelBlock otherTitle:(NSString *)otherTitle otherBlock:(PRPAlertBlock)otherBlock; PRPAlertView/PRPAlertView/PRPAlertView.h typedef void(^PRPAlertBlock)(void); 还有一个简化版的“只显示”方法,比较便利,用于只需告诉用户一些事情而不需要用户响应的情形。 PRPAlertView/PRPAlertView/PRPAlertView.h + (void)showWithTitle:(NSString *)title message:(NSString *)message buttonTitle:(NSString *)buttonTitle; 实现很简单:这两个便利方法都是使用下面列出的新定义的-initWithTitle:…方法来创建、显示并自动释放警告。这个方法使用copy型的属性保存传入的块和按钮标题,供将来比较之用。同时它也是自身的委托——如果确实传入了一个或多个事件处理器块的话。 PRPAlertView/PRPAlertView/PRPAlertView.m + (void)showWithTitle:(NSString *)title message:(NSString *)message cancelTitle:(NSString *)cancelTitle cancelBlock:(PRPAlertBlock)cancelBlk otherTitle:(NSString *)otherTitle otherBlock:(PRPAlertBlock)otherBlk { [[[[self alloc] initWithTitle:title message:message cancelTitle:cancelTitle cancelBlock:cancelBlk otherTitle:otherTitle otherBlock:otherBlk] autorelease] show]; } PRPAlertView/PRPAlertView/PRPAlertView.m - (id)initWithTitle:(NSString *)title message:(NSString *)message cancelTitle:(NSString *)cancelTitle cancelBlock:(PRPAlertBlock)cancelBlk otherTitle:(NSString *)otherTitle otherBlock:(PRPAlertBlock)otherBlk { if ((self = [super initWithTitle:title message:message delegate:self cancelButtonTitle:cancelTitle otherButtonTitles:otherTitle, nil])) { if (cancelBlk == nil && otherBlk == nil) { self.delegate = nil; } self.cancelButtonTitle = cancelTitle; self.otherButtonTitle = otherTitle; self.cancelBlock = cancelBlk; self.otherBlock = otherBlk; } return self; } init方法和那些属性隐藏到私有的类扩展中,以简化头文件中接口的定义。这样能够增加可读性,并鼓励使用者只使用便利方法(这是使用这个类的最简便方式)。 PRPAlertView/PRPAlertView/PRPAlertView.m @interface PRPAlertView () @property (nonatomic, copy) PRPAlertBlock cancelBlock; @property (nonatomic, copy) PRPAlertBlock otherBlock; @property (nonatomic, copy) NSString *cancelButtonTitle; @property (nonatomic, copy) NSString *otherButtonTitle; - (id)initWithTitle:(NSString *)title message:(NSString *)message cancelTitle:(NSString *)cancelTitle cancelBlock:(PRPAlertBlock)cancelBlock otherTitle:(NSString *)otherTitle otherBlock:(PRPAlertBlock)otherBlock; @end 不需响应的便利方法只是调用同样的代码而不传入事件处理器块。而这个类内部的实际逻辑则完全独立出来。 PRPAlertView/PRPAlertView/PRPAlertView.h + (void)showWithTitle:(NSString *)title message:(NSString *)message buttonTitle:(NSString *)buttonTitle; 那么,这些块是如何让我们避免使用委托的呢?如前所示,PRPAlertView是自身的委托,且内部实现了一个UIAlertViewDelegate方法,使用各个按钮的标题去匹配块。 PRPAlertView/PRPAlertView/PRPAlertView.m - (void)alertView:(UIAlertView *)alertView willDismissWithButtonIndex:(NSInteger)buttonIndex { NSString *buttonTitle = [alertView buttonTitleAtIndex:buttonIndex]; if ([buttonTitle isEqualToString:self.cancelButtonTitle]) { if (self.cancelBlock) self.cancelBlock(); } else if ([buttonTitle isEqualToString:self.otherButtonTitle]) { if (self.otherBlock) self.otherBlock(); } } 关于块与循环保持 本攻略使用隐蔽所创建的UIAlertView的便利方法(初始器隐藏于私有的类扩展之中)。这强化了某种观念,即警告只是“临时性”元素而不会保留很长时间。既然我们使用块(block)来处理按钮事件,这一观念就更加重要了,因为块会保持它所引用的任何对象。比如有个视图控制器从传入这个类的cancelBlock中引用了self,然后把警告保存到属性中供将来再利用。那么视图控制器就保持了警告视图,警告视图有一个块属性,块属性保持了视图控制器。 如果视图控制器被警告的块所保持,那么在警告(以及它的块)被显式释放之前,视图控制器都不会被释放。而警告被卡在视图控制器的一个属性之中,这叫做循环保持(retain cycle),会导致严重的内存泄露。我们不公开所创建的自动释放的警告视图,它就不会被保持,这样就彻底避免了这个问题。警告视图生命周期很短,而分配的代价又不大,所以没有必要抓着不放。 读者大概注意到了这个类只允许两个按钮,屏蔽了UIAlertView对“otherButtonTitles”可变参数列表的支持。这使得代码更简单,说实话,有三个以上按钮的警告视图你见过几个呢?如果你觉得两个按钮不够用,那么先别写代码,很可能是设计上有问题。也就是说,给这个类添加可变参数的支持(需要例子的话可参考攻略36),并且使用字典管理块和标题以便于查找,并不是太难。我们选择保持简单,既降低了难度又更具美感。 有了PRPAlertView,控制器的代码就变得简单多了。没有PRPAlertView的时候,为了显示带有两个按钮的警告,并且让每个按钮有自己的响应动作,我们需要下面这样的代码: PRPAlertView/ScrapCode.m - (void)showAlert { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Uh Oh" message:@"Something bad happened." delegate:self cancelButtonTitle:PRPAlertButtonTitleRunAway otherButtonTitles:PRPAlertButtonTitleOnward, nil]; [alert show]; [alert release]; } - (void)alertView:(UIAlertView *)alertView willDismissWithButtonIndex:(NSInteger)buttonIndex { NSString *buttonTitle = [alertView buttonTitleAtIndex:buttonIndex]; if ([buttonTitle isEqualToString:PRPAlertButtonTitleAbort]) { [self runAway]; } else if ([buttonTitle isEqualToString:PRPAlertButtonTitleOnward]) { [self proceedOnward]; } } 使用PRPAlertView时,代码是这样的: PRPAlertView/ScrapCode.m - (void)showAlert { [PRPAlertView showWithTitle:@"Uh Oh" message:@"Something bad happened." cancelTitle:PRPAlertButtonTitleAbort cancelBlock:^(void) { [self runAway]; } otherTitle:PRPAlertButtonTitleOnward otherBlock:^(void) { [self proceedOnward]; } ]; } 使用本攻略,我们不必再去管内存管理、重构、耦合或者使用同一个控制器的多个交互警告的复杂情况。警告所需要做的都在其创建之处做了定义。这样就使得哪些代码响应警告的按钮变得非常清楚,我们自己或者继承我们代码的人就不必千辛万苦地去寻找旧的委托方法的藏身之处了。 攻略10 表示带属性字符串的标签 问题 iOS的标签(label)类不能显示带属性的字符串,即包含下划线、颜色或不同字体的“富文本”(rich text)格式的字符串。 解决方案 苹果公司向iOS添加用于低级文本渲染的核心文本API时,也应该把NSAttributedString类包括进来,为文本格式化提供更强大的功能。虽然OS X可以通过UI控件渲染带属性的字符串,但目前iOS还不行。 核心文本API处理字形(glyph)、字距调整(kerning)、连续文本(text run)和文本行(line),非常难懂,所以要是不用对它深究就能解决这个问题,那就太好了。 值得称道的是,核心文本提供了一个非常简单的方法,通过它可以创建一行带属性的文本。然后我们可以把这一行文本绘制到任何图形上下文中(参见图14)。我们新创建的PRPAttri- butedLabel应该是UIView的子类,因为这样就能以最简单的方式访问所需的图形上下文。 图14 带属性标签的TableView drawRect:方法中直接跟创建与渲染带属性字符串有关的代码只有3行。代码主要是获取对上下文的引用、保存与恢复上下文的状态,以及对上下文坐标进行平移以匹配反转的iOS坐标系。 第一个方法CTLineCreateWithAttributedString创建非常简单的核心文本的文本行,而不需要typesetter对象,因为排版已经在内部完成。然后我们使用CGContextSetTextPosition设置文本行在视图边框内的位置。当前文本位置处于边框的中央,所以我们需要计算相对于它的偏移量。在这个简单的例子中,我们从左侧的边开始,从底部向上移动边框高度的四分之一。这些定位没有考虑字符串的属性(比如字体大小),所以我们需要根据带属性文本的字体来调整标签边框的大小。与UILabel一样,需要反复试验才能把文本行放到合适的位置。 最后,我们调用CTLineDraw方法把文本行绘制到图形上下文中的指定位置。 coreText/Classes/PRPAttributedLabel.m - (void)drawRect:(CGRect)rect { if (self.attributedText == nil) return; CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSaveGState(context); CGContextTranslateCTM(context, self.bounds.size.width/2, self.bounds.size.height); CGContextScaleCTM(context, 1.0, -1.0); CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef) self.attributedText); CGContextSetTextPosition(context, ceill(-self.bounds.size.width/2), ceill(self.bounds.size.height/4)); CTLineDraw(line, context); CGContextRestoreGState(context); CFRelease(line); } 通常我们乐于使用@synthesize指令构造属性的设置器,但在这里我们需要保证带属性字符串的任何改变都会触发重画,使得标签会对每次变更进行更新。为了实现这一点,我们需要为attributedString属性创建定制的设置器,在其中调用setNeedsDisplay以强制重画。 coreText/Classes/PRPAttributedLabel.m - (void)setAttributedText:(NSAttributedString *)newAttributedText { if (attributedText != newAttributedText) { [attributedText release]; attributedText = [newAttributedText copy]; [self setNeedsDisplay]; } } 在示例代码中,我们使用定制的UITableViewController来显示可用字体的列表。这基本上是样板代码,但是在tableView:cellForRowAtIndex:委托方法中,我们用PRPAttributed- Label替代了标准的标签,把它的大小设置为与整个tableView的宽度和行高一致。 coreText/Classes/FontsTableViewController.m - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { static NSString *CellIdentifier = @"Cell"; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; if (cell == nil) { cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease]; CGRect frame = CGRectMake(10, 0, self.tableView.frame.size.width, self.tableView.rowHeight); PRPAttributedLabel *attLabel = [[PRPAttributedLabel alloc] initWithFrame:frame]; attLabel.backgroundColor = [UIColor whiteColor]; attLabel.tag = 999; [cell.contentView addSubview:attLabel]; [attLabel release]; } PRPAttributedLabel *attLabel = (id)[cell.contentView viewWithTag:999]; attLabel.attributedText = [self.attributedFontNames objectAtIndex:indexPath.row]; return cell; } 创建带属性字符串(常常是根据得到的XML数据来创建)有很多种方式,但是在这个例子中,我们需要一种简单的方式,从普通字符串构建在新标签中显示的内容。 illuminatedString方法接受输入的字符串和字体参数,以创建带属性字符串。将其第一个字母大小设置为稍大,颜色为鲜红。字符串的其余部分设置为深灰色。我们一个属性一个属性地构建字符串,先设置颜色和范围,然后添加不同大小的字体。 coreText/Classes/FontsTableViewController.m - (NSAttributedString *)illuminatedString:(NSString *)text font:(UIFont *)AtFont{ int len = [text length]; NSMutableAttributedString *mutaString = [[[NSMutableAttributedString alloc] initWithString:text] autorelease]; [mutaString addAttribute:(NSString *)(kCTForegroundColorAttributeName) value:(id)[UIColor darkGrayColor].CGColor range:NSMakeRange(1, len-1)]; [mutaString addAttribute:(NSString *)(kCTForegroundColorAttributeName) value:(id)[UIColor redColor].CGColor range:NSMakeRange(0, 1)]; CTFontRef ctFont = CTFontCreateWithName((CFStringRef)AtFont.fontName, AtFont.pointSize, NULL); [mutaString addAttribute:(NSString *)(kCTFontAttributeName) value:(id)ctFont range:NSMakeRange(0, 1)]; CTFontRef ctFont2 = CTFontCreateWithName((CFStringRef)AtFont.fontName, AtFont.pointSize*0.8, NULL); [mutaString addAttribute:(NSString *)(kCTFontAttributeName) value:(id)ctFont2 range:NSMakeRange(1, len-1)]; CFRelease(ctFont); CFRelease(ctFont2); return [[mutaString copy] autorelease]; } underlinedString方法非常类似,只是使用kCTUnderlineStyleSingle属性标示符为前6个字符添加了下划线属性(虽然有些做作,但是可以很好地展示其效果)。 coreText/Classes/FontsTableViewController.m [mutaString addAttribute:(NSString *)(kCTUnderlineStyleAttributeName) value:[NSNumber numberWithInt:kCTUnderlineStyleSingle] range:NSMakeRange(0, 6)]; PRPAttributedLabel类目前不如UILabel功能齐全。如果想要增强其功能,比如加入更好的位置选项,则需要进一步研究核心文本,抽出文本行与字形数据,进而计算出以点为单位的文本行的长度和最大高度,对文本行的位置进行调整,以支持中心对齐或左/右对齐等方式。 攻略11 滚动无止境的专辑封面墙 问题 滚动视图必然会受到被滚动的视图大小的限制。无论往哪个方向滚动,很快就会碰撞视图的边界,而且很可能会弹回来。目前,没有简单的办法能让UIScrollView的内容翻卷而仍然保持连续滚动的感觉。 解决方案 如果不去碰撞边界,而是把视图翻卷回来,形成自由流动的体验,效果可能会更好。我们可以设置滚动视图,让它在到达一侧的边界时跳回到另一侧,但是这会产生视觉上的跳动,并会立刻停止所有进行中的滚动。我们怎样才能创建一个无限翻卷的滚动视图呢? 有一个办法是使用一个包含非常大的视图的滚动视图。如果被滚动的视图足够大,看起来就好像没有边界一样。然而,用足够的数据填充巨大的视图来产生翻卷的效果,会有内存占用的问题。我们为移动设备编写代码的时候,节省内存是永恒不变的要求。即便是拥有大量物理内存的较新设备,多任务处理也要求我们考虑app的内存占用(包括其非活动状态的内存占用)。 我们所需的是这样一个解决方案,它使用最小限度的内存来实例化一个非常大的视图,这听起来很难,但是我们可以借助映射(mapping)API之中的CATiledLayer类来实现。地图应用程序所具有的功能就是我们想要的:似乎无限的滚动,以及按需以图像填充的视图(参见图15)。 图15 专辑封面墙的例子 CATiledLayer类将其内容分解为固定大小的图块(tile)。当一个图块滚动到屏幕上时,它会用待绘制图像的大小作为rect参数,调用关联视图的drawRect方法。这意味着只有当前可见或即将可见的图块区域才需要绘制,因而就节省了处理时间与内存。 现在我们离想要的连续翻卷效果更近了一步。因为每个图块在drawRect方法中进行绘制,所以我们可以控制它所包含的图像。做一点儿数学计算,就可以保证在到达可用图像列表的边界时,再重新从第一个开始。 本攻略中我们使用一个常常被忽略的丰富图形数据源:iPod库。唯一缺点是Xcode模拟器无法访问这个库,所以我们需要一点儿额外的代码来避免访问错误,显示替代图像。 MainViewController类包含滚动视图与PRPTiledView类的实例的初始化代码。滚动视图是分块专辑视图(tiled album view)的窗口,所以它的边框不要大于设备窗口,而它的contentSize则必须设置为专辑视图的大小,在本例中,这是一个非常大的矩形。 我们要避免使用UIScrollViewDecelerationRateNormal,这是滚动视图默认的减速率(decelerationRate)。当进行平滑快速的滚动的时候,这会导致专辑封面的显示上有明显的延迟,因为图像需要不停地刷新。而使用UIScrollViewDecelerationRateFast,我们就能控制滚动速度,从而得到更棒的用户体验。 巨大的假想视图虽然很棒,但是视图如果从默认的左上角开始的话就毫无意义,因为马上就能碰到边界。因此,我们需要把contentOffset属性(即当前距左上角的距离)设置为视图的中心。这样设置之后,就算滚上几个小时也到不了真正的边界。与contentSize一样,我们需要把tiles视图的边框大小设置为同一个很大的矩形。 InfiniteImages/MainViewController.m - (void)viewDidLoad { [super viewDidLoad]; width = self.view.bounds.size.width; height = self.view.bounds.size.height; CGRect frameRect = CGRectMake(0, 0, width, height); UIScrollView *infScroller = [[UIScrollView alloc] initWithFrame:frameRect]; infScroller.contentSize = CGSizeMake(BIG, BIG); infScroller.delegate = self; infScroller.contentOffset = CGPointMake(BIG/2, BIG/2); infScroller.backgroundColor = [UIColor blackColor]; infScroller.showsHorizontalScrollIndicator = NO; infScroller.showsVerticalScrollIndicator = NO; infScroller.decelerationRate = UIScrollViewDecelerationRateFast; [self.view addSubview:infScroller]; [infScroller release]; CGRect infFrame = CGRectMake(0, 0, BIG, BIG); PRPTileView *tiles = [[PRPTileView alloc] initWithFrame:infFrame]; [infScroller addSubview:tiles]; [tiles release]; } PRPTiledView类定义为标准的UIView的子类,但我们需要把它的内部图层类设置为CATiledLayer类型,使其成为分块视图(tiled view)。这里我们实际使用的是CATiledLayer的子类,原因将在后面介绍。 InfiniteImages/PRPTileView.m + (Class)layerClass { return [PRPTiledLayer class]; } initWithFrame:方法需要处理3个任务:设置图块大小、计算列数、访问iTunes数据库生成一组现有专辑。我们必须考虑到目标设备可能使用的是Retina显示屏,其分辨率会高很多。所以需要使用contentScaleFactor属性来调整图块的大小,在本例中是将其大小加倍。MPMediaQuery调用有可能会返回空数组,但之后我们会在创建图块的时候进行检查,必要时我们将绘制占位图像来填补空缺。 InfiniteImages/PRPTileView.m - (id)initWithFrame:(CGRect)frame { if ((self = [super initWithFrame:frame])) { PRPTiledLayer *tiledLayer = (PRPTiledLayer *)[self layer]; CGFloat sf = self.contentScaleFactor; tiledLayer.tileSize = CGSizeMake(SIZE*sf, SIZE*sf); MPMediaQuery *everything = [MPMediaQuery albumsQuery]; self.albumCollections = [everything collections]; } return self; } drawRect:方法需要准确计算出所要求的图块的行与列,这样我们才能把位置编号传给tileAtPosition方法。之后,从这个调用取得的图像将直接绘制到分块图层的指定rect之上。 InfiniteImages/PRPTileView.m - (void)drawRect:(CGRect)rect { int col = rect.origin.x / SIZE; int row = rect.origin.y / SIZE; int columns = self.bounds.size.width/SIZE; UIImage *tile = [self tileAtPosition:row*columns+col]; [tile drawInRect:rect]; } tileAtPosition方法用专辑数对位置编号取模,算出我们所需的albumsCollection的索引值。MPMediaItem类通过representativeItem方法返回一个媒体项,其属性将代表其他项目。这样,在每首乐曲有不同的图像时,我们仍能从每个专辑取出一幅图像。 MPMediaItemArtwork类有一个便利方法imageWithSize:,它按所需尺寸返回专辑封面的实例,所以我们不必再为将其放入rect而进行任何额外的缩放。数据库中不是所有专辑都有封面,没有封面的时候,我们加载一幅占位图像来填充rect。 InfiniteImages/PRPTileView.m - (UIImage *)tileAtPosition:(int)position { int albums = [self.albumCollections count]; if (albums == 0) { return [UIImage imageNamed:@"missing.png"]; } int index = position%albums; MPMediaItemCollection *mCollection = [self.albumCollections objectAtIndex:index]; MPMediaItem *mItem = [mCollection representativeItem]; MPMediaItemArtwork *artwork = [mItem valueForProperty: MPMediaItemPropertyArtwork]; UIImage *image = [artwork imageWithSize: CGSizeMake(SIZE, SIZE)]; if (!image) image = [UIImage imageNamed:@"missing.png"]; return image; } 之前我们没有使用CATiledLayer类来重载视图的layerClass,是由于CATiledLayer API的一个略显古怪的特性。图块通常在后台的线程中加载,然后以设定的持续时间(默认为0.25秒)淡入到位。奇怪的是,fadeDuration不是属性,而是定义为类方法,所以无法从分块图层进行修改。为了解决这个问题,我们需要创建一个CATiledLayer的子类,PRPTiledLayer,来重载fadeDuration方法,以返回我们想要的值(在这里是0)。这使得新的图块立即得以显示,而总体上对滚动性能没有什么影响。 InfiniteImages/PRPTiledLayer.m + (CFTimeInterval)fadeDuration { return 0.00; } 最后的效果相当不错,专辑封面在各个方向翻卷,而对滚动的响应性没有影响。快速的滚动会造成图像的一点滞后(使用分块图层的副作用),但总的来说,即使在Retina显示屏上性能也相当令人满意。
iOS应用开发攻略——第1章 UI攻略
书名: iOS应用开发攻略
作者: [美] Matt Drance Paul Warren
出版社: 人民邮电出版社
原作名: iOS Recipes:Tips and Tricks for
译者: 刘威
出版年: 2012-9
页数: 149
定价: 35.00元
装帧: 平装
ISBN: 9787115291783