65.9K
CodeProject 正在变化。 阅读更多。
Home

移动 Safari 风格的视图切换

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (30投票s)

2010年6月14日

CPOL

15分钟阅读

viewsIcon

87431

downloadIcon

691

一个可重用的库(特别是UIViewController子类),用于在您自己的应用程序中实现移动Safari页面/标签切换界面。现在支持方向更改!

引言

移动Safari提供了另一种标签外观——当您点击最右侧按钮时,页面会整齐地排列起来。之后,您只需向左滑动,向右滑动即可选择您想要的页面,再点击一次,页面就会缩放到填满屏幕。界面很漂亮。可惜苹果没有提供易于使用的UIViewController子类来实现相同的界面。

隆重推出… LSPageViewController!

在本文中,我将向您展示如何使用LSPageViewController在您自己的应用程序中模仿移动Safari的页面切换界面,以及如何按需自定义该界面。

viewswitcher.png

必备组件

  • 需要一些Objective-C知识。不用太多,但足以写一个HelloWorld应用程序,其中包含一个按钮,该按钮在被点击时会显示一只微笑的猴子。
  • Xcode和iPhone SDK 4.0或更高版本。已在4.0上测试。

我一路上的收获

在制作此项目的过程中,我几乎将所有精力都集中在Core Animation上,我对隐式和显式动画的知识得到了极大的提高。此外,我还简要了解了为图层绘制自定义内容(渐变背景、文本和圆点),以及需要对每个带有自定义绘图方法的图层调用- setNeedsDisplay,这与UIViews不同。最后,我了解了UIViewController在iPhone应用程序中的重要性。

4.0中的新功能: Blocks。我相信在C#中它们被称为“Lambda表达式”。Blocks极大地简化了LSPagesPresentationView的实现,因为有了+ [CATransaction setCompletionBlock:]来替代传统的callback和delegate方法(您会发现LSPagesPresentationView不再实现- (void)animationDidStop:finished:)。当然,Blocks的用途比这更广泛。我建议您阅读Apple的文档以获取更多信息。

此外,UIGestureRecognizer的具体子类允许您在不费吹灰之力的情况下为您的视图实现不同的手势识别算法。

好了,长篇大论就到此为止。是时候探索了!

冰山一角

获取源代码副本,然后在Xcode中打开项目。您可以看到“Main guts”组中有许多类。将这些类复制到您的项目中,您就可以开始使用了。您将直接与LSPageViewController交互——LSPageViewController将管理其他类。

第一步是创建一个LSPageViewController实例。您可以选择将LSPageViewController实例添加到nib文件中,创建一个带有LSPageViewController实例作为所有者的单独nib文件,甚至可以以编程方式完成工作。

之后,您需要显示LSPageViewController的视图。这里有几种选择:

  • 您可以将LSPageViewController设为主视图控制器,将其添加到MainWindow.xib(或您放置主窗口和应用程序委托的任何NIB文件)中。然后,在您的应用程序委托中,您可以配置LSPageViewController(设置frame、位置等),并将其视图添加为窗口的子视图。此方法类似于Xcode中的Navigation-based Application模板,其中一个UINavigationController被添加到MainWindow.xib并连接到应用程序委托。
  • LSPageViewController的视图可以是另一个视图控制器的子视图(而不是主窗口的子视图)。此解决方案在示例项目中得到了演示。请注意,由于LSPageViewController不是主视图控制器,您的主窗口将不会向其转发一些重要消息(例如,- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration)。您的主视图控制器负责转发这些消息。(有关参考,请阅读示例项目中的MainViewController类的实现)。
  • 以编程方式创建LSPageViewController的实例,然后将其视图添加为窗口或其他视图的子视图。

