交互式滑动侧边栏(上)

5,132 阅读10分钟

在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。当然如果你时间宝贵,直接往下看也绝对没问题。

先来看看所谓的“阿姆斯特朗加速回旋喷气式滑动侧边栏”…啊不对…所谓的“可交互滑动侧边栏”的最终效果:

slideoutFinal

  • 点按“Menu”展开侧边栏
  • 点按主内容区域(蓝色)关闭侧边栏
  • 从屏幕左侧向右滑动,侧边栏会随滑动展开
  • 在主内容区向左滑动,侧边栏会随滑动关闭
  • 交互手势要么成功要么成回滚,依滑动距离而定

入门

首先下载初始项目。当然了,现在它长得还不像侧边栏。只是一个蓝色的View Controller,它可以打开另一个绿色的Modal视图。通过角落里的按钮,打开或关闭Modal视图。

starterProject

当前的代码结构如下:

  • 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的下方。

addToVC

还是在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。

addSnapshot

同样还是在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,以便下次使用。

centerXshift

在自定义动画正常运行之前,我们还需进行一些必要的连接。用下面的代码替换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按钮好像被遮住了。施主莫急,我们这就来修正这个问题。

presentMenuAnimator

修正Close按钮

Modal视图的Close按钮被快照挡住了。这没什么影响(我们正好可以利用这点),我们需要修改一下约束,让Close按钮占满快照所有可见部分。

closeButtonResized

由于之前我们把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按钮的标题

closeButtonConstraints

编译运行。现在我们可以通过点击快照关闭菜单了。

tapToClose

呃等等…这个关闭动画是什么鬼…别着急我们这就来调教调教它。

是时候编写自定义的关闭动画了,它和展开菜单的动画没有太大区别。

创建一个名为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(新)

编译运行。调教完毕,在现在的动画里,主内容区应该能正常移回它的初始位置了。

closingAnimation

上篇教程就到这里了,欲知如何用手势控制动画进度,且等下回更新。

原文链接:www.thorntech.com/2016/03/ios…

第一时间获取iOS优质中文教程,扫描下方的二维码订阅程序猴猴猴,或订阅我的博客:www.jiarui-blog.com。

交互式滑动侧边栏(上)