仿Taasky的3D翻转菜单动画实现

本文翻译自Audrey Tam发布在raywenderlich上的文章How To Create a Cool 3D Sidebar Animation Like in Taasky

第一次翻译,用词方面可能不是很准确,我尽量把句子翻译通顺。本文并未逐字翻译,部分不痛不痒的句子我就直接简化或忽略了,如果想要了解详细内容,还请查看原文。

最终效果

最终效果
最终效果

开始

首先下载并打开一个事先搭好架子的Demo,然后来分析一下。这个Demo包含一个主页和详情页,其中MenuViewController继承自UITableViewController,它主要用于展示左边侧栏,自定义的MenuItemCell中设置了每一个菜单的图标和颜色。DetailViewController为详情页,显示了每个cell点击后,对应的颜色和图标。

Starter Project效果
Starter Project效果

这个教程将详细的介绍实现步骤,具体步骤如下:

  • 整个教程将使用自动布局来实现,需要将现在的主页push到详情页的这种模式改为横向ScrollView滚动的模式。

  • 需要在左上角添加显示/隐藏菜单栏的按钮。
  • 接下来,需要实现类似于Taasky的菜单栏的3D翻转效果。
  • 最后,需要将菜单栏的翻转效果与按钮的旋转效果结合起来。

首要任务是把菜单栏改成滑出的效果,UIScrollView中需要包含菜单栏和详情页面,就像是 Swift Scroll View School Part 13这个视频教程中介绍的SwiftSideNav一样。示意图如下:

视图范围
视图范围

用户可以通过左右滑动来显示/隐藏菜单栏。在上图中,紫色方框是当菜单栏显示的时候,屏幕所显示的部分,绿色方框是当菜单栏隐藏时,屏幕所显示的内容。当菜单栏打开时,显示的是一个局部侧滑边栏。

如果对UIScrollView不熟悉,请先学习Part 1Part2一系列教程,它们讲介绍UIScrollView的工作流程。

刚才提到的SwiftSideNav是一个使用自动布局完成例子,但是今天的教程将会直接在StoryBoard中嵌入菜单与详情页,完成后的结构如图:

最终结构图
最终结构图

为UIScrollView添加约束

这个部分会使用到控制器约束等知识(在iOS5时推出),如果还不熟悉,可以先学习iOS 5 By Tutorials书中的第18章UIViewController Containment。

菜单栏与详情。整个视图层级应该是这样的:

  • 有一个根控制器,并将UIScrollView添加到根控制器上;
  • 然后添加一个UIView到UIScrollView上,暂且叫它”Content View”;
  • 添加两个子容器到”Content View”上,然后分别把菜单栏和详情页填到到子容器中。

创建一个名为ContainerViewController的控制机,继承自UIViewController,语言选择Swift

创建容器
创建容器

 

打开Main.StoryBoard,从Object Library中拖一个View Controller到画布中,并在Identity Inspector(Xcode右边栏的第三个选项)中设置自定义类为ContainerViewController,唯一标识为ContainerVC

绑定类,修改唯一标识
绑定类,修改唯一标识

接下来,在 Attributes Inspector(Xcode右边栏的第四个选项)中将ContainerViewController的view背景色设置为黑色。

设置背景色
设置背景色

创建UIScrollView

接着,需要添加UIScrollView到ContainerViewController中,并添加约束。具体操作如下:

  • 拖一个UIScrollView到ContainerViewController中,并让它填充整个ContentViewController
  • 取消横、纵滚动条的显示(Shows Horizontal IndicatorShows Vertical Indicator);
  • 取消Delays Content Touches,让响应事件及时传递给子控件,防止响应延迟;

ContainerViewController设置为UIScrollView的delegate(按住control键,从UIScrollView拖到UIViewController)。

关联代理
关联代理

接下来是给UIScrollView添加约束的步骤:

  • 找到右下角的Pin按钮,打开添加约束的弹窗;
  • 取消Constrain to margins,防止系统自动加左右边距;

  • 添加上下左右四个约束,并确保约束的值都是0;
  • 点击Add 4 Constraints按钮,将约束加上。

给UIScrollView添加约束
给UIScrollView添加约束

