原攻略來自于:How To Create a Cool 3D Sidebar Animation Like in Taasky
本篇文章為我自己的總結(jié)、翻譯版,你可以點(diǎn)擊上面的鏈接找到原文(如果你不是簡書看到的,可以點(diǎn)擊這里查看我的原文)
在今天的文章中,由于我的英文能力有限,就不會對原文進(jìn)行逐句翻譯了,我會以自己的理解來講解這篇文章的精髓,教你一步步來實(shí)現(xiàn)最終的效果
另外由于我個人的能力有限,可能有些地方出現(xiàn)遺漏或者理解出錯,歡迎指正下,共同學(xué)習(xí)。
最終效果:

由于之前作者是用swift老版本的初始工程來講解的,你可以來這里下載swift版本轉(zhuǎn)換好的源文件開始今天的學(xué)習(xí)
(ps:接下來的工程我都用swift3.0實(shí)現(xiàn)了一遍,不過仍可能出現(xiàn)粘貼的是老代碼未及時更新的問題,不過swift版本的問題也不難。)
下載好后,Taasky_origin就是我們要開始的初始文件了。
點(diǎn)擊Run運(yùn)行,你可以看到如下效果:

看起來像是一個工作之余和朋友喝喝咖啡之類的App
我們現(xiàn)在要做如下的事情
- 首先你要創(chuàng)建一個ScrollView來包含左側(cè)的側(cè)邊欄按鈕部分和右邊的詳情部分
- 然后添加一個按鈕來控制側(cè)邊欄的顯示和隱藏
- 接著實(shí)現(xiàn)一個3D 效果來完成平滑的折疊效果
- 最后當(dāng)你觸摸的時候,我們順帶添加一個菜單旋轉(zhuǎn)的動畫,和我們顯示、隱藏側(cè)邊欄同步起來
我們來分析一下

- 當(dāng)菜單顯示的時候我們實(shí)際看到的是紫色部分的區(qū)域
- 當(dāng)菜單隱藏的時候我們看到的是綠色的區(qū)域
接下來我們會用到ContainerView ,并通過自動布局添加約束的方式來完成我們最終的布局,最終我們要讓storyboard出現(xiàn)下面的畫面,提前來預(yù)覽下吧:

不要著急,我們接下來會一步步實(shí)現(xiàn)的。
給UIScrollView添加約束
接下來我們需要做的是:創(chuàng)建一個新的控制器來協(xié)調(diào)菜單控制器和詳情控制器,在這個新的控制器中我們添加一個UIScrollView,然后給ScrollView添加 兩個container views ,分別嵌入菜單控制器和詳情控制器。
創(chuàng)建一個新的ContainerViewController繼承自UIViewController,語言選擇Swift
-
在Main.storyboard拖拽一個新的UIViewController,在Inspector中修改下之前Class\Custom Class 為 ContainerViewController
把背景改為黑色:
-
到了添加ScrollView的時候了,我們拖拽一個ScrollView,并把大小拉至填充整個屏幕,在右側(cè)屬性檢測器中去掉顯示指示條,并且把Delays Content Touches去掉勾選,這樣當(dāng)你選中的時候就不會有那么一點(diǎn)延遲了
設(shè)置ScrollView 的delegate為當(dāng)前控制器
給ScrollView添加四個約束,記得去掉勾選Constrain to margins(不要在意下圖中的寬高值的問題):
添加的約束是:
Trailing Space to: Superview
Leading Space to: Superview
Top Space to: Superview
-
Bottom Space to: Bottom Layout Guide
添加完后的樣子 該給ScrollView添加內(nèi)容了,拖拽一個UIView給ScrollView,讓它的大小和ScrollView的大小一樣,然后再通過右側(cè)屬性檢測器給這個UIView的寬度增加80,舉例:我在7Plus的尺寸下編輯,ScrollView由于完全占滿屏幕所以寬度是414,那么我們剛剛添加的UIView的尺寸就是494了(這個時候我們還沒有添加布局哦)