有两种方法可以操作LSPageViewController管理的视图控制器:

  • 调用- [LSPageViewController addViewController:]添加新的视图控制器,调用- [LSPageViewController insertViewController:atIndex:]将视图控制器插入到指定位置,以及调用- [LSPageViewController removeViewControllerAtIndex:]删除视图控制器。您还可以调用- [LSPageViewController numberOfViewControllers]来检查LSPageViewController管理的视图控制器的数量。
  • 通过访问只读属性viewControllers来获取LSPageViewController用于存储视图控制器的NSMutableArray的引用。然后,您可以根据需要添加、插入、删除……

这两种方法之间的区别是,通过调用LSPageViewController的方法,如果显示了呈现视图,LSPageViewController将有机会触发必要的动画。直接操作NSMutableArray viewControllers时,无论是否存在呈现视图,都不会显示任何动画。

当您想向用户展示所有视图以供选择时(我称之为“呈现视图”),调用- [LSPageViewController displayPagesPresentationView]

当您想关闭上述呈现视图时,只需调用- [LSPageViewController shouldDismissPagesPresenationView]

您可以通过调用- [LSPageViewController numberOfViewControllers]来查询LSPageViewController实例正在管理的视图控制器的数量。要检查呈现视图是否显示,请查看LSPageViewController的isShowingAllView属性。

几点说明

您可以将一个对象分配给LSPageViewController的代理,方法是访问其delegate属性。当呈现视图成功显示(调用- [LSPageViewController displayPagesPresentationView]后)和呈现视图关闭(调用- [LSPageViewController shouldDismissPresentationView])后,此对象将收到通知。

当呈现视图成功显示时,代理将收到- (void)didShowPresentationView:(LSPagesPresentationView *)obj,当呈现视图关闭时,代理将收到- (void)presentationViewDismissed

提供给LSPageViewController的任何UIViewController都应实现- (NSString *)presentationName方法——返回的字符串将显示在上述截图中的视图名称。或者,所提供UIViewController的UIView可以实现该方法。但是,UIViewController的方法将优先于UIView的方法(这意味着,如果两者都实现了- ( NSString *)presentationName,则会调用UIViewController的方法)。如果UIViewController或其UIView都没有实现该方法,则名称将设置为“@Dev: name?”。

您应该确保您提供给LSPageViewController的UIViewController管理的UIView能够适应不同的大小,因为它们将被调整为与LSPageViewController管理的视图相同的大小。或者,您可以确保这些视图和LSPageViewController管理的视图大小相同。

另外,您无需直接与LSPagesPresentationView交互。永远不要。LSPageViewController将处理工作。但是,您可以自定义该类以按您喜欢的方式更改呈现视图的行为和外观,如以下部分所述。

4.0中的新功能: 您可以通过将useShadow属性设置为YES来启用阴影,使界面看起来更具吸引力。请注意,默认情况下,阴影是禁用的,因为可能会影响性能——尽管我无法测试这一点,因为我无法在我的iPod上进行调试。如果您拥有iPhone开发者计划的访问权限,如果您能在您的设备上启用阴影并测试此项目,看看性能是否受到影响,我将不胜感激。

幕后

LSPageViewController只是一个普通的UIViewController子类。它的任务是处理视图控制器的添加和删除,并在需要时显示和关闭呈现视图。在显示呈现视图时,它还负责将代表用户要查看的视图的CALayer馈送到呈现视图中。