仍然选中UIScrollView,打开Size Inspector选项(Xcode右边栏倒数第二个选项),确认一下约束是否与教程所加的约束相同:

  • Trailing Space to: Superview
  • Leading Space to: Superview
  • Top Space to: Superview

  • Bottom Space to: Bottom Layout Guide

检查约束
检查约束

如果有类似16这样的数字出现,说明在添加约束的时候,没有取消Constrain to margins。解决方案:删除UIScrollView的约束,重新添加一次,注意记得取消Constrain to margins。

在添加或修改约束以后,可能需要根据新的约束来展示frame。这个需求可以通过右下角Resolve Auto Layout Issues弹出框(Pin按钮旁边)的Update Frames选项来解决。

创建Content View

接下来需要做的是添加一个Content View到UIScrollView上,并添加响应约束,这些约束对设置UIScrollView的contentSize很重要。在下一节中,要将添加菜单栏与详情两个容器视图添加到Content View上。

拖一个新的View到UIScrollView上,让它自动填满整个父视图,并将背景色设置为Default

设置宽与背景色
设置宽与背景色

译者注:上图中,作者将Width设置为了680,但在文字描述中并未提到。这个宽度可加可不加,因为之后界面上的宽度其实全都要通过约束来表现。

选中刚加上的这个View,打开Identity Inspector,将Document\Label设置为Content View。这个名字将会在document outline(IB左边视图层级栏)显示出来,可以很容易追踪到这个View,并且在之后加与它相关的约束的时候,这个名字也会显示出来。

在给Content View添加约束的过程中,可以看到警告,不过不用惊慌,这些警告在这一节完之前能得到解决。

 

打开Pin弹出框,给Content View添加相对于父视图的约束。

给Content View添加约束
给Content View添加约束

在Size Inspector中,确保Trailing Space的约束值为0。

将右边距约束改为0
将右边距约束改为0

译者注:这里有点不明白作者的操作,直接在添加约束的时候,将右边距设置为0就可以了,但是作者却先将宽设置为680,然后添加约束的时候也是添加的-80,最后又改成0。感觉有点多此一举,还请大神们解答下。

现在之所以有警告是因为StoryBoard需要Content View的高和宽来设置contentSize。添加相对于Scroll View的父容器的这些约束,可以使之适配各种设备与横竖屏。

在document outline中,按住Control键,从Content View拖到View(Scroll View的父容器)。按住Shift键,选择Equal WidthsEqual Heights

添加等宽等高约束
添加等宽等高约束

 

然后将Equal Width约束改为80。

加80像素的约束
加80像素的约束

将约束改为80的用意是:这样Content View就会比View宽80,就可以刚好装下菜单栏。同时,可以发现之前的警告也不存在了——干得漂亮。

添加菜单栏与详情界面

现在Scroll View上有Content View可以作为菜单栏和详情页的容器,然后将菜单栏与详情页(后文中称为Menu View与Detail View)嵌入Content View,这样便可以创建一个可以滑动显示.隐藏的Menu View。

首先,创建Menu Container View:拖一个Container ViewContent View上,在Size Inspector中,将其宽度设为80,然后在Identity Inspector中将Document\Label设置为Menu Container View

设置宽度,添加描述Label
设置宽度,添加描述Label

高度应该是默认的600,不过如果你不确定,也可以设置一下。

译者注:这里作者只提到设置宽度,但根据后文的一些描述,这里应该还需要将x设置为0,y设置为0。

然后,添加Detail Container View:新拖一个Container ViewContent View上,并放置在menu container的右边。打开Size Inspector,并设置以下值:

  • X: 80
  • Y: 0
  • Width: 600
  • Height: 600

接下来,打开Identity Inspector,将Document\Label设置为Detail Container View

设置frame,添加描述Label
设置frame,添加描述Label

这样,Menu ViewDetail View的宽度就和Content View相等了。

容器
容器

当添加完这两个自控制器后(container view),可在在IB中看到系统已经默认添加了contained view controller,但是我们需要用到已存在的Menu View与Detail View,所以需要在document outline或者画布中删除这两个控制器。

删除系统自动关联的ViewController
删除系统自动关联的ViewController