-
修改添加的UIView的Document\Label為Content View(主要是為了方便追蹤各個view)
Open the Identity Inspector for the content view you just added and set
給剛才的UIView添加約束
-
修改一下Trailing Space的 Constant 為0(??,好吧在上面一步你完全可以直接設(shè)置好的,多做一步只是為了讓新手熟悉一下...)
設(shè)置好后,你會發(fā)現(xiàn)有布局的警告,這是因為對于ScrollView的布局僅僅設(shè)置上下左右是不夠的,還要對Content View的寬高做設(shè)置,這樣才能決定ScrollView的 content size.
-
接下來我們來設(shè)置寬和高,選中Content View,按住control鍵拖拽至View,如下:
在右側(cè)屬性檢測器中修改寬度的常量為80
設(shè)置常量為80的意思是我們的Content View會比整個View(也相當(dāng)于屏幕的寬度)的寬度寬80
好了,現(xiàn)在再看看,發(fā)現(xiàn)警告不見了吧,哈哈,太棒了!
添加菜單和詳情的Container Views
拖拽一個UIContainer View到Content View上,在屬性檢測器中設(shè)置寬度為80,并且給 Document\Label設(shè)置為Menu Container View,如下:
不要在意圖中的600,因為Xcode版本的問題,現(xiàn)在的Xcode8已經(jīng)不是默認(rèn)600X600了,我們要設(shè)置的是高度和Content View一樣,寬度為80,左上角對齊父視圖就對了
添加詳情Container View:同樣再拖拽一個UIContainer View放在菜單Container View右邊,給它的Document\Label 設(shè)置為Detail Container View
這個詳情Container View的寬度和父視圖的寬度是一樣的,現(xiàn)在你應(yīng)該得到如下的視圖:
添加Container View 會自帶一個控制器,把它們刪掉:
現(xiàn)在來設(shè)置布局吧:
對于Menu Container View:相對于它的父視圖以及詳情 Container View共5個約束:
對于 Detail Container View:添加了3個約束:
通過移動箭頭改一下 Initial view controller
把菜單控制器和詳情控制器嵌套進(jìn)來:選中Menu Container View拖拽到Navigation Controller,并選擇embed
設(shè)置好嵌套之后,你的storyboard會變成如下:
嵌套進(jìn)來的控制器統(tǒng)統(tǒng)都收縮到80的寬度了。
調(diào)整一下menu view controller上UIImageView的寬度
刪掉 menu和 detail 之間的 segue,并給detail view controller添加一個Navigation Controller 通過上面菜單欄的按鈕:Editor\Embed In\Navigation Controller
給這個新的navigation controller 設(shè)置如下屬性:
在屬性檢測器中設(shè)置View Controller\Layout\Adjust Scroll View Insets選中(這個會避免內(nèi)容被bar覆蓋):
我們要讓Detail View Controller嵌套在Detail Container View中,
現(xiàn)在你就可以運(yùn)行一下了,運(yùn)行結(jié)果應(yīng)該是這個樣子:
現(xiàn)在我們可以隨意滑動ScrollView了, 接下來我們要修改一下只讓它顯示整個側(cè)邊菜單和隱藏側(cè)邊菜單, 并且讓它不能滑出邊界
- 在ScrollView的屬性檢測器中選擇Scrolling\Paging Enabled
- 不要選擇Bounce\Bounces
再次build&run 你會發(fā)現(xiàn):
但是還有一點(diǎn)點(diǎn)問題,當(dāng)你試圖隱藏菜單框的時候,它有時又重新彈回并顯示出來了,這是一個 paging的問題,詳細(xì)的你可以參考這里: this problem discussion on StackOverflow.
先留著這個問題,我們先來解決點(diǎn)擊左側(cè)按鈕沒有相應(yīng)的事件變化的問題(和我們最上面演示的圖作比較)
這或許會使你感到驚訝,因為我們并沒有改變?nèi)魏蜗嚓P(guān)的代碼,接著往下看:
先修改一下細(xì)節(jié)的問題:
在 MenuViewController.swift的 viewDidLoad() 加入以下代碼:
override func viewDidLoad() {
super.viewDidLoad()
// Remove the drop shadow from the navigation bar
navigationController!.navigationBar.clipsToBounds = true
}
這能消除掉navigation bar下極小的細(xì)縫,雖然是個很小的細(xì)節(jié),但是也能對整個APP填色不少。
當(dāng)我們點(diǎn)擊MenuViewController中的cell的時候 應(yīng)該設(shè)置DetailViewController 的 menuItem 屬性,但是現(xiàn)在DetailViewController已經(jīng)不直接和MenuViewController連接了,所以沒什么反應(yīng)
ContainerViewController將會扮演MenuViewController和DetailViewController之間的協(xié)調(diào)者的角色
給ContainerViewController.swift添加一個屬性:
private var detailViewController: DetailViewController?
在ContainerViewController.swift中實(shí)現(xiàn) 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嵌套進(jìn)container View中的時候會生成一條Storyboard Embed Segue的線,你選中這條線,并設(shè)置它的Identifier為DetailViewSegue:
在ContainerViewController中聲明一個menuItem屬性,并設(shè)置屬性觀測器:
var menuItem: NSDictionary? {
didSet {
if let detailViewController = detailViewController {
detailViewController.menuItem = menuItem
}
}
}
由于MenuViewController 和DetailViewController之間已經(jīng)沒有segue連接了,但是當(dāng)選中MenuViewController中的cell的時候仍然需要作出相應(yīng),我們把事件的相應(yīng)從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的時候設(shè)置ContainerViewController的menuItem屬性了,并且觸發(fā)了屬性觀測器從而設(shè)置了DetailViewController的menuItem屬性
我們在MenuViewController.swift中的viewDidLoad()添加:
(navigationController!.parentViewController as! ContainerViewController).menuItem =
(menuItems[0] as! NSDictionary)
這是為了app第一次啟動的時候給DetailViewController設(shè)置默認(rèn)的圖片
現(xiàn)在運(yùn)行app,會發(fā)現(xiàn)如下效果了:
現(xiàn)在我們要顯示和隱藏左側(cè)菜單
當(dāng)我們選中菜單的時候,我們要隱藏掉菜單
為了實(shí)現(xiàn)這個目的,我們要設(shè)置Scroll View 的content
在ContainerViewController.swift 中連接 Scroll View并命名為scrollView
具體操作如下:
現(xiàn)在在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 ,當(dāng)true的時候,那么origin就是(0,0),這個時候菜單是可見的,同理,當(dāng)origin時(80,0)的時候菜單是隱藏的
在ContainerViewController的menuItem屬性觀測器中添加
var menuItem: NSDictionary? {
didSet {
hideOrShowMenu(false, animated: true)
// ...
運(yùn)行app,將會出現(xiàn)如下效果:
好了這個時候我們來解決paging 的問題吧(滑動來隱藏側(cè)邊欄的時候,側(cè)邊欄會彈出來的BUG)
我們將通過遵守UIScrollViewDelegate協(xié)議來解決這個問題
給ContainerViewController添加協(xié)議:
class ContainerViewController: UIViewController, UIScrollViewDelegate {
添加協(xié)議方法并實(shí)現(xiàn):
// 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)
}
對于這個問題的詳細(xì)探究就不在本文的范圍之內(nèi)了,有興趣的同學(xué)可以進(jìn)入這個鏈接看看:這里
Bulid&Run,你會發(fā)現(xiàn)解決了這個問題:
添加左上角的按鈕
通過點(diǎn)擊按鈕來隱藏和展示左側(cè)欄,創(chuàng)建一個HamburgerView.swift繼承于UIView
其內(nèi)部實(shí)現(xiàn):
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()中創(chuàng)建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!)
實(shí)現(xiàn)點(diǎn)擊方法:hamburgerViewTapped()
在這個方法中我們將調(diào)用ContainerViewController的hideOrShowMenu(_:animated:)方法,但是我們應(yīng)該傳入什么值呢?
我們給ContainerViewController添加一個bool類型的屬性來記錄左側(cè)菜單是否顯示
在ContainerViewController.swift下面添加屬性:
var showingMenu = false
我們重寫viewDidLayoutSubviews() 來控制展示或者隱藏菜單欄,這樣的好處是旋轉(zhuǎn)的時候也能及時響應(yīng):
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
hideOrShowMenu(show: showingMenu, animated: false)
}
那么我們就不需要ContainerViewController.swift中的viewDidLoad()了,刪了它們吧
別忘了我們還要在DetailViewController.swift中添加響應(yīng)事件:
func hamburgerViewTapped() {
let navigationController = parent as! UINavigationController
let containerViewController = navigationController.parent as! ContainerViewController
containerViewController.hideOrShowMenu(show: !containerViewController.showingMenu, animated: true)
}
每當(dāng)我們點(diǎn)擊的時候需要通過!containerViewController.showingMenu來控制是否顯示,就像button的選中非選中那樣。
我們要在hideOrShowMenu方法中及時修改一下我們的showingMenu的狀態(tài),在hideOrShowMenu方法下面添加如下:
showingMenu = show
B&R(Build & Run)你會看到如下的效果:
還有一個問題就是當(dāng)你滑動展示菜單欄或者隱藏菜單欄的時候,再去點(diǎn)擊左上角的button來響應(yīng)事件需要點(diǎn)擊兩次,這是為什么呢?
這是因為我們滑動ScrollView的時候 并沒有更新showingMenu
為了修正這個問題,你需要實(shí)現(xiàn)UIScrollViewDelegate另一個方法
在ContainerViewController中添加:
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
let menuOffset = CGRectGetWidth(menuContainerView.bounds)
showingMenu = !CGPointEqualToPoint(CGPoint(x: menuOffset, y: 0), scrollView.contentOffset)
println(“didEndDecelerating showingMenu \(showingMenu)”)
}
當(dāng)ScrollView的content offset等于菜單欄寬度(80)的時候,也就是菜單欄是隱藏的,設(shè)置showingMenu為false,反之同理
B&R 讓我們來看看當(dāng)停下來的時候是否會如預(yù)期那樣?看起來和期望好像有一點(diǎn)點(diǎn)的差池,這點(diǎn)差池有點(diǎn)依賴于滑動的速度,當(dāng)我在模擬器上測試的時候,只有滾動很慢的情況下達(dá)到預(yù)期,但是在真實(shí)設(shè)備上只有滾動很快才會達(dá)到預(yù)期。
好吧,那就把上面的代碼全部都移動到scrollViewDidScroll(_:)中,這個方法會不斷的調(diào)用,相對來說更可靠點(diǎn)
添加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(_:):的作用:
- 當(dāng) fraction為0的是菜單欄完全隱藏,當(dāng)fraction為1的時候菜單欄完全顯示
- CATransform3DIdentity 是4x4的單位juzhen
- CATransform3DIdentity的m34屬性控制著透視的量
- CATransform3DRotate 用弧度制 來控制繞y軸的旋轉(zhuǎn)量,-90表明垂直于當(dāng)前的x-y平面,0表明平行于x-y平面
- rotateTransform是單位矩陣經(jīng)傳入m34 值按照一定弧度選擇變換之后的矩陣
- translateTransform 是將菜單欄向右移動其一半寬度距離變換而來的矩陣
- CATransform3DConcat 將上面的兩個矩陣進(jìn)行了連鎖變化
注意: m34 通常是1除以一個值來表示,這個值表達(dá)的含義是你在z軸上觀察x-y平面的位置(單位是像素),負(fù)數(shù)表明觀察者是在屏幕前,而正數(shù)表示觀察者在屏幕后面。
在觀察者和觀察的對象之間畫線形成3D透視效果。觀察者移動的越遠(yuǎn),那么透視效果越不明顯。你可以試試改變值1000到500或者2000來看看菜單欄會發(fā)生什么樣的變化.
在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 完全依賴于已經(jīng)顯示的菜單欄的寬度來改變其值(0~1),同時我們還通過fraction來調(diào)整菜單欄的alpha 來改變它的明暗情況
B&R 我們可以看到我們的3D效果了
很顯然還有一點(diǎn)錯誤,我們的連接點(diǎn)好像出問題了,這是因為view默認(rèn)狀態(tài)的anchorPoint是view的中心
我們來修改anchorPoint使其在右側(cè)邊緣中心:
在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!