- (CALayer *)layerForViewController:(UIViewController *)vc
{
	UIImage *viewImage = [self imageRepresentationForViewController:vc];
	
	// Construct layer
	CALayer *viewImageLayer = [CALayer layer];
	viewImageLayer.frame = vc.view.bounds;
	viewImageLayer.contents = (id)[viewImage CGImage];
	if (self.useShadows)
	{
		viewImageLayer.shadowOpacity = 0.5f;
		viewImageLayer.shadowRadius = 4.0;
		viewImageLayer.shadowOffset = CGSizeMake(1.0, 3.0);
	}
	
	// Add close button layer
	CloseButtonLayer *closeButtonLayer = [CloseButtonLayer layer];
	closeButtonLayer.name = @"closeButton";
	closeButtonLayer.hidden = NO;
	closeButtonLayer.opacity = 0.0f;
	closeButtonLayer.opaque = YES;
	closeButtonLayer.bounds = CGRectMake(0.0f, 0.0f, 25.0f, 25.0f);
	closeButtonLayer.position = CGPointMake(0.0f, 0.0f);
	[viewImageLayer addSublayer:closeButtonLayer];
	
	// Obtain name to display to the user:
	// We'll search both the view controller and its view to see if they can provide the name.
	// If no name is found, we'll simply display @"@Dev: Name?" (Well I'm addicted to Twitter. Sue me.)
	if (([vc respondsToSelector:@selector(presentationName)]) && ([vc performSelector:@selector(presentationName)] != nil) && ([vc performSelector:@selector(presentationName)] != @""))
		viewImageLayer.name = [vc performSelector:@selector(presentationName)];
	else if (([vc.view respondsToSelector:@selector(presentationName)]) && ([vc.view performSelector:@selector(presentationName)] != nil) && ([vc.view performSelector:@selector(presentationName)] != @""))
		viewImageLayer.name = [vc.view performSelector:@selector(presentationName)];
	else
		viewImageLayer.name = @"@Dev: name?";
	
	return viewImageLayer;
}

(对于需要的人,这是- (UIImage *)imageRepresentationForViewController:(UIViewController *)vc的实现:)

- (UIImage *)imageRepresentationForViewController:(UIViewController *)vc
{
	// Take a screenshot of the view controller's view
	// by telling the main layer of that view
	// to render in a custom context
	UIGraphicsBeginImageContext(vc.view.frame.size);
	[vc.view.layer renderInContext:UIGraphicsGetCurrentContext()];
	UIImage *viewImage = UIGraphicsGetImageFromCurrentImageContext();
	UIGraphicsEndImageContext();
	
	return viewImage;
}

从片段中,您可以看到该层是通过指示视图绘制到自定义上下文中来创建的,从该上下文中获取UIImage表示——简而言之,以编程方式获取视图的屏幕截图,然后将CALayer的content属性设置为该图像。其他2个部分只是添加一个关闭按钮并将名称设置为显示给用户。

另一方面,LSPagesPresentationView要复杂得多。初始化时,它接收一个由代表视图的CALayer填充的数组。LSPageViewController在设置了正确的值后,向呈现视图发送- [LSPagesPresentationView setup]

// A multitude of setups
- (void)setup
{
	[[UIApplication sharedApplication] beginIgnoringInteractionEvents];
 
	[self.layer setNeedsDisplay];
 
	isLayingOutLayers = YES;
 
	[self setupNameLayer];
	[self setupDotLayer];
	[self setupLayers];
	[self prepareForEntranceAnimation];
}

下面是一个详细说明呈现视图布局的简单图示

LSPagePresentationView_layout.png

前两个设置方法不言自明。第三个方法创建并定位一个容器层,然后将所有代表视图的层设置为该容器层的子层,同时设置它们的位置和边界(当然,是相对于容器层)。第四个方法基本上将当前选定视图的代表层调整到屏幕(实际上是根视图)的大小,以准备进入(缩小)动画。

之后,LSPageViewController只需将呈现视图设置为其根视图的唯一子视图。然后,它调用- [LSPagesPresentationView startEntranceAnimation],当前选定视图的层会缩小到位,显示其他视图、文本层和点层。

动画可以通过触摸事件触发:由手势识别器识别的点击和滑动,以及手势识别器未能识别的其他触摸事件。手势识别器在初始化时添加到LSPagesPresentationView的实例中。

	// From - (id)initWithFrame:layers:
	
	// Set up gesture recognizers
	// Tap recognizer
	UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleSingleTap:)];
	tapRecognizer.cancelsTouchesInView = YES;
	[self addGestureRecognizer:tapRecognizer];
	[tapRecognizer release];