译者注:这个教程中的操作,都在文章开头下载的那个Demo中进行(称为starter project),所以,已存在的Menu View与Detail View就是指starter project中的控制器视图。

接下来,需要给两个container view添加约束。

Menu Container View添加宽为80,上下左右边距为0,共5个约束。

添加5个约束
添加5个约束

Detail Container View添加上、右、下边距为0,共3个约束;注意不要添加左边距约束,否则会和Menu Container View的右边距约束重复。

添加三个约束
添加三个约束

给两个Container View添加的约束都要确保Constrain to margins为取消。

嵌入Menu与Detail View Controllers

这一节中,将会把starter project中的menu和detail view controllers拆开,然后分别嵌入Menu Container ViewDetail Container View

首先,将Container View Controller设为入口控制器:将原本指向Navigation Controller的入口箭头指向Container View Controller

设置入口控制器
设置入口控制器

接下来,按住Control键,从Menu Container View拖到Navigation Controller,在弹出框中选择embed

关联菜单视图
关联菜单视图

Menu Container View嵌入Navigation Controller之后,相关的视图的宽度就都缩小到了80:

关联以后,控制器宽度变为80
关联以后,控制器宽度变为80

现在来调整一下menu与detail:首先,将table view cell中imageView的宽度调整为80

调整cell宽度
调整cell宽度

然后,删除menu与detail之间的push连线。选中Detail View Controller,然后在Xcode菜单中选择Editor\Embed In\Navigation Controller

给详情页添加导航栏
给详情页添加导航栏

现在detail应该加在了Navigation Controller上,同时拥有了黑色的导航栏。

选中这个新的Navigation Controller的导航栏,打开Attributes Inspector,选择StyleBlank,取消Translucent,并将Bar Tint设置为Black Color

设置导航栏样式与背景色
设置导航栏样式与背景色

译者注:如果不好选中导航栏,可以在document outline中进行选择,注意不要选错了。

这个设置完以后,新加的导航栏就和第一个导航栏的样式一致了。

接着,打开Detail View Controller控制器的Attributes Inspector,确保View Controller\Layout\Adjust Scroll View Insets是处于选中状态。

选中Adjust Scroll View Insets
选中Adjust Scroll View Insets

Adjust Scroll View Insets的选中是为了将视图从Navigation Bar下面开始显示,而不会被Navigation Bar盖住

最后,将Detail View Controller嵌入Detail Container View:按住Control键,从Detail Container View拖到Detail View ControllerNavigation Controller,在弹出框中选择embed

嵌入详情页
嵌入详情页

运行程序,左右拖动视图,就可以显示/隐藏菜单栏了。你是否注意到,在拖动Scroll View是,可以超出左右边界,而且还可以停止滚动,显示部分菜单这个问题?

当前效果图
当前效果图

为了修复这个问题,需要在Scroll View的Attributes Inspector中修改一下属性:

  • 选中Scrolling\Paging Enabled属性,这样就可以…(译者注:原谅我找不到好的词语来表达原词了,原文中用到的是“snaps”,大概就是有一种惯性、巧劲的感觉,具体Paging Enabled的效果相信大家都懂的,就不说了);
  • 取消Bounce\Bounces属性,防止左右滚出边距(译者注:即没有回弹效果)。

译者注:原文的动图没有截导航栏部分,我在跟着做的时候,发现菜单的导航栏上有白边,原因是Menu Container View与Detail Container View的背景色为默认色的原因,设置为黑色即可。

这时,在运行程序,就不会出现左右滑出边界的情况了,并且,也不会出现菜单栏可以显示一部分的情况。但是,在向左滑动,试图隐藏菜单栏时,又出现了新问题。

Paging Enable问题
Paging Enable问题

本来滚动隐藏的菜单栏又弹了出来。这个问题在StackOverflow上讨论过,我们将在关联Scroll View的时候再去解决它。

现在我们需要先解决另外一个问题:详情页是空白的,并且在点击菜单栏的时候,没有任何事件触发。

详情页没有切换
详情页没有切换

这完全在意料之中,因为还没有代码对container views进行关联。

对容器进行编码

