如何创建一个3D侧边栏动画
原攻略来自于:How To Create a Cool 3D Sidebar Animation Like in Taasky
本篇文章为我自己的总结、翻译版,你可以点击上面的链接找到原文(如果你不是简书看到的,可以点击这里查看我的原文)
在今天的文章中,由于我的英文能力有限,就不会对原文进行逐句翻译了,我会以自己的理解来讲解这篇文章的精髓,教你一步步来实现最终的效果
另外由于我个人的能力有限,可能有些地方出现遗漏或者理解出错,欢迎指正下,共同学习。
最终效果:
-
修改添加的UIView的Document\Label为Content View(主要是为了方便追踪各个view)
Open the Identity Inspector for the content view you just added and set
给刚才的UIView添加约束
-
修改一下Trailing Space的 Constant 为0(😁,好吧在上面一步你完全可以直接设置好的,多做一步只是为了让新手熟悉一下...)
设置好后,你会发现有布局的警告,这是因为对于ScrollView的布局仅仅设置上下左右是不够的,还要对Content View的宽高做设置,这样才能决定ScrollView的 content size.
-
接下来我们来设置宽和高,选中Content View,按住control键拖拽至View,如下:
在右侧属性检测器中修改宽度的常量为80
设置常量为80的意思是我们的Content View会比整个View(也相当于屏幕的宽度)的宽度宽80
好了,现在再看看,发现警告不见了吧,哈哈,太棒了!
添加菜单和详情的Container Views
拖拽一个UIContainer View到Content View上,在属性检测器中设置宽度为80,并且给 Document\Label设置为Menu Container View,如下:
不要在意图中的600,因为Xcode版本的问题,现在的Xcode8已经不是默认600X600了,我们要设置的是高度和Content View一样,宽度为80,左上角对齐父视图就对了
添加详情Container View:同样再拖拽一个UIContainer View放在菜单Container View右边,给它的Document\Label 设置为Detail Container View
这个详情Container View的宽度和父视图的宽度是一样的,现在你应该得到如下的视图:
添加Container View 会自带一个控制器,把它们删掉:
现在来设置布局吧:
对于Menu Container View:相对于它的父视图以及详情 Container View共5个约束:
对于 Detail Container View:添加了3个约束:
通过移动箭头改一下 Initial view controller
把菜单控制器和详情控制器嵌套进来:选中Menu Container View拖拽到Navigation Controller,并选择embed
设置好嵌套之后,你的storyboard会变成如下:
嵌套进来的控制器统统都收缩到80的宽度了。
调整一下menu view controller上UIImageView的宽度
删掉 menu和 detail 之间的 segue,并给detail view controller添加一个Navigation Controller 通过上面菜单栏的按钮:Editor\Embed In\Navigation Controller
给这个新的navigation controller 设置如下属性:
在属性检测器中设置View Controller\Layout\Adjust Scroll View Insets选中(这个会避免内容被bar覆盖):
我们要让Detail View Controller嵌套在Detail Container View中,
现在你就可以运行一下了,运行结果应该是这个样子:
现在我们可以随意滑动ScrollView了, 接下来我们要修改一下只让它显示整个侧边菜单和隐藏侧边菜单, 并且让它不能滑出边界
- 在ScrollView的属性检测器中选择Scrolling\Paging Enabled
- 不要选择Bounce\Bounces
再次build&run 你会发现:
但是还有一点点问题,当你试图隐藏菜单框的时候,它有时又重新弹回并显示出来了,这是一个 paging的问题,详细的你可以参考这里: this problem discussion on StackOverflow.
先留着这个问题,我们先来解决点击左侧按钮没有相应的事件变化的问题(和我们最上面演示的图作比较)
这或许会使你感到惊讶,因为我们并没有改变任何相关的代码,接着往下看:
先修改一下细节的问题:
在 MenuViewController.swift的 viewDidLoad() 加入以下代码:
override func viewDidLoad() {
super.viewDidLoad()
// Remove the drop shadow from the navigation bar
navigationController!.navigationBar.clipsToBounds = true
}
这能消除掉navigation bar下极小的细缝,虽然是个很小的细节,但是也能对整个APP填色不少。
当我们点击MenuViewController中的cell的时候 应该设置DetailViewController 的 menuItem 属性,但是现在DetailViewController已经不直接和MenuViewController连接了,所以没什么反应
ContainerViewController将会扮演MenuViewController和DetailViewController之间的协调者的角色
给ContainerViewController.swift添加一个属性:
private var detailViewController: DetailViewController?
在ContainerViewController.swift中实现 prepareForSegue(_:sender:) 方法:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "DetailViewSegue" {
let navigationController = segue.destination as! UINavigationController
detailViewController = navigationController.topViewController as? DetailViewController
}
}
在将DetailViewController嵌套进container View中的时候会生成一条Storyboard Embed Segue的线,你选中这条线,并设置它的Identifier为DetailViewSegue:
在ContainerViewController中声明一个menuItem属性,并设置属性观测器:
var menuItem: NSDictionary? {
didSet {
if let detailViewController = detailViewController {
detailViewController.menuItem = menuItem
}
}
}
由于MenuViewController 和DetailViewController之间已经没有segue连接了,但是当选中MenuViewController中的cell的时候仍然需要作出相应,我们把事件的相应从prepareForSegue(:sender:)移动到tableView(:didDeselectRowAtIndexPath:)中。
删掉prepareForSegue(_:sender:)中的代码,改成如下:
// MARK: UITableViewDelegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let menuItem = menuItems[indexPath.row] as! NSDictionary
(navigationController!.parent as! ContainerViewController).menuItem = menuItem
}
这样我们就在选中menu中的cell的时候设置ContainerViewController的menuItem属性了,并且触发了属性观测器从而设置了DetailViewController的menuItem属性
我们在MenuViewController.swift中的viewDidLoad()添加:
(navigationController!.parentViewController as! ContainerViewController).menuItem =
(menuItems[0] as! NSDictionary)
这是为了app第一次启动的时候给DetailViewController设置默认的图片
现在运行app,会发现如下效果了:
现在我们要显示和隐藏左侧菜单
当我们选中菜单的时候,我们要隐藏掉菜单
为了实现这个目的,我们要设置Scroll View 的content
在ContainerViewController.swift 中连接 Scroll View并命名为scrollView
具体操作如下:
现在在ContainerViewController.swift 添加 hideOrShowMenu(_:animated:)方法
// MARK: ContainerViewController
func hideOrShowMenu(show: Bool, animated: Bool) {
let menuOffset = menuContainerView.bounds.width
scrollView.setContentOffset(show ? CGPoint.zero : CGPoint(x: menuOffset, y: 0), animated: animated)
}
menuOffset 的值是 80 ,当true的时候,那么origin就是(0,0),这个时候菜单是可见的,同理,当origin时(80,0)的时候菜单是隐藏的
在ContainerViewController的menuItem属性观测器中添加
var menuItem: NSDictionary? {
didSet {
hideOrShowMenu(false, animated: true)
// ...
运行app,将会出现如下效果:
好了这个时候我们来解决paging 的问题吧(滑动来隐藏侧边栏的时候,侧边栏会弹出来的BUG)
我们将通过遵守UIScrollViewDelegate协议来解决这个问题
给ContainerViewController添加协议:
class ContainerViewController: UIViewController, 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.isPagingEnabled = scrollView.contentOffset.x < (scrollView.contentSize.width - scrollView.frame.width)
}
对于这个问题的详细探究就不在本文的范围之内了,有兴趣的同学可以进入这个链接看看:这里
Bulid&Run,你会发现解决了这个问题:
添加左上角的按钮
通过点击按钮来隐藏和展示左侧栏,创建一个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)
}
}
我们来给DetailViewController.swift添加一个属性:
var hamburgerView: HamburgerView?
在 viewDidLoad()中创建hamburgerView并赋在navigation bar上
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:)方法,但是我们应该传入什么值呢?
我们给ContainerViewController添加一个bool类型的属性来记录左侧菜单是否显示
在ContainerViewController.swift下面添加属性:
var showingMenu = false
我们重写viewDidLayoutSubviews() 来控制展示或者隐藏菜单栏,这样的好处是旋转的时候也能及时响应:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
hideOrShowMenu(show: showingMenu, animated: false)
}
那么我们就不需要ContainerViewController.swift中的viewDidLoad()了,删了它们吧
别忘了我们还要在DetailViewController.swift中添加响应事件:
func hamburgerViewTapped() {
let navigationController = parent as! UINavigationController
let containerViewController = navigationController.parent as! ContainerViewController
containerViewController.hideOrShowMenu(show: !containerViewController.showingMenu, animated: true)
}
每当我们点击的时候需要通过!containerViewController.showingMenu来控制是否显示,就像button的选中非选中那样。
我们要在hideOrShowMenu方法中及时修改一下我们的showingMenu的状态,在hideOrShowMenu方法下面添加如下:
showingMenu = show
B&R(Build & Run)你会看到如下的效果:
还有一个问题就是当你滑动展示菜单栏或者隐藏菜单栏的时候,再去点击左上角的button来响应事件需要点击两次,这是为什么呢?
这是因为我们滑动ScrollView的时候 并没有更新showingMenu
为了修正这个问题,你需要实现UIScrollViewDelegate另一个方法
在ContainerViewController中添加:
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
let menuOffset = CGRectGetWidth(menuContainerView.bounds)
showingMenu = !CGPointEqualToPoint(CGPoint(x: menuOffset, y: 0), scrollView.contentOffset)
println(“didEndDecelerating showingMenu \(showingMenu)”)
}
当ScrollView的content offset等于菜单栏宽度(80)的时候,也就是菜单栏是隐藏的,设置showingMenu为false,反之同理
B&R 让我们来看看当停下来的时候是否会如预期那样?看起来和期望好像有一点点的差池,这点差池有点依赖于滑动的速度,当我在模拟器上测试的时候,只有滚动很慢的情况下达到预期,但是在真实设备上只有滚动很快才会达到预期。
好吧,那就把上面的代码全部都移动到scrollViewDidScroll(_:)中,这个方法会不断的调用,相对来说更可靠点
添加3D效果
首先要添加透视效果:
在ContainerViewController.swift中添加如下代码:
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 = menuContainerView.bounds.width * 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 是4x4的单位juzhen
- CATransform3DIdentity的m34属性控制着透视的量
- CATransform3DRotate 用弧度制 来控制绕y轴的旋转量,-90表明垂直于当前的x-y平面,0表明平行于x-y平面
- rotateTransform是单位矩阵经传入m34 值按照一定弧度选择变换之后的矩阵
- translateTransform 是将菜单栏向右移动其一半宽度距离变换而来的矩阵
- CATransform3DConcat 将上面的两个矩阵进行了连锁变化
注意: m34 通常是1除以一个值来表示,这个值表达的含义是你在z轴上观察x-y平面的位置(单位是像素),负数表明观察者是在屏幕前,而正数表示观察者在屏幕后面。
在观察者和观察的对象之间画线形成3D透视效果。观察者移动的越远,那么透视效果越不明显。你可以试试改变值1000到500或者2000来看看菜单栏会发生什么样的变化.
在scrollViewDidScroll(_:):下添加如下代码:
let multiplier = 1.0 / menuContainerView.bounds.width
let offset = scrollView.contentOffset.x * multiplier
let fraction = 1.0 - offset
menuContainerView.layer.transform = transformForFraction(fraction: fraction)
menuContainerView.alpha = fraction
值是从0到1的,0表示完全显示,1表示完全隐藏菜单栏
这样的话fraction 完全依赖于已经显示的菜单栏的宽度来改变其值(0~1),同时我们还通过fraction来调整菜单栏的alpha 来改变它的明暗情况
B&R 我们可以看到我们的3D效果了
很显然还有一点错误,我们的连接点好像出问题了,这是因为view默认状态的anchorPoint是view的中心
我们来修改anchorPoint使其在右侧边缘中心:
在ContainerViewController.swift的viewDidLayoutSubviews()中添加如下code:
menuContainerView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5)
B&R 你会看到:
还有一件事:
我们来给左上角的按钮(以下称MenuBtn)添加动画吧
在HamburgerView.swift中添加:
func rotate(fraction: CGFloat) { let angle = Double(fraction) * M_PI_2 imageView.transform = CGAffineTransformMakeRotation(CGFloat(angle)) }
在ContainerViewController.swift中的scrollViewDidScroll(_:)中添加如下代码:
if let detailViewController = detailViewController { if let rotatingView = detailViewController.hamburgerView { rotatingView.rotate(fraction) } }
B&R :
Cool!