原文:How to Create Your Own Slide-Out Navigation Panel in Swift
作者:Nicholas Sakaimbo
译者:kmyhy更新说明:本教程由 Nick Sakaimbo 更新为 iOS 11、Xcode 9 和 Swift 4。原文作者是 Tammy Coron。
本文介绍如何编写一个滑出式导航面板,这是用于替代 UINavigationController 和 UITabBarController 的一种主流做法,它允许用户将内容切入/切出屏幕。
滑出式导航面板这样的设计模式允许开发者在不占用宝贵屏幕空间的情况下为 app 增加一种固定的导航方式。用户可以在任何时候显示这个导航,同时不用隐藏当前显示内容。
在本文,你会用一种“越少越好”的方式将滑动式导航面板轻松地应用到你自己的 app 中。
开始
你将在一个漂亮的猫咪/狗狗图片浏览器中编写这个滑动式导航面板。首先,请下载这个开始项目。这是一个 zip 文件,请保存后解压缩。
然后打开项目,看一下项目结构。Assets 文件夹是一些 asset catalogs,全都是 app 中用于显示的猫狗图片。注意有 3 个主要的 view controller。如果你要将这个教程用到自己的项目中,请注意它们:
- ContainerViewController: 奇迹将在这里发生!它包含了一个左、中、右的视图,负责处理动画和滑动手势。在这个项目中,它是在 AppDelegate.swift 的application(_:didFinishLaunchingWithOptons:) 方法中被创建和添加到 window 的。
- CenterViewController: 中间面板。你可以用自己的 view controller(确保你复制了按钮的 action) 来替换它。
- SidePanelViewController: 左、右面板。也可以用你自己的 view controller 进行替换。
这 3 个 view controller 的视图在 Main.storyboard 中定义,请自行查看以便了解 app 的大致模样。
熟悉完项目的结构,来看看中间面板。
找到你的中心
在这节,你将以子控制器的形式将 CenterViewcontroller 放到 ContainerViewcontroller 中。
注意:这里使用了 iOS 5 中的视图控制器容器的概念。如果你不熟悉,请阅读 iOS 5 by Tutorials 第 22 章“UIViewController 容器”。
打开 ContainerViewController.swift。在文件底部,有一个扩展,用于 UIStoryboard。它添加了几个静态方法,用于简化从 storyboard 中加载某个 view controller 的过程。你等会会用到这些方法。
为 ContainerViewController 添加几个属性,用于保存一个 CenterVierController 和一个 UINavigationController 对象:
var centerNavigationController: UINavigationController! var centerViewController: CenterViewController!
注意:这里使用了隐式解包(注意 !的使用)。它们肯定是可空的,因为当 init() 方法调用时它们还不会被初始化,但它们会进行自动解包,因为当它们被创建时你能够确保它们总是会有值的。
然后,在 viewDidLoad() 的 super 调用之后添加:
centerViewController = UIStoryboard.centerViewController()
centerViewController.delegate = self
// wrap the centerViewController in a navigation controller,so we can push views to it
// and display bar button items in the navigation bar
centerNavigationController = UINavigationController(rootViewController: centerViewController)
view.addSubview(centerNavigationController.view)
addChildViewController(centerNavigationController)
centerNavigationController.didMove(toParentViewController: self)
上述代码创建了一个新的 CenterViewController 并将它保存到 centerViewController 属性。然后创建了一个 UINavigationController 用于包含这个 center view controller。然后将 navigation controller 的 view 添加到 ContainerViewController 的 view,并调用 addChildViewController(_:) 和 didMove(toParentViewController:) 方法建立二者的父子关系。
同时也将当前 view controller 设置为 center view controller 的委托。 center view controller 会问当前 view controller 何时显示和隐藏左右面板。
如果现在编译,你会看到在设置 delegate 一句处报错。你必须修改这个类,让它实现 CenterViewControllerDelegate 协议。添加一个扩展来实现这个协议。在 UIStoryboard 扩展后添加(有一些空方法,我们后面实现它们):
// MARK: CenterViewController delegate
extension ContainerViewController: CenterViewControllerDelegate {
func toggleLeftPanel() {
}
func toggleRightPanel() {
}
func addLeftPanelViewController() {
}
func addRightPanelViewController() {
}
func animateLeftPanel(shouldExpand: Bool) {
}
func animateRightPanel(shouldExpand: Bool) {
}
}
来检验一下成果。Build & run。如果一切正常,你会看到:
顶部按钮最终会打开猫咪的图片或者狗狗的图片。那为什么还要创建一个滑出式导航面板呢?仅仅是为了看起来好看,你必须实现滑动式。首先,从左边开始!
猫咪们到左边来…
你创建了你的中间面板,但添加左边的 View controller 是不同的一个步骤。这个过程稍有点多,请务必耐心。多想想猫咪们吧!
要展开左侧菜单,用户需要点击 Kitties 按钮。因此请打开 CenterViewController.swift。
为了将精力集中在重要的事情上,IBAction 和 IBOutlet 在 storyboard 已经是建好的了。但是,如果要实现你自己的滑出式导航面板,你必须理解这些按钮是如何被设置的。
注意已经有两个 IBAction 方法了,一个方法针对一个按钮。找到 kittiesTapped(_:) 方法,添加代码:
delegate?.toggleLeftPanel?()
前面提过,这个方法连接到了 Kitties 按钮。
用一个可空链使得只有在 delegate 不为空且 toggleLeftPanel 方法已经实现的情况下才会调用 toggleLeftPanel 方法。
你可以在 CenterViewControllerDelegate.swift 中看一下委托协议的定义。你会看到,有两个 optional 方法 toggleLeftPanel() 和 toggleRightPanel()。还记得吧,在你创建 center view controller 实例时,你将它的 delegate 设置为 container view controller。接下来我们就实现 toggleLeftPanel()。
打开 ContainerViewController.swift。首先,声明一个枚举。在类名之下添加:
class ContainerViewController: UIViewController {
enum SlideOutState {
case bothCollapsed
case leftPanelExpanded
case rightPanelExpanded
}
// ...
这个枚举用于保存侧面板的当前状态,你可以用它们表示任何一个面板都不可见,或者左右面板中有一个可见。
然后,在 centerViewController 属性下添加两个属性:
var currentState: SlideOutState = .bothCollapsed var leftViewController: SidePanelViewController?
分别用于保存当前状态,以及左侧的 view controller:
一开始的初始状态默认为 .bothCollapsed——也就是说两个侧面板都不可见。leftViewController 属性是一个可空类型,因为你会在不同的时候添加和移除这个 view controller,因此它有可能有时候是没有值的。
接着,实现 toggleLeftPanel() 委托方法:
let notAlreadyExpanded = (currentState != .leftPanelExpanded)
if notAlreadyExpanded { addLeftPanelViewController() }
animateLeftPanel(shouldExpand: notAlreadyExpanded)
首先,这个方法会检查左面板是否已经展开。如果它未显示,就将面板添加到视图树中,并将其动画到’打开’的位置。如果这个面板已经显示,它则将它动画到’关闭’位置。
然后,用下面的代码将左面板添加到视图树中。找到 addLeftPanelViewController() 方法,在里面添加代码:
guard leftViewController == nil else { return }
if let vc = UIStoryboard.leftViewController() {
vc.animals = Animal.allCats()
addChildSidePanelController(vc)
leftViewController = vc
}
这段代码首先检查 leftViewController 属性是否为 nil。如果是,创建一个新的 SidePanelViewController,然后设置它的 animals 数组,也就是要显示的数据——猫咪们!
然后在 addLeftPanelViewController() 下面添加 addChildSidePanelController() 方法:
func addChildSidePanelController(_ sidePanelController: SidePanelViewController) {
view.insertSubview(sidePanelController.view,at: 0)
addChildViewController(sidePanelController)
sidePanelController.didMove(toParentViewController: self)
}
这个方法用于向 container view controller 中添加子 view。这个过程和前面添加 cener view controller 是一样的。首先插入它的 view(这里将插入的 z 位置设置为 0,这样它就会位于 center view controller 的下面),然后添加子控制器。
基本上可以运行项目了,但还有一个事情要做:添加动画!它不需要多少时间!
说好的滑滑滑滑动呢?
首先,在 ContainerViewController 中添加一个常量:
let centerPanelExpandedOffset: CGFloat = 60
这是center view controller 滑开后还有多少像素宽度可见,设置为 60。
接着,找到 animateLeftPanel(shouldExpand:) 方法,添加代码:
if shouldExpand {
currentState = .leftPanelExpanded
animateCenterPanelXPosition(
targetPosition: centerNavigationController.view.frame.width - centerPanelExpandedOffset)
} else {
animateCenterPanelXPosition(targetPosition: 0) { finished in
self.currentState = .bothCollapsed
self.leftViewController?.view.removeFromSuperview()
self.leftViewController = nil
}
}
这个方法简单地判断侧边面板是要展开还是隐藏。如果是展开,设置 currentState 属性为展开,让中间面板移动以成为“打开”状态。相反,它会让中间面板动画到“关闭”位置,并移除视图,将当前状态置为关闭状态。
最后,在 animatedLeftPanel(shouldExpand:) 方法后面添加animateCenterPanelXPosition(targetPosition:completion:) 方法:
func animateCenterPanelXPosition(targetPosition: CGFloat,completion: ((Bool) -> Void)? = nil) {
UIView.animate(withDuration: 0.5,delay: 0,usingSpringWithDamping: 0.8,initialSpringVelocity: 0,options: .curveEaseInOut,animations: {
self.centerNavigationController.view.frame.origin.x = targetPosition
},completion: completion)
}
这是真正放动画代码的地方。中间控制器的 view 被动画到指定位置,带有弹簧动画效果。这个方法也带一个可控的完成闭包,用于传递给 UIView 动画的 animate 方法。如果你想修改动画的效果,可以调整动画时长和弹簧动画的阻尼系数。
好了……是时候来试试看了,可以 Build & run 一下了。动手吧!
运行 app 时,点击 Kitties 按钮。中间控制器会滑动——刷!——然后露出底下的 Kitties 菜单。噢,看起来好棒。
太可爱了,真是受不了!再次点击 Kitties 按钮,又可以隐藏菜单。
我和影子
当左面板打开时,它正好在中间视图控制器上面。如果让两者之间有一个明显区分将是个不错的事情。可以加一个阴影吗?
若日后是 ContainerViewController,添加方法:
func showShadowForCenterViewController(_ shouldShowShadow: Bool) {
if shouldShowShadow {
centerNavigationController.view.layer.shadowOpacity = 0.8
} else {
centerNavigationController.view.layer.shadowOpacity = 0.0
}
}
通过修改导航控制器的 shadowOpacity 属性来显示和显示阴影。你可以用 currentState 属性的 didSet 属性观察器来添加、删除这个阴影。
扎到 ContentViewController 的 currentState 定义,将它修改为:
var currentState: SlideOutState = .bothCollapsed {
didSet {
let shouldShowShadow = currentState != .bothCollapsed
showShadowForCenterViewController(shouldShowShadow)
}
}
在 didSet 闭包中,当属性值被改变时,调用上个方法。无论哪一个面板被打开,都显示它们的阴影。
Build & run。当你点击 Kitties 按钮,注意可爱的阴影!这样效果更好,不是吗?
接下来,实现同样的功能,但这次是针对右边栏,也就是……狗狗们!
狗狗们到右边来……
添加右面板视图控制器的方法,和添加左面板的视图控制器一模一样。
打开 ContainerViewController.swift,在 leftViewController 下增加一个属性:
var rightViewController: SidePanelViewController?
然后找到 toggleRightPanel() 方法,添加代码:
let notAlreadyExpanded = (currentState != .rightPanelExpanded)
if notAlreadyExpanded { addRightPanelViewController() }
animateRightPanel(shouldExpand: notAlreadyExpanded)
接着,将 addRightPanelViewController() 和 animateRightPanel(shouldExpand:) 方法修改为:
func addRightPanelViewController() {
guard rightViewController == nil else { return }
if let vc = UIStoryboard.rightViewController() {
vc.animals = Animal.allDogs()
addChildSidePanelController(vc)
rightViewController = vc
}
}
func animateRightPanel(shouldExpand: Bool) {
if shouldExpand {
currentState = .rightPanelExpanded
animateCenterPanelXPosition(
targetPosition: -centerNavigationController.view.frame.width + centerPanelExpandedOffset)
} else {
animateCenterPanelXPosition(targetPosition: 0) { _ in
self.currentState = .bothCollapsed
self.rightViewController?.view.removeFromSuperview()
self.rightViewController = nil
}
}
}
这些代码基本上和之前左面板实现的方法一样,只不过方法名和属性名以及动画方向不同。如果你对此有疑问,请参考上一节的解释。
和之前一样,IBActions 和 IBOultets 已经建立了连接。和 Kitties 按钮一样,Puppies 按钮连接到 IBAction 方法 puppiesTapped(_:)。这个按钮能够将中间的面板滑开显示出右边的面板。
最后,回到 CenterViewController.swift 在 puppiesTapped(_:) 中添加代码:
delegate?.toggleRightPanel?()
这和 kittiesTapped(_:) 还是一样,只不过 toggleLeftPanel 变成了 toggleRightPanel。
来看一下狗狗们吧!
Build & run,点击 Puppies 按钮,你会看到:
开起来不错,是吧?记住不要让你自己在这些可爱的狗狗面前呆得太长哦,现在再次点击按钮关闭它。
你现在既可以查看猫咪也可以查看狗狗了,但能够查看它们的大图岂不更爽?:]
选一个宠物吧
狗狗和猫咪们分别显示在右面板和左面板中,这是两个 SidePanelViewController 实例,实际上就只包含了 table view 而已。
回到 SidePanelViewControllerDelegate.swift 看一眼 SidePanelViewController 的委托方法。当某个宠物被选中时,委托对象的这个方法会被调用。我们可以利用它!
在 SidePanelViewController.swift 中,在 tableView 属性下添加一个可空的 delegate 属性:
var delegate: SidePanelViewControllerDelegate?
然后在 UITableViewDelegate 扩展的 tableView(_:didSelectRowAt:) 方法中:
func tableView(_ tableView: UITableView,didSelectRowAt indexPath: IndexPath) {
let animal = animals[indexPath.row]
delegate?.didSelectAnimal(animal)
}
如果 delegate 不为空,就通知 delegate 某个宠物被选中了。但是现在委托对象都还没有呢!可以用 CenterViewController 作为 side panel 的委托,因为可以用来显示所选宠物的图片和标题。
打开 CenterViewController.swift 实现委托协议。添加一个扩展:
extension CenterViewController: SidePanelViewControllerDelegate {
func didSelectAnimal(_ animal: Animal) {
imageView.image = animal.image
titleLabel.text = animal.title
creatorLabel.text = animal.creator
delegate?.collapseSidePanels?()
}
}
这个方法简单地在中间视图控制器中渲染了图片和标签。然后,如果中间视图控制器也有一个委托的话,就告诉它收起 side panel,以便你能够看到所选的宠物。
collapseSidePanels()还没实现。打开 ContainerViewController.swift 在 toggleRightPanel() 下加入:
func collapseSidePanels() {
switch currentState {
case .rightPanelExpanded:
toggleRightPanel()
case .leftPanelExpanded:
toggleLeftPanel()
default:
break
}
}
该 switch 语句判断当前状态,如果发现有打开着的 side panel 就关闭它。
最后,将 addChildSidePanelViewController(_:) 修改为:
func addChildSidePanelController(_ sidePanelController: SidePanelViewController) {
sidePanelController.delegate = centerViewController
view.insertSubview(sidePanelController.view,at: 0)
addChildViewController(sidePanelController)
sidePanelController.didMove(toParentViewController: self)
}
除了之前的代码,还将 center view controller 设置为 side panel 的 delegate。
就是这样了!Build & run。查看 Kitties 或者 Puppies,点击其中的某个萌宠。side panel 会收起,你将看到这只宠物的详情。
左右切换
除了导航栏按钮之外,有许多 app 都支持以“滑动”的方式打开 side panel。添加一个手势识别器是很简单的。别怕,你会搞定它!
打开 ContainerViewController.swift 找到 viewDidLoad()。添加:
let panGestureRecognizer = UIPanGestureRecognizer(target: self,action: #selector(handlePanGesture(_:)))
centerNavigationController.view.addGestureRecognizer(panGestureRecognizer)
这段代码声明了一个 UIPanGestureRecognizer 并指定 handlePanGesture(_:) 为它的平移手势处理器。(等会你再来实现这个方法)
默认,平移手势识别器会监听单指触摸,因此不需要做其它设置了。你只需要将它添加到 centerNavigationController 的 view 中就可以了。
注意:关于 iOS 中的手势识别器的更多内容,可以阅读我们的 UIGestureRecognizer Swift 教程。
在文件底部,UIStoryboard 扩展之上增加一个扩展,让这个类实现 UIGestureRecognizerDelegate 协议。
// MARK: Gesture recognizer
extension ContainerViewController: UIGestureRecognizerDelegate {
@objc func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
}
}
我说很简单吧!只剩一个步骤就完成了滑动切换面板的功能了。
让视图动起来!
当手势识别器检测到一个手势时,会调用 handlePanGesture(_:) 方法。所以本教程的最后一个任务就是实现这个方法。
Now Move That View!
let gestureIsDraggingFromLeftToRight = (recognizer.velocity(in: view).x > 0)
switch recognizer.state {
case .began:
if currentState == .bothCollapsed {
if gestureIsDraggingFromLeftToRight {
addLeftPanelViewController()
} else {
addRightPanelViewController()
}
showShadowForCenterViewController(true)
}
case .changed:
if let rview = recognizer.view {
rview.center.x = rview.center.x + recognizer.translation(in: view).x
recognizer.setTranslation(CGPoint.zero,in: view)
}
case .ended:
if let _ = leftViewController,let rview = recognizer.view {
// animate the side panel open or closed based on whether the view
// has moved more or less than halfway
let hasMovedGreaterThanHalfway = rview.center.x > view.bounds.size.width
animateLeftPanel(shouldExpand: hasMovedGreaterThanHalfway)
} else if let _ = rightViewController,let rview = recognizer.view {
let hasMovedGreaterThanHalfway = rview.center.x < 0
animateRightPanel(shouldExpand: hasMovedGreaterThanHalfway)
}
default:
break
}
平移手势可以检测任意方向的滑动手势,但我们只对水平方向感兴趣。首先,用一个布尔变量 gestureIsDraggingFromLeftToRight 检查这个手势 x 方向上的速度。
我们需要监听这 3 个状态:UIGestureRecognizerState.began、UIGestureRecognizerState.changed 和UIGestureRecognizerState.ended:
- .began: 当用户开始移动手指,同时两个面板都不可见,则根据移动的方向显示对应的面板和阴影。
- .changed: 当用户正在平移时,让中心视图跟随用户的手指一起移动。
- .ended: 当用户平移结束,判断左右控制器是否可见。根据是左还是右以及平移手势划过的距离,执行这个动画。
你可以在中视图上滑动,打开/隐藏左右视图,组合运用平移手势的这三种状态:位置、速度、方向。
例如,如果手势的方向是向右移动,会显示左侧面板。如果方向是向左的,显示右侧面板。
Build& run。你可以在中视图上左右滑动,打开隐藏在下面的左右面板。如果一切顺当……那就好了。
接下来做什么?
恭喜!看完本教程,你就是一个滑出式导航面板的专家了!
希望你喜欢本教程。请在这里下载完成后的项目。我肯定你会喜欢上这些猫咪和狗狗的。
如果你想尝试一些现成的库而不是自己动手,请查看 SideMenu。要深入探讨这个 UI 控件的源代码(或者来一次记忆中的旅行),请看 iOS 开发者和设计者 Ken Yarmosh 的文章“iOS 的新设计模式:滑出式导航”。他对这种模式的好处进行了很好的阐述,并演示了在真实环境下的常见用法。
请在论坛中发表你对滑出式导航的看法。