在iOS开发中,对于滑动侧边栏我们是又爱又恨。一方面,Apple并不鼓励这种设计,因此也没有提供相应的UIKit支持;另一方面,客户总是青睐这种设计。这就让夹在中间的我们非常尴尬,要么从头造轮子,要么利用第三方库。
译者:我个人的准则是,UI类轮子能自己造就自己造,一来UI类第三方库往往比较臃肿,有时会出现杀鸡用牛刀的情况,二来客户总会有各种各样奇怪而变态的UX/UI设计,多学点技能,防止出现补不上设计师脑洞的情况。至于网络类和排版类框架,我一般会使用现成的第三方库。有空就读读源码,提升码力;没空就读读README,直接上。经过长年Github洗礼的代码一般不会差。唯一要注意的是,每年Xcode更新季,要双手合十诚心祈祷第三方库不要挂,不然只能呵呵了。顺带一句,今年怕是在劫难逃了…
普通程序猴实现滑动侧边栏的方法是,把Container View嵌到一个Scroll View里。乍看之下各种机智,但仔细观之,有不少瑕疵:
- Scroll View的滑动手势会和主内容区的水平滑动手势冲突
- 侧边栏打开时主内容区是可以点击的,需要用一个额外的Modal视图遮住它
- 菜单的动画效果难以自定义
其实个人还是推荐遵循Apple的设计理念,用Tab View Controller作为替代,但如果你执意想用侧边栏的话,建议通过自定义View Controller Transition来实现。新来的同学不要怕,可以看看这篇《下拉手势关闭Modal视图》熟悉一下相关API。当然如果你时间宝贵,直接往下看也绝对没问题。
先来看看所谓的“阿姆斯特朗加速回旋喷气式滑动侧边栏”…啊不对…所谓的“可交互滑动侧边栏”的最终效果:
- 点按“Menu”展开侧边栏
- 点按主内容区域(蓝色)关闭侧边栏
- 从屏幕左侧向右滑动,侧边栏会随滑动展开
- 在主内容区向左滑动,侧边栏会随滑动关闭
- 交互手势要么成功要么成回滚,依滑动距离而定
入门
首先下载初始项目。当然了,现在它长得还不像侧边栏。只是一个蓝色的View Controller,它可以打开另一个绿色的Modal视图。通过角落里的按钮,打开或关闭Modal视图。
当前的代码结构如下:
- MainViewController.swift
- MenuViewController.swift
添加辅助文件
首先我们需要创建两个Swift文件:
- Interactor.swift
- MenuHelper.swift
创建Interactor
Interactor好比一只状态鸡(状态机),用于跟踪过渡动画的状态。关于这一点,在之前的《下拉手势关闭Modal视图》里有详细讲过。
创建一个名为Interactor.swift的源文件,并添加下面的代码:
importUIKit
classInteractor: UIPercentDrivenInteractiveTransition{
varhasStarted=false
varshouldFinish=false
Interactor类里有两个状态标志:
hasStarted:交互是否开始的标志shouldFinish:用于决定此次交互成功还是回滚
就这么简单!下一个文件稍微复杂些,且耐心阅读。
每当使用UIPercentDrivenInteractiveTransition的时候,必须实现下面两点:
我们在MenuHelper里实现这两个任务。
创建一个名为MenuHelper.swift的源文件,添加下面的代码:
importFoundation
importUIKit
enumDirection{
caseUp
caseDown
caseLeft
caseRight
structMenuHelper{
staticletmenuWidth:CGFloat=0.8
staticletpercentThreshold:CGFloat=0.3
staticletsnapshotNumber=12345
枚举型Direction包含四个方向,我们只会用到.Left和.Right,分别用于展开和关闭侧边栏。
结构体MenuHelper包含了以下属性:
menuWidth:定义了侧边栏的宽度,暂时硬编码成80%percentThreshold:引起侧边栏状态改变所需的滑动距离,设定为30%snapshotNumber:为后面要用到的快照视图设定的tag值
把下面的calculateProgress方法添加到结构体里:
staticfunccalculateProgress(translationInView:CGPoint,viewBounds:CGRect,direction:Direction)->CGFloat{
letpointOnAxis:CGFloat
letaxisLength:CGFloat
switchdirection{
case.Up,.Down:
pointOnAxis=translationInView.y
axisLength=viewBounds.height
case.Left,.Right:
pointOnAxis=translationInView.x
axisLength=viewBounds.width
letmovementOnAxis=pointOnAxis/axisLength
letpositiveMovementOnAxis:Float
letpositiveMovementOnAxisPercent:Float
switchdirection{
case.Right,.Down:// positive
positiveMovementOnAxis=fmaxf(Float(movementOnAxis),0.0)
positiveMovementOnAxisPercent=fminf(positiveMovementOnAxis,1.0)
returnCGFloat(positiveMovementOnAxisPercent)
case.Up,.Left:// negative
positiveMovementOnAxis=fminf(Float(movementOnAxis),0.0)
positiveMovementOnAxisPercent=fmaxf(positiveMovementOnAxis,-1.0)
returnCGFloat(-positiveMovementOnAxisPercent)
它接受三个参数:
translationInView: 用户点击处的坐标viewBounds:屏幕尺寸direction:侧边栏滑动方向
上面的方法计算了:沿某个特定方向的移动距离占该方向屏幕长度的百分比。举个例子,如果你传入的参数是.Right,那么它只关注沿x-轴正方向的移动。类似地,.Left只关注沿x-轴负方向的移动。
把下面的mapGestureStateToInteractor方法也添加到结构体里:
staticfuncmapGestureStateToInteractor(gestureState:UIGestureRecognizerState,progress:CGFloat,interactor:Interactor?,triggerSegue:()->Void){
guard letinteractor=interactorelse{return}
switchgestureState{
case.Began:
interactor.hasStarted=true
triggerSegue()
case.Changed:
interactor.shouldFinish=progress>percentThreshold
interactor.updateInteractiveTransition(progress)
case.Cancelled:
interactor.hasStarted=false
interactor.cancelInteractiveTransition()
case.Ended:
interactor.hasStarted=false
interactor.shouldFinish
?interactor.finishInteractiveTransition()
:interactor.cancelInteractiveTransition()
default:
break
它接受的参数如下:
gestureState:手势当前状态progress:滑动的距离(进度)interactor:作为状态机的UIPercentDrivenInteractiveTransition对象triggerSegue:触发视图过渡的闭包,包含类似performSegueWithIdentifier之类的方法
上面的方法把滑动手势的状态映射到了对应的Interactor方法上。
.Began:由于交互已经开始,我们把hasStarted设置为true。此外,还需调用triggerSegue()来触发过渡。
提示:尽管我们在
.Began处就调用了segue,不用担心过渡在这里就会直接进行完,在转变到别的手势状态之前,它走不到那么远。
.Changed:把用户滑动的进度传入updateInteractiveTransition()方法。如果用户滑过了50%的屏幕距离,过渡动画会处于正中间。.Cancelled:直接映射到cancelInteractiveTransition()方法.Ended:根据滑动距离,interactor会继续完成或回滚过渡动画。
目前为止的代码结构如下:
- MainViewController.swift(无修改)
- menuViewController.swift(无修改)
- Interactor.swift(新)
- MenuHelper.swift(新)
现在我们的Modal菜单还用着默认的向上滑出动画。我们想用自定义的动画覆盖掉它:让主内容区向右滑动,并展示出它底下的菜单栏。你可以想象从一摞牌中把第一张抽走的样子。
为此我们需要创建一个PresentMenuAnimator。
创建一个名为PresentMenuAnimator.swift的文件,并添加下面的代码:
importUIKit
classPresentMenuAnimator: NSObject{
extensionPresentMenuAnimator: UIViewControllerAnimatedTransitioning{
functransitionDuration(transitionContext:UIViewControllerContextTransitioning?)->NSTimeInterval{
return0.6
funcanimateTransition(transitionContext:UIViewControllerContextTransitioning){
guard
letfromVC=transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
lettoVC=transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
letcontainerView=transitionContext.containerView()
else{
return
// more code goes here
这是一个Animator的标准结构。
- NSObject:继承它以获得NSObjectProtocol的功能
- UIViewControllerAnimatedTransitioning:这个协议用于实现自定义View Controller Transition的动画
transitionDuration():定义动画时长animateTransition():添加自定义动画
animateTransition()允许你访问与过渡动画相关的两个View Controller。
- fromVC:我们的MainViewController(蓝色)
- toVC:我们的MenuViewController(绿色)
- containerView:把它想象成Window或主屏幕
动画开始时,containerView已经包含了fromVC,我们需要自己添加toVC以及可选的快照。
在animateTransition方法里添加下面的代码:
containerView.insertSubview(toVC.view,belowSubview:fromVC.view)
它负责把Menu插入到Main View Controller的下方。
还是在animateTransition方法里,继续添加下面的代码:
letsnapshot=fromVC.view.snapshotViewAfterScreenUpdates(false)
snapshot.tag=MenuHelper.snapshotNumber
snapshot.userInteractionEnabled=false
snapshot.layer.shadowOpacity=0.7
containerView.insertSubview(snapshot,aboveSubview:toVC.view)
fromVC.view.hidden=true
首先,我们创建了MainViewController的快照,原因有二:
- 快照是张图片,用户绝不会意外与它交互
- 由于fromVC在过渡动画后会自动删除,快照的存在会让你觉得它好像还在屏幕上一样
我们对快照进行了一些设定:
- tag:作为快照的句柄,方便后面删除快照用
- userInteractionEnabled:把它设置为
false,这样对它的手势操作会直接传到它的下一层。这一点在后面的步骤中格外有用。 - shadowOpacity:创造快照悬浮在绿色菜单之上的视觉效果
接着是视图的切换:把快照插到Menu上方,并隐藏MainViewController。
同样还是在animateTransition()里,继续添加下面的代码:
UIView.animateWithDuration(
transitionDuration(transitionContext),
animations:{
snapshot.center.x+=UIScreen.mainScreen().bounds.width*MenuHelper.menuWidth
completion:{_in
fromVC.view.hidden=false
transitionContext.completeTransition(!transitionContext.transitionWasCancelled())
- animateWithDuration:动画时长设置为0.6秒
- animations:快照向右移动80%的屏幕距离
- completion:我们把MainViewController的
hidden状态设置为false,以便下次使用。
在自定义动画正常运行之前,我们还需进行一些必要的连接。用下面的代码替换MainViewController.swift里面的内容:
importUIKit
classMainViewController: UIViewController{
@IBAction funcopenMenu(sender:AnyObject){
performSegueWithIdentifier("openMenu",sender:nil)
overridefuncprepareForSegue(segue:UIStoryboardSegue,sender:AnyObject?){
ifletdestinationViewController=segue.destinationViewControlleras?MenuViewController{
destinationViewController.transitioningDelegate=self
extensionMainViewController: UIViewControllerTransitioningDelegate{
funcanimationControllerForPresentedController(presented:UIViewController,presentingController presenting:UIViewController,sourceController source:UIViewController)->UIViewControllerAnimatedTransitioning?{
returnPresentMenuAnimator()
UIViewControllerTransitioningDelegate允许你用自定义动画覆盖默认动画。这里我们用PresentMenuAnimator提供过渡动画。
目前为止的代码结构:
- MainViewController.swift
- MenuViewController.swift(未修改)
- Interactor.swift(未修改)
- MenuHelper.swift(未修改)
- PresentMenuAnimator.swift(新)
编译运行。
点击Menu按钮后MainViewController会滑开并显示它下方的菜单。遗憾的是我们的Close按钮好像被遮住了。施主莫急,我们这就来修正这个问题。
修正Close按钮
Modal视图的Close按钮被快照挡住了。这没什么影响(我们正好可以利用这点),我们需要修改一下约束,让Close按钮占满快照所有可见部分。
由于之前我们把snapshot.userInteractionEnabled设置为了false,手势会直接传递到快照下一层,让你产生“快照可以点击”的错觉。
- 在Storyboard里,定位到MenuViewController(绿色)
- 点击Close按钮,打开右侧的Size Inspector
- 在Bottom Layout Guide里添加垂直约束
- 给父视图添加Equal Width约束
- 把Bottom Layout的垂直约束设置为0
- 把Equal Width约束的multiplier设置为0.2
- 点击Resolve Auto Layout Issues(一个炫酷的钛战机形的按钮),并选择Update Frames
- 删除Close按钮的标题
编译运行。现在我们可以通过点击快照关闭菜单了。
呃等等…这个关闭动画是什么鬼…别着急我们这就来调教调教它。
是时候编写自定义的关闭动画了,它和展开菜单的动画没有太大区别。
创建一个名为DismissMenuAnimator.swift的文件,并添加下面的代码:
importUIKit
classDismissMenuAnimator: NSObject{
extensionDismissMenuAnimator: UIViewControllerAnimatedTransitioning{
functransitionDuration(transitionContext:UIViewControllerContextTransitioning?)->NSTimeInterval{
return0.6
funcanimateTransition(transitionContext:UIViewControllerContextTransitioning){
guard
letfromVC=transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey),
lettoVC=transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey),
letcontainerView=transitionContext.containerView()
else{
return
// 1
letsnapshot=containerView.viewWithTag(MenuHelper.snapshotNumber)
UIView.animateWithDuration(
transitionDuration(transitionContext),
animations:{
// 2
snapshot?.frame=CGRect(origin:CGPoint.zero,size:UIScreen.mainScreen().bounds.size)
completion:{_in
letdidTransitionComplete=!transitionContext.transitionWasCancelled()
ifdidTransitionComplete{
// 3
containerView.insertSubview(toVC.view,aboveSubview:fromVC.view)
snapshot?.removeFromSuperview()
transitionContext.completeTransition(didTransitionComplete)
上面的代码和之前的PresentMenuAnimator非常相似。我们同样实现了UIViewControllerContextTransitioning协议的两个必需方法。
只不过这一次我们写的是关闭动画,因此toVC和fromVC和之前正好相反。
- fromVC:MenuViewController(绿色)
- toVC:MainViewController(蓝色)
和之前一样,动画始于ContainerView和fromVC。我们并没有删除快照,所以它还在之前的位置待着——向右移动80%屏幕宽度的位置。
- 注释1:第一步是获得指向快照的句柄。机智的我们之前已经给快照设定好了标签,所以这里可以直接通过
viewWithTag()方法获得它 - 注释2:动画负责把快照移回屏幕正中央
- 注释3:动画结束,用实际的Main View取代快照
把DismissMenuAnimator和对应的过渡代理方法连接,以便查看实际效果。
打开MainViewController.swift,往UIViewControllerTransitioningDelegate扩展里添加下面的方法:
funcanimationControllerForDismissedController(dismissed:UIViewController)->UIViewControllerAnimatedTransitioning?{
returnDismissMenuAnimator()
目前为止的代码结构:
- MainViewController.swift
- MenuViewController.swift(未修改)
- Interactor.swift(未修改)
- MenuHelper.swift(未修改)
- PresentMenuAnimator(未修改)
- DismissMenuAnimator.swift(新)
编译运行。调教完毕,在现在的动画里,主内容区应该能正常移回它的初始位置了。
上篇教程就到这里了,欲知如何用手势控制动画进度,且等下回更新。
原文链接:www.thorntech.com/2016/03/ios…
第一时间获取iOS优质中文教程,扫描下方的二维码订阅程序猴猴猴,或订阅我的博客:www.jiarui-blog.com。
交互式滑动侧边栏(上)