视图中只有一个手势识别器,即点击识别器。以下是当LSPagesPresentationView实例生成触摸事件时将发生的操作的简化列表:

  • UIApplication将触摸事件发送到前台窗口。
  • 前台窗口将该事件转发给我们的视图。但在接收事件之前,点击识别器会先处理它。
  • 如果点击识别器未识别(或未能识别)手势,我们的视图随后会收到该事件。
  • 如果点击识别器成功识别了一个手势,则会调用- (void)touchesCancelled:forEvent

通过阅读代码,您会发现- (void)touchesEnded:forEvent(仅在点击识别器未能正式识别特定点击手势时调用)会根据手指的移动来将图层动画到下一个或上一个位置。另一方面,- (void)touchesCancelled:forEvent在点击识别器识别到点击时,或者当另一个操作取消事件时(例如,来电)会被调用——它以不同的方式处理这两种情况(当取消来自点击识别器时,不执行任何操作,而当取消源自其他事物时,则会动画到原始位置)。

LSPagesPresentationView中使用的所有动画都是隐式动画。尽管它们是隐式的,但它们可以非常强大。下面是一个当用户点击左侧区域时会触发的动画示例:

	// From - (void)handleSingleTap:
	
	// Animate to previous layer if it exists
	if (self.previousLayer != nil)
	{
		self.selectedIndex -= 1;
	}
	
	[CATransaction begin];
	[CATransaction setAnimationDuration:0.5];
	[CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]];
	
	// Move all layers
	for (int i = 0; i < [pagesLayers count]; i++)
	{
		// Change position
		CALayer *layer = [pagesLayers objectAtIndex:i];
		layer.position = [self positionForLayerAtIndex:i];
		
		// Change opacity
		layer.opacity = (i == self.selectedIndex) ? 1.0 : 0.4;
		
		// Show close button if necessary
		[layer sublayerWithName:@"closeButton"].opacity = ((i == self.selectedIndex) && (self.shouldShowCloseButton)) ? 1.0 : 0.0;
	}
	
	// Update name and dot layer
	self.nameLayer.string = self.currentLayer.name;
	self.dotLayer.activeDot = self.selectedIndex;
	
	[CATransaction commit];

只需为图层的各种属性分配新值,并将它们分组在+ [CATransaction begin]+ [CATransaction commit]之间,图层就会平滑地动画到它们适当的位置。题外话:由于- (CGPoint)positionForLayerAtIndex:依赖于selectedIndex,通过更新selectedIndex,我们将能够为LSPagesPresentationView管理的每个图层生成适当的位置。

回调怎么办?如果您想将多个动画链接在一起,或在动画完成后执行某些任务怎么办?在4.0之前,您需要显式创建CAAnimations,将其中一个的delegate属性设置为self,并实现- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)fin。更糟糕的是,当您设置越来越多的带有回调的动画时,您的- animationDidStop:finished:方法会越来越大。最终,该委托方法将变成一个巨大的霸王龙,难以被人阅读。