在开始之前,先将MenuViewController.swift中的viewDidLoad()复制到DetailViewController.swift中,如下:

override func viewDidLoad() {
  super.viewDidLoad()
  // Remove the drop shadow from the navigation bar
  navigationController!.navigationBar.clipsToBounds = true
}

这句代码的目的是不显示导航栏下面的那条阴影,虽然是个小细节,但就是这些细节使我们的App更加优雅。

当用户选中某个单元格时, MenuViewController必须要设置DetailViewController中的menuItem属性,但是这两个类已经没有直接联系了。所以,这两个类之间的通信将有ContainerViewController来代替。

ContainerViewController.swift的顶部添加DetailViewController属性:

private var detailViewController: DetailViewController?

ContainerViewController.swift中实现prepareForSegue(_:sender:)这个方法,并添加以下代码:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  // 译者注:这里的"DetailViewSegue"设置接下来的描述中讲解(就是这段代码下面的这段话)
  if segue.identifier == "DetailViewSegue" {
    let navigationController = segue.destinationViewController as! UINavigationController
    detailViewController = navigationController.topViewController as? DetailViewController
  }
}

segue.identifier是啥?在将DetailViewController嵌入到容器时,需要在Attributes inspector中将Storyboard Embed SegueIdentifier设置为DetailViewSegue

设置Segue唯一标识
设置Segue唯一标识

然后,声明menuItem属性,并在didSet中,在它进行赋值时,也给DetailViewController中的menuItem属性进行赋值。

var menuItem: NSDictionary? {
  didSet {
    if let detailViewController = detailViewController {
      detailViewController.menuItem = menuItem
    }
  }
}

这已不再是table view cell与content view之间的交互了,而是用户选择菜单时,MenuViewController需要做出响应。

译者注:上面这句话纠结好久,翻译出来还是不通顺,原话是这样的:There’s no longer a segue from a table view cell to the content view, but MenuViewController needs to respond when the user selects an item.

删除MenuViewControllerprepareForSegue(:sender:)部分的代码,并且添加下面这段代码,注意,在选择方法的时候,不要选成tableView(:didDeselectRowAtIndexPath:)而造成麻烦。

// MARK: UITableViewDelegate
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
  // 译者注:取消单元格选中
  tableView.deselectRowAtIndexPath(indexPath, animated: true)
  // 译者注:从menuItems数组中取出menuItem字典,里面的值是根据MenuItems.plist生成的,包含小图,大图,背景色
  let menuItem = menuItems[indexPath.row] as! NSDictionary
  // 译者注:当前的navigationController!.parentViewController就是ContainerViewController,因为navigationController的视图是作为80像素的Container View添加在ContainerViewController中的
  (navigationController!.parentViewController as! ContainerViewController).menuItem = menuItem
}

这里,只是简单将基于选择的单元行设置了ContainerViewControllermenuItem属性。这将使属性的didSet方法触发(就是上面将menuItem赋值给detailViewController.menuItem的那段代码)。

最后,在MenuViewController.swiftviewDidLoad()方法中,添加以下代码:

(navigationController!.parentViewController as! ContainerViewController).menuItem = 
  (menuItems[0] as! NSDictionary)

这句代码将在App第一次加载的时候给detail view添加对应的图片。

运行程序,可以看到程序加载以后,详情页已经有了图片,菜单栏正常显示,并且在切换菜单的时候,详情页也能显示对应的图片:

运行效果
运行效果

显示/隐藏菜单

为了达到菜单栏点击以后应该自动隐藏的目的,应该在单元行点击以后,设置Scroll View的水平位移为菜单栏的宽度,这样就可以完全展示出详情页。

首先,需要将Scroll ViewContainer View关联。

建立scrollView与ContainerViewController.swift的关联:在Storyboard的document outline中,选中Scroll View,打开Assistant Editor,按住Control键,从Scroll View拖到ContainerViewController.swift。然后在弹出框的Name一栏中将Scroll View命名为scrollView

关联Scroll View
关联Scroll View

这里,我设置了View\Assistant Editor\Assistant Editors on Bottom,所以Assistant Editor就会在Xcode窗口的底部(译者注:默认是在右边),这样我就不用在拖动关联时,跨越整个画布了。

