如何创建一个3D侧边栏动画

原攻略来自于:How To Create a Cool 3D Sidebar Animation Like in Taasky

本篇文章为我自己的总结、翻译版,你可以点击上面的链接找到原文(如果你不是简书看到的,可以点击这里查看我的原文)

在今天的文章中,由于我的英文能力有限,就不会对原文进行逐句翻译了,我会以自己的理解来讲解这篇文章的精髓,教你一步步来实现最终的效果

另外由于我个人的能力有限,可能有些地方出现遗漏或者理解出错,欢迎指正下,共同学习。
最终效果:

最终效果
最终效果

由于之前作者是用swift老版本的初始工程来讲解的,你可以来这里下载swift版本转换好的源文件开始今天的学习
(ps:接下来的工程我都用swift3.0实现了一遍,不过仍可能出现粘贴的是老代码未及时更新的问题,不过swift版本的问题也不难。)

下载好后,Taasky_origin就是我们要开始的初始文件了。

点击Run运行,你可以看到如下效果:


首页、详情页
首页、详情页

看起来像是一个工作之余和朋友喝喝咖啡之类的App

我们现在要做如下的事情

  • 首先你要创建一个ScrollView来包含左侧的侧边栏按钮部分和右边的详情部分
  • 然后添加一个按钮来控制侧边栏的显示和隐藏
  • 接着实现一个3D 效果来完成平滑的折叠效果
  • 最后当你触摸的时候,我们顺带添加一个菜单旋转的动画,和我们显示、隐藏侧边栏同步起来
我们来分析一下
具体来看
具体来看
  • 当菜单显示的时候我们实际看到的是紫色部分的区域
  • 当菜单隐藏的时候我们看到的是绿色的区域

接下来我们会用到ContainerView ,并通过自动布局添加约束的方式来完成我们最终的布局,最终我们要让storyboard出现下面的画面,提前来预览下吧:


最终我们将会看到的storyboard
最终我们将会看到的storyboard

不要着急,我们接下来会一步步实现的。

给UIScrollView添加约束
  • 接下来我们需要做的是:创建一个新的控制器来协调菜单控制器和详情控制器,在这个新的控制器中我们添加一个UIScrollView,然后给ScrollView添加 两个container views ,分别嵌入菜单控制器和详情控制器。

  • 创建一个新的ContainerViewController继承自UIViewController,语言选择Swift

  • 在Main.storyboard拖拽一个新的UIViewController,在Inspector中修改下之前Class\Custom Class 为 ContainerViewController



    把背景改为黑色:


  • 到了添加ScrollView的时候了,我们拖拽一个ScrollView,并把大小拉至填充整个屏幕,在右侧属性检测器中去掉显示指示条,并且把Delays Content Touches去掉勾选,这样当你选中的时候就不会有那么一点延迟了



    设置ScrollView 的delegate为当前控制器



    给ScrollView添加四个约束,记得去掉勾选Constrain to margins(不要在意下图中的宽高值的问题):

    添加的约束是:

  • Trailing Space to: Superview

  • Leading Space to: Superview

  • Top Space to: Superview

  • Bottom Space to: Bottom Layout Guide


    添加完后的样子
    添加完后的样子
  • 该给ScrollView添加内容了,拖拽一个UIView给ScrollView,让它的大小和ScrollView的大小一样,然后再通过右侧属性检测器给这个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(😁,好吧在上面一步你完全可以直接设置好的,多做一步只是为了让新手熟悉一下...)



    设置好后,你会发现有布局的警告,这是因为对于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了, 接下来我们要修改一下只让它显示整个侧边菜单和隐藏侧边菜单, 并且让它不能滑出边界

  1. 在ScrollView的属性检测器中选择Scrolling\Paging Enabled
  2. 不要选择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!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,943评论 4 60
  • 原文地址在这里.原文 去年,读者们投票选出了Top5的iOS7最佳动画,当然也很想看到有关这些动画如何实现的教程。...
    bu再等阅读 658评论 0 0
  • 一 慌慌张张地考完普通话,在预备火锅面前早已被抛到九霄云外。2016年冬天的第一场火锅,...
    碰我就炸气阅读 235评论 0 0
  • 有一天,给丹妞讲《狐狸和乌鸦》的故事,末了,我跟她说:“可怜的小乌鸦,轻信了狐狸的话,害得自己要挨饿了。”丹妞回我...
    刘忙不盲阅读 3,911评论 0 1