幸运的是,在4.0中,随着Blocks的引入,CATransaction支持了一个新的类方法:+ [CATransaction setCompletionBlock:]。这取代了笨拙的callback的需要,并极大地简化了您的代码。在将新的视图表示层添加到LSPagesPresentationView实例时,请查看此代码片段:

	- (void)addNewLayer:(CALayer *)layer
	{	
		// Add the new layer first
		layer.bounds = [self boundsForViewLayer];
		layer.opacity = 0.0f;
		[pagesLayers addObject:layer];
		[self.contentContainerLayer addSublayer:layer];

		// Now move to the new layer
		int index = [pagesLayers count]-1;

		[[UIApplication sharedApplication] beginIgnoringInteractionEvents];

		[CATransaction begin];

		// Set duration
		int times = abs(self.selectedIndex - index);
		float duration = 0.2 * times;
		[CATransaction setAnimationDuration:duration];

		// Timing function
		CAMediaTimingFunction *easeInOut = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
		[CATransaction setValue:easeInOut forKey:kCATransactionAnimationTimingFunction];

		// Callback
		[CATransaction setCompletionBlock:^{
			[CATransaction begin];
			[CATransaction setAnimationDuration:0.3];

			[CATransaction setCompletionBlock:^{
				[[UIApplication sharedApplication] endIgnoringInteractionEvents];
				[self zoomCurrentLayerForEnding];
			}];

			// Show currentLayer
			self.currentLayer.position = [self positionForCurrentLayer];
			self.currentLayer.opacity = 1.0;

			// Show closeButton
			if (self.shouldShowCloseButton)
			{
				[self.currentLayer sublayerWithName:@"closeButton"].opacity = 1.0f;
			}

			// Update dotLayer and nameLayer
			self.nameLayer.string = self.currentLayer.name;
			self.dotLayer.activeDot = self.selectedIndex;
			self.dotLayer.numberOfDots = [pagesLayers count];

			[CATransaction commit];
		}];

		// Change selectedIndex
		self.selectedIndex = index;

		// Actually move the layers
		for (int i = 0; i < [pagesLayers count]; i++)
		{
			CALayer *layer = [pagesLayers objectAtIndex:i];

			// Animate position
			layer.position = [self positionForLayerAtIndex:i];

			// Animate opacity + closeButton's opacity
			if (i != self.selectedIndex)
			{
				layer.opacity = 0.4;
				[layer sublayerWithName:@"closeButton"].opacity = 0.0;
			}
		}

		[CATransaction commit];
	}

粗体行显示了completion block(封装在^{ /* Code goes here... */ }中),它将在动画完成后立即执行。关于block的一个特殊之处在于,它会继承在声明之前声明的所有局部变量——尽管block只能访问变量的只读表示。如果您希望一个变量能被block完全访问,您需要在声明变量时添加__block指令(例如__block int index)。

现在LSPageViewController也能响应方向变化了!如果您使用另一个视图控制器来管理LSPageViewController,您需要将- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration转发给LSPageViewController的实例(再次,请参阅示例项目中MainViewController的实现)。另一方面,如果LSPageViewController的视图被添加为主窗口的子视图,则无需转发消息。但是,在这两种情况下,您都需要配置LSPageViewController视图的自动调整大小蒙版,以便在方向更改时能够正确调整大小——这个配置可以在Interface Builder中或以编程方式完成。

更具体地说,当方向发生变化时,LSPageViewController会收到一条消息(无论是从您自己的视图控制器转发的,还是从主窗口自动收到的;消息的名称是- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration——显然。)然后,LSPageViewController会继续调整它管理的视图控制器的视图大小,并且如果呈现视图正在显示,则调整它的大小并通知它方向更改,以便它可以调整其层的大小和位置(在LSPagesPresentationView的-(void)respondtoOrientationChange:中)。

总而言之,本节向您展示了LSPageViewController如何将层传递给LSPagesPresentationView的实例,层如何在传递给LSPagesPresentationView后进行设置和动画到位,手势识别器如何设置以及项目中使用哪些隐式动画。

现在您已经理解了LSPageViewController和LSPagesPresentationView的底层原理,您可以轻松地自定义这两个类以满足您的需求。

让它成为你的

您想要更多标签?没有标签?更改背景?没问题!您可以尽可能多地自定义LSPageViewController和LSPagesPresentationView,直到它们让您感觉满意为止。

LSPageViewController的改动需求不大,因为它只是您视图控制器和LSPagesPresentationView之间的中间人——存储提供的UIViewControllers并在调用- [LSPageViewController displayPagesPresentationView]时将它们传递给LSPagesPresentationView。但是,如上所述,LSPageViewController传递的不是原始视图控制器,而是经过处理的CALayer——如果您需要自定义这些层,请阅读并更改- (CALayer *)layerForViewController:(UIViewController *)vc以满足您的需求。

LSPagesPresentationView有更多的自定义选项——这是魔法发生的地方。

要自定义背景渐变(更改颜色或绘制完全不同的渐变),请修改Delegate类别中的- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx

有关层的大小和位置的自定义,请参阅LSPagesPresentationView.m中的Maths类别。请注意LSPagesPresentationViewPrivate.m中Maths方法的列表。请注意,许多Maths方法依赖于其他方法,因此修改一个方法可能会影响所有方法。一般而言,在自定义之前请研究所有Maths方法。

要自定义名称层,请参阅- (void)setupNameLayer。从这里,您可以添加更多或删除所有名称层。如果您更改此方法,也请检查这些方法:

  • - (void)prepareForEntranceAnimation
  • - (void)handleSingleTap
  • - (void)handleSwipeLeft
  • - (void)handleSwipeRight
  • - (void)touchesBegan:forEvent
  • - (void)touchesMoved:forEvent
  • - (void)touchesEnded:forEvent
  • - (void)touchesCancelled:forEvent
  • - (void)addNewLayer
  • - (void)insertLayer:atIndex
  • - (void)removeLayerAtIndex

以上方法包含动画,这些动画将移动视图表示层,并更新点和名称层以匹配currentLayer。您需要更改这些方法以适应名称层的添加或删除。

要自定义动画,请检查上面列出的方法。LSPagesPresentationView不再有专门的Animation类别,因为我发现该解决方案过于死板——每个动画都需要进行微调。如果您想绝对确定,只需在.m文件中搜索“[CATransaction begin]”。每个[CATransation begin/commit]组代表一组隐式动画。

在此新版本的LSPagesPresentationView中,也不再有- (void)processTouch:。现在由手势识别器来处理工作。仍然有一个NSMutableDictionary extraTouchInfo,但它只用于存储要在EventHandling方法之间使用的信息——与touchStorage在先前版本(3.1.3及更低版本)中的作用相比,它微不足道。如果您想修改手势识别算法,请创建UIGestureRecognizer的子类,并替换我在- (id)initWithFrame:layers:中使用的3个标准识别器。您可以查阅Apple的文档,了解UIGestureRecognizer的子类化注意事项。

到此为止,几乎涵盖了所有内容。如果您有任何问题或疑虑,请随时提出。

致谢

  • Scott Stevenson。他的ArtGallery项目非常有帮助。虽然我没有直接使用该项目中的任何代码,但它给了我实现这一点的想法。
  • Alan Duncun和他关于绘制圆形的示例。
  • 任何愿意在自己的设备上测试此项目的好心人,因为我无法仅凭iPhone模拟器确认该库的性能。
  • 以及许多我已忘记的重要人物。

待办

  • 目前(再次)感到满意。如果您希望改进或包含某些内容,请告诉我。
  • 仍然需要放在真机上测试 :(

历史

  • 2010年7月12日:现在支持方向更改!不过,这要求此库的用户遵守某些要求——请阅读以上部分。
  • 2010年6月30日:删除了两个滑动(swipe)手势识别器,并对动画行为进行了微调——如果您尝试将最左侧的层向右移动,或者将最右侧的层向左移动(没有更多可显示的层),会有一个“摩擦”效果。
  • 2010年6月27日:添加了- [LSPageViewController insertViewController:atIndex:],以防有人需要。此外,LSPageViewController用于存储托管视图控制器的NSMutableArray现在通过只读属性viewControllers公开(只读意味着您不能为该属性分配新数组——您仍然可以随意添加和删除对象)。
  • 2010年6月25日:支持iOS 4。
    • 使用了UIGestureRecognizer的子类而不是手动进行手势识别,因此删除了touchStorage- (void)processTouch:
    • 在链接动画时,不再使用回调。取而代之的是,通过将Blocks分配给+ [CATransaction setCompletionBlock:]来使用它们。分配的Blocks将在CATransaction组内的所有动画完成后执行。代码库极大地简化,更易于阅读。
    • 可以启用阴影,但不确定是否有性能影响。(如果您有人能在自己的设备上测试此功能,请在评论中发言)
  • 2010年6月20日:小更新——关闭按钮变大。
  • 2010年6月14日:首次发布!

唉,维护是两倍的困难和耗时,不是吗?

© . All rights reserved.