同样的步骤,按住Control键,从Menu Container View拖到ContainerViewController.swift,创建一个menuContainerView

关联菜单视图
关联菜单视图

然后,给ContainerViewController.swift添加hideOrShowMenu(_:animated:)这个方法:

// MARK: ContainerViewController
func hideOrShowMenu(show: Bool, animated: Bool) {
  let menuOffset = CGRectGetWidth(menuContainerView.bounds)
  scrollView.setContentOffset(show ? CGPointZero : CGPoint(x: menuOffset, y: 0), animated: animated)
}

menuOffset的值就是Menu Container View宽度为80,如果showtrue,scrollView的偏移量就为0,这是菜单栏就会显示,同样,scrollView的偏移量为80,菜单栏就会隐藏。

现在,在menuItem的didSet中调用hideOrShowMenu(_:animated:)方法:

var menuItem: NSDictionary? {
  didSet {
    hideOrShowMenu(false, animated: true)
    // ...

在用户点击单元行之后,应该关闭菜单栏,所以show置为false

同样,在viewDidLoad() 中调用hideOrShowMenu(_:animated:)方法,让程序启动时,菜单栏处于隐藏状态:

override func viewDidLoad() {
  super.viewDidLoad()
  hideOrShowMenu(false, animated: false)
}

运行程序,这时详情页显示的是笑脸那张图,并且菜单栏处于隐藏状态。滑出菜单,选择某个单元行,可以看到菜单栏会隐藏,并且详情页能显示出对应的图片与背景色。

当前效果
当前效果

但是,之前因为Paging Enable出现的问题依然存在:如果滑出菜单栏,不选择单元行,然后滑动隐藏菜单栏时,菜单栏又会弹出来。这个问题可以通过实现UIScrollViewDelegate中的一个方法来解决。

ContainerViewController类声明的地方,遵守UIScrollViewDelegate协议:

class ContainerViewController: UIViewController, UIScrollViewDelegate {

然后,在ContainerViewController添加UIScrollViewDelegate的代理方法:

// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(scrollView: UIScrollView) {
  /*
  Fix for the UIScrollView paging-related issue mentioned here:
  http://stackoverflow.com/questions/4480512/uiscrollview-single-tap-scrolls-it-to-top
  */
  scrollView.pagingEnabled = scrollView.contentOffset.x < (scrollView.contentSize.width - CGRectGetWidth(scrollView.frame))
}

这将在Scroll View的偏移量与菜单栏宽度相等的时候,禁用Paging Enable;这样,当菜单栏完全隐藏的时候,就会保持隐藏状态。当用户滑出菜单时,Paging Enable又会启用,从而菜单能一次性滑出。

运行程序,可以发现,这个问题已经解决了。

当前效果
当前效果

看起来不错,但依然缺少一些东西。详情也的导航栏上少了汉堡按钮(The hamburger menu button,即有三条横线的菜单按钮),它能控制菜单的开关,并且还能随着菜单的显示而旋转。

添加菜单按钮

我们需要菜单按钮是自定义视图,这样才可以在菜单栏显示/隐藏的时候进行相应的旋转动画。

创建一个HamburgerView.swift,继承自UIView

在这个类中添加以下代码:

 class HamburgerView: UIView {
 
  let imageView: UIImageView! = UIImageView(image: UIImage(named: “Hamburger”))
 
  required init(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    configure()
  }
 
  required override init(frame: CGRect) {
    super.init(frame: frame)
    configure()
  }
 
  // MARK: Private
 
  private func configure() {
    imageView.contentMode = UIViewContentMode.Center
    addSubview(imageView)
  }
 
}

上面的代码重写了两个初始化方法,并调用了configure()来将imageView加载到父视图上。

DetailViewController.swift中添加hamburgerView属性:

var hamburgerView: HamburgerView?

viewDidLoad()中,给hamburgerView创建实例,并将它作为Navigation Barleft bar button,并添加一个点击手势。

let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: “hamburgerViewTapped”)
hamburgerView = HamburgerView(frame: CGRect(x: 0, y: 0, width: 20, height: 20))
hamburgerView!.addGestureRecognizer(tapGestureRecognizer)
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: hamburgerView!)

