如何创建一个非常酷的3D效果菜单

96
叶孤城___
2015.05.31 20:47* 字数 3426

原文地址在这里.原文
去年,读者们投票选出了Top5的iOS7最佳动画,当然也很想看到有关这些动画如何实现的教程。这次,我们将会实现Taasky这个app的3D效果的侧滑菜单。

这篇教程比较适合开发经验比较丰富的开发者。因为这篇教程涵盖Autolayout,UIScrollView,viewcontroller容器还有CoreAnimation。这些对于初学者来说都比较陌生,所以如果你之前没有接触过的话阅读起来会有点困难。

开始

首先下载一个我们的初始项目。地址在这里
下载之后打开他,运行起来。

第一个页面和点击Cell之后进入的第二个页面是这样的。


第一个页面是一个继承自UITableViewController的Controller,名字叫做MenuViewController,从名字也能看出来了,这将会是我们的侧滑菜单。我们的TableView中使用的Cell是我们自定义的Cell,叫做MenuItemCell。每个Cell都是可以点击的,点击之后进入的是另一个界面,叫做DetailViewController,里面只有一张和点击Cell匹配的同一种背景色和图片。


例如点击绿色的cell

现在这个app距离我们的完成形态还有不少距离。但是耐心跟着教程走是肯定可以完成的。

首先我们需要按照下面几个步骤来。

  1. 首先现在的app实际上是两个页面,由navigationController来控制两个controller的切换。我们第一步要做的就是利用Autolayout和viewcontroller container这两个特性,把这两个viewcontroller合二为一放在一个容器里,而这个容器我们会用scrollview来充当。
  2. 第二步是添加一个button来控制显示和隐藏我们的菜单。
  3. 第三步实现我们菜单的3D化,就像Taasky这个APP里面的菜单一样。
  4. 最后一步,你要将菜单动画和scrollView的offset结合起来。

废话不多说,我们新建一个Viewcontroller,用来当做ViewController容器,名字就叫ContainerViewController.确保是继承自UIViewController。语言选择swift。


同样的在storyboard里也拉出一个ViewController,并把class改成我们的ContainerViewController。Storyboard ID改成ContainerVC.


选择view,并且把背景色改成黑色.


ok,拉一个UIScrollview到我们的view上.并且把垂直和水平滚动条隐藏掉.把Delays Content Touches也取消掉.如图.


右键单击我们的scrollview,把delegate设置为我们的ContainerViewController.


给我们的scrollview添加约束.很简单的约束,上下左右与父view间距为0.


设置contentView

然后托一个view到我们的scrollview上,并且把size和背景色设置如图的值.


把我们的view的Document Label设置为ContentView,用来和其他的view区别.

然后给我们的contentView添加约束.


然后把我们的Trailing这个约束的constant改为0.


这时候xcode会出现红色的警告,是因为我们的约束没有添加完成,因为你如果不给scrollview的contentview设置宽高的话,scrollview是没办法确定自己的contentsize的.

所以我们这样设置.


把我们的ContentView的宽高设置为和ContainerViewController的view的宽高一致.

然后修改如下约束.


把constant改为80的意思就是,我们的Contentview的宽一直是底层view宽度+80(这80就是给我们的侧边栏准备的.).

添加Menu和Detail Container Views

从storyboard找到一个叫做ContainerView的控件,相信这个控件很多人并没有用过.这个控件就是在storyboard中为某个ViewController添加一个childViewController用的.

首先,拖一个ContainerView到我们的ContentView,宽高改为(80,600),然后Document里的label改为Menu Container View.


然后,再拖一个ContainerView到我们的ContentView,并且把size和Document里的label改为下图所示的数值.


拖完之后我们的ContentView就会长成这样.


ContainerView有一个特性,就是你一旦拖出一个ContainerView,那么xcode会自动帮你生成一个他的子ViewController.如图.