hamburgerViewTapped() 方法需要触发ContainerViewController中的hideOrShowMenu(_:animated:)方法,但是问题是show应该传什么参数?所以ContainerViewController需要一个Bool值来记录菜单显示/隐藏的状态。

ContainerViewController.swift中添加以下属性:

var showingMenu = false

初始时菜单的显示状态是false,重写viewDidLayoutSubviews()方法,在bounds改变的时候来显示/隐藏菜单。

override func viewDidLayoutSubviews() {
  super.viewDidLayoutSubviews()
  hideOrShowMenu(showingMenu, animated: false)
}

你将不再需要viewDidLoad()方法,所以将它从ContainerViewController.swift中删除。

打开DetailViewController.swift,添加以下代码:

func hamburgerViewTapped() {
  let navigationController = parentViewController as! UINavigationController
  let containerViewController = navigationController.parentViewController as! ContainerViewController
  containerViewController.hideOrShowMenu(!containerViewController.showingMenu, animated: true)
}

如果showingMenu的值为false,菜单栏为隐藏状态,这是,当用户点击按钮,hideOrShowMenu(:animated:)方法就会被调用,show参数传入true,然后菜单显示。相反,当菜单处于显示状态时,即showingMenu值为true,这时用户点击按钮,菜单栏就会隐藏。因此,需要更新ContainerViewController.swifthideOrShowMenu(:animated:)方法的showingMenu属性。

hideOrShowMenu(_:animated:):中添加以下代码:

showingMenu = show

运行程序,尝试去滚动视图、点击菜单按钮、点击单元行这些操作。

当前运行效果
当前运行效果

这存在一个问题:如果是采用滑出/滑入菜单栏的操作,那么菜单栏按钮要点两次,菜单栏才有响应。这是为什么?

出现的问题
出现的问题

这个问题出现的原因为:在滚动的后,showingMenu没有更改,菜单滑出的时候showingMenu还是false。当第一次点击的时候,showingMenu的值设置为true,所以菜单还是显示状态。第二次点击的时候,才能将showingMenu置为false,从而隐藏菜单。

要解决这个问题,需要在ContainerViewControllerUIScrollViewDelegate代理方法中将showingMenu设置一下,具体如下:

func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
  let menuOffset = CGRectGetWidth(menuContainerView.bounds)
  showingMenu = !CGPointEqualToPoint(CGPoint(x: menuOffset, y: 0), scrollView.contentOffset)
  println(“didEndDecelerating showingMenu \(showingMenu)”)
}

当滚动结束以后,如果Scroll View的偏移量与菜单栏的宽度相同(即,菜单栏处于隐藏状态),那就将showingMenu设置为false。相反,则置为true。

运行程序,向右滑动,当滚动停止时,查看控制台打印信息。它的调用取决于Scroll View的滚动速度。当我在模拟器上运行时,只有慢慢的滚动,才会调用这个方法,但是当我在真机上运行时,它又需要快速滚动才能调用。

所以,将这段代码放入到scrollViewDidScroll(_:)方法中,这样可以更好的响应滚动。这个方法在用户滚动时就会调用,这也更加的可靠。

接下来就可以给菜单按钮加旋转动画了,但在此之前,我们先给菜单栏加3D翻转动画。

给菜单栏加立体效果

这个超级炫酷的动画看起来就像开门和关门。与此同时,菜单按钮也应该跟随菜单的状态,有一个平滑的旋转效果。

为了达到这个效果,我们将计算菜单滑出的比例(后文称为fraction),从而得到按钮应该旋转的角度。

ContainerViewController.swift中,添加一个私有方法来通过比例进行3D旋转动画:

func transformForFraction(fraction:CGFloat) -> CATransform3D {
  var identity = CATransform3DIdentity
  identity.m34 = -1.0 / 1000.0;
  let angle = Double(1.0 - fraction) * -M_PI_2
  let xOffset = CGRectGetWidth(menuContainerView.bounds) * 0.5
  let rotateTransform = CATransform3DRotate(identity, CGFloat(angle), 0.0, 1.0, 0.0)
  let translateTransform = CATransform3DMakeTranslation(xOffset, 0.0, 0.0)
  return CATransform3DConcat(rotateTransform, translateTransform)
}

下面是transformForFraction(_:)这个方法的实现步骤:

  • 在菜单完全隐藏时,fraction = 0,完全显示时,fraction = 1;

  • CATransform3DIdentity是一个4*4的矩阵,对角线是1,其他是0;
  • CATransform3DIdentitym34属性是这个矩阵中的第三列第四行,它控制着变换中的立体程度;
  • CATransform3DRotate通过angle这个变量来控制y轴的旋转量:-90度呈现的是菜单栏垂直于屏幕的状态,0度呈现的是与xy轴平行的状态;
  • rotateTransform的旋转变换,是根据m34矩阵与y轴旋转量来的;
  • translateTransform设置了x轴的偏移量为菜单栏的一般;
  • CATransform3DConcatrotateTransformtranslateTransform联系起来,使得在做翻转动画时,也有偏移动画。

接下来,在scrollViewDidScroll(_:):中添加下面代码:

let multiplier = 1.0 / CGRectGetWidth(menuContainerView.bounds)
let offset = scrollView.contentOffset.x * multiplier
let fraction = 1.0 - offset
menuContainerView.layer.transform = transformForFraction(fraction)
menuContainerView.alpha = fraction

offset的值在0到1之间。当offset的值为0的时候,菜单栏处于显示状态;当offset为1的时候,菜单栏处于隐藏状态。

fraction是菜单显示部分所占的比例,范围在0到1之间,0是菜单栏完全隐藏,1是显示状态。

同时,fraction也用来调整菜单栏的alpha值,从暗到明,从隐藏到显示。

运行程序,滑动看一下这个3D效果,但是…菜单栏的变换关系有点问题(译者注:原文用的”hinge”这个词,这里的问题是菜单栏沿着中心轴在旋转),原因是菜单栏的锚点在中心点。

当前效果
当前效果

为了让菜单栏沿着右边距旋转,需要在ContainerViewController.swiftviewDidLayoutSubviews()方法中添加以下方法:

menuContainerView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5)

将锚点的x设为1,y设为0,这样就可以沿右边距旋转了。

运行程序,可以看到完美的3D旋转效果。

当前效果
当前效果

最后一件事:给菜单按钮加旋转动画

菜单按钮动画将是整个App的点睛之笔。

当菜单栏隐藏的时候,按钮处于原有状态,当菜单栏显示的时候,按钮旋转90度。

技术上来讲,是按钮的图片在旋转,但是实际看起来的就效果,就像是按钮在旋转。

HamburgerView.swift中添加以下代码:

func rotate(fraction: CGFloat) {
  let angle = Double(fraction) * M_PI_2
  imageView.transform = CGAffineTransformMakeRotation(CGFloat(angle))
}

现在菜单按钮可以平滑的旋转了。通过fraction来计算应该旋转的角度,其中M_PI_2常量是定义在math.h头文件中的,表示pi/2。

添加以下代码到scrollViewDidScroll(_:)方法中,这样旋转角度可以和滚动偏移量结合起来:

if let detailViewController = detailViewController {
  if let rotatingView = detailViewController.hamburgerView {
    rotatingView.rotate(fraction)
  }
}

运行程序,可以看到动画已经能很好的结合起来了。

最终效果
最终效果

最后

可以在这里下载这个项目的最终版。

本文简单的实验了以下m34的3D旋转效果,如果想要了解更多3D变换,可以看Richard Turton的Visual Tool for CATransform3D

维基百科上也有一些文章,使用图片很好的解释了立体视觉这个概念。

同时,你也可以思考一下,还有那些3D效果可以用在你的App中,来提高用户交互体验。就像是菜单按钮这样的微妙的动画,细节的处理能很好的提高用户体验。

译者注

第一次翻译,还请见谅,之后也会尝试着翻译一些其他的文章,尽请期待。如果有什么建议,请留言或者邮箱联系,谢谢。

《仿Taasky的3D翻转菜单动画实现》有2个想法

发表评论

电子邮件地址不会被公开。