显然,系统帮我们生成的这两个ViewController对我们来说是没用的,因为我们已经有了MenuController和DetailController,所以删掉他们.

删掉之后,给我们的两个ContainerView分别添加约束.Menu ContainerView的约束如下.


DetailContainerView的约束如下.


我们刚才删除了系统帮我们生成的childViewController,现在我们需要手动添加.

首先把我们的InitController改成我们的ContainerViewController.


然后右键点击Menu ContainerView,拖一根线到我们的Navigation Controller.然后在弹出框中选择embed.


一旦线拖好之后,我们的storyboard看起来是这样子的.


肯定要改一改.首先把MenuController里的Cell里的UIImageView的width改成80.


然后,把MenuViewController和DetailViewController中间代表push的那个segue删掉.

然后为我们的DetailViewController生成一个自己的navigationController.


选择我们刚刚生成的navigationController,把我们的navagationbar改为如下.


然后把MenuViewController的navigationbar也改成一样的参数.并且把View Controller\Layout\Adjust Scroll View Insets选中.


ok,按照刚才拉MenuContainerView的方式拉一下DetailContainerView.


这样,我们的ContainerViewController就拥有两个childViewController了.

运行一下.试试效果.


看起来不错.但是有个问题.使劲往右拉的话,左边会拉出来一片黑色的区域.这显然不是我们想要的.

所以在Storyboard中找到我们的ScrollView.
1.选中Paging Enabled.
2.取消Bounce\Bounces的选中状态.

再运行一次.向右拉,这次menu显示正确了.不会在左边漏出一大段黑色的空间.但是每次我们试图隐藏menu的时候它又会弹回来.(实际上我按照教程做到这的时候并没有发生这种情况,菜单是可以隐藏的.)

第二个问题是,点击侧边栏,detailContainerView并不会发生变化.这很正常,因为你还没写代码呢.

修改我们的代码

首先,把MenuViewController.swift里的这些代码拷贝到我们的DetailViewController中.

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

这个的作用是消除navigationbar下面的一条特别细的线.

每次选择一个MenuViewController里面的一个tableviewCell的时候,相应的我们应该设置DetailViewController里面的menuItem属性.但是现在我们的MenuViewController和DetailViewController还没有关联起来.所以我们会利用ContainerViewController来建立两个controller之间的联系.

在ContainerViewController里添加这么一个属性.

private var detailViewController: DetailViewController?

然后override我们的ContainerViewController里的prepareForSegue(_:sender:)方法.

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  if segue.identifier == "DetailViewSegue" {
    let navigationController = segue.destinationViewController as! UINavigationController
    detailViewController = navigationController.topViewController as? DetailViewController
  }
}

别忘了设置我们的segue.identifier.如图所示.


然后再添加一个menuItem的属性到ContainerViewController里,并且监听如果menuItem被设置,那么让detailViewController的menuItem相应的也改变.

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

然后,到我们的MenuViewController里,先删除prepareForSegue这个方法,因为这个方法是以前MenuViewController和DetailViewController有直接关联的时候才有用的,现在这个方法显然已经没有意义了.

我们要做的就是在MenuViewController里的tableview 的delegate里添加以下的内容.

// MARK: UITableViewDelegate
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
  tableView.deselectRowAtIndexPath(indexPath, animated: true)
  let menuItem = menuItems[indexPath.row] as! NSDictionary
  (navigationController!.parentViewController as! ContainerViewController).menuItem = menuItem
}

然后再在ViewDidLoad()方法里加入以下内容,确保第一次进入页面的时候默认选择的是第一个Cell.

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

运行一下.效果如下.


显示和隐藏我们的Menu

现在我们点击cell虽然DetailViewController的内容可以正确显示,但是菜单并不能自动隐藏.所以我们首先要实现的是点击菜单之后菜单自动隐藏.

要实现这个效果,首先要把我们的ContainerViewController里的scrollView和MenuContainerView拖线拖到我们的ContainerViewController里.

如图.



然后给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)
}

然后在MenuItem的didSet里加入这个方法,意思就是每次设置menuItem的时候都会自动调用这个方法.

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

运行一下.


原文中提到了这时候菜单还是存在回弹和收不回去的问题,实际上在我做的时候并没有出现这种情况.所以如果你们做的时候如果出现了回弹.那么需要在ContainerViewController里实现UIScrollView的这个Delegate.

// 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))
}

然后运行,这时候应该没问题了.


添加我们的汉堡按钮

新建一个类继承自UIView,起名叫做HamburgerView.swift.

然后修改内容如下.

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)
  }

}

然后在我们的DetailViewController里,把他加进去.先添加一个属性

var hamburgerView: HamburgerView?

然后在viewDidLoad()里添加如下代码.

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’s hideOrShowMenu(_:animated:),但是现在缺少一个布尔值来表示菜单是否处于打开状态.所以我们为ContainerViewController添加一个布尔值用来记录菜单的状态.

var showingMenu = false

然后override viewDidLayoutSubviews()方法.加入如下代码.

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

这会在ContainerViewController的布局每次发生变化的时候调用hideorShow方法.

然后打开DetailViewController,添加我们的点击事件.

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

现在点击汉堡按钮,已经能够打开菜单了,但是再次点击应该是关闭菜单,然后并没有效果,原因很简单,你没有跟新showingMenu的值,所以在我们的hideOrShowMenu方法里加入showingMenu = show.

再试一下.


ok了.

然而,问题依然没有结束.
当你滑动打开菜单的时候,需要点击汉堡菜单两次才能关闭菜单.这是因为你滑动打开菜单的时候并没有更新showingMenu的值.所以,需要在UIScrollviewDelegate里更新我们的showingMenu.

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

运行一下,注意一下console,当你快速滑动的时候是没问题的,但是缓慢滑动的时候这个方法似乎不响应.所以这个方法并不靠谱.
我们把代码移到另一个代理方法scrollViewDidScroll(_:)里.

再次运行.



应该没问题了.

给我们的菜单添加透视效果

实际上完整的效果华丽就华丽在菜单出现的方式并不是水平的,而是以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)
}

上面的方法就是计算菜单显示的部分和旋转角度的关系.

  1. fraction当menu完全隐藏的时候是0,完全显示的时候是1.
  2. CATransform3DIdentity代表原始的Transform.
  3. CATransform3DIdentity’s m34这个值代表view的perspective.(设置了他旋转的时候才会有3D效果)
  4. 利用CATransform3DRotate来实现菜单的旋转效果.并且是绕Y轴旋转.-90度的时候代表与平面向内垂直(所以你看不到).0度的时候水品展开.
  5. translateTransform负责menu在旋转的时候同时位移到正确的位置.
  6. CATransform3DConcat负责把位置的transform和旋转的transform结合起来.

现在在我们的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

运行一下.


效果似乎不太对.那是因为我们并没有设置menuContainerView的anchorpoint,现在的anchorPoint还是在view的中心点我们实际上的anchorpoint应该是在view中心最右的位置.所以在ContainerViewController的viewDidLayoutSubViews()里修改anchorPoint.
menuContainerView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5)

运行.


效果不错.

让汉堡按钮动起来.

我们只剩最后一个效果了,就是菜单出现的过程中,汉堡按钮也要转相应的角度.

在HamburgerView中添加下面的方法.

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

然后在ContainerViewController里的scrollViewDidScroll()里添加以下代码.

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

运行一下.



Perfect!

从这里获取最终的程序.

下载
如果你对perspective有疑问.那么请在这里浏览相关信息.
Perspective

有任何疑问可以留言.

如果你认为这篇文章不错,也有闲钱,那你可以用支付宝扫描下方二维码随便捐助一点,以慰劳作者的辛苦


日記本