上一篇:iOS界面开发—开篇
当前篇:iOS界面开发—导航控制器和标签控制器
下一篇:iOS界面开发—定制导航栏标题
导航控制器
一个应用基本不可能是单一界面,我们需要跳转到下一个界面,返回上一个界面,因此我们需要用到UINavigationController,首先继承一个导航控制方便自定义:
class MyNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.isTranslucent = true
}
}
然后打开AppDelegate.swift文件修改代码如下:
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
window = UIWindow.init(frame: UIScreen.main.bounds)
window!.backgroundColor = UIColor.white
let vc = ViewController()
let nc = MyNavigationController.init(rootViewController: vc)
window!.rootViewController = nc
window!.makeKeyAndVisible()
return true
}
运行应用,同样看到的是刚刚那个界面,只不过这个界面现在是在导航控制器中呈现,现在我们把Hello World放在界面顶部看看会出现什么情况:
class ViewController: UIViewController {
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.centerX = view.width / 2
label.top = 0
}
}
如果你照着我的代码写,运行应用后发现Hello World不见了,这是因为在MyNavigationController中设置了导航栏为半透明状态,在导航栏半透明状态下,视图控制器的view默认会沉浸到屏幕顶部,于是最上面的部分被导航栏遮挡了,这里提供两种解决办法:
一种是在ViewController中修改edgesForExtendedLayout属性
class ViewController: UIViewController {
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
edgesForExtendedLayout = .bottom
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.centerX = view.width / 2
label.top = 0
}
}
这种方法有个好处就是,ViewController各自控制各自的布局,如果导航控制器中其他的视图控制器需要沉浸到顶部,很容易控制,修改自己的edgesForExtendedLayout属性就行了
第二种方法就是关闭导航栏的半透明状态:navigationBar.isTranslucent = false。这样就把该导航控制器下所有的视图控制器的沉浸状态禁止了,很多应用不需要沉浸状态,所以这个方法也很好用。
pushViewController
下面我们让导航控制器无限push同一个视图控制器:
class ViewController: UIViewController {
let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onClickBackground))
view.addGestureRecognizer(tap)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.centerX = view.width / 2
label.top = 0
}
@objc private func onClickBackground() {
navigationController?.pushViewController(ViewController(), animated: true)
}
}
现在点击界面任何地方,都会弹出一个新的界面,点击返回后返回到上一个界面,这就是导航控制器的作用。
返回手势
在默认的情况下,导航控制器支持左侧边缘手势返回,很多应用会支持全屏手势返回,我们在UINavigationController的头文件中能看到interactivePopGestureRecognizer这样的实例,这就是控制手势返回的手势控制器,如果需要关闭手势,可以将其失效:
class MyNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.isTranslucent = false
interactivePopGestureRecognizer?.isEnabled = false
}
}
不过一般不会这样做,很多时候我们需要的是一个全屏的返回手势,自己去实现貌似有点麻烦,最简单的办法,就是创建一个UIPanGestureRecognizer,将其target指向interactivePopGestureRecognizer同一个target,我们可以通过runtime获取到相关信息,下面直接给出代码:
class MyNavigationController: UINavigationController {
var fullScreenPopGestureRecorgnizer: UIPanGestureRecognizer?
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.isTranslucent = false
addFullScreenPopGesture()
}
private func addFullScreenPopGesture() {
let target = interactivePopGestureRecognizer?.delegate
fullScreenPopGestureRecorgnizer = UIPanGestureRecognizer.init(target: target,
action: Selector.init(("handleNavigationTransition:")))
view.addGestureRecognizer(fullScreenPopGestureRecorgnizer!)
}
}
当然我们也可以用扩展来实现,而不用继承,只是用继承代码更简洁明了,光是这样做还不够,会有bug,比如导航控制器只有一个视图控制器时会出现异常,为了解决这些问题,同时提供更高的灵活性,我们可以设置一个开关,首先为UIViewController扩展一个属性(默认开启全屏手势返回)
extension UIViewController {
@objc var isFullScreenInteractivePopAllowed: Bool {
return true
}
}
然后设置手势代理,精确控制手势是否触发
class MyNavigationController: UINavigationController, UIGestureRecognizerDelegate {
var fullScreenPopGestureRecorgnizer: UIPanGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.isTranslucent = false
addFullScreenPopGesture()
}
private func addFullScreenPopGesture() {
let target = interactivePopGestureRecognizer?.delegate
fullScreenPopGestureRecorgnizer = UIPanGestureRecognizer.init(target: target,
action: Selector.init(("handleNavigationTransition:")))
view.addGestureRecognizer(fullScreenPopGestureRecorgnizer)
fullScreenPopGestureRecorgnizer.delegate = self
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == fullScreenPopGestureRecorgnizer {
if viewControllers.count <= 1 {
return false
}
let velocity = fullScreenPopGestureRecorgnizer.velocity(in: view)
if velocity.x <= 0 {
return false
}
if let topVC = topViewController {
return topVC.isFullScreenInteractivePopAllowed
}
}
return true
}
}
这样我们的全屏手势返回就完成了,视图控制器可以根据自己的需要重写isFullScreenInteractivePopAllowed属性来控制是否进行全屏手势返回。
对于isFullScreenInteractivePopAllowed扩展属性,我们可以用更灵活的方式来处理,原因在于扩展属性如果需要自定义,必须继承然后重写,如果我们需要使用第三方库中的视图控制器,但是又想关闭它的全屏手势返回,这样就没法实现了,我们可以使用runtime把isFullScreenInteractivePopAllowed修改成存储属性:
extension UIViewController {
private static var allowFullScreenInteractivePopKey: UInt = 0
var isFullScreenInteractivePopAllowed: Bool {
get {
if let allowed = objc_getAssociatedObject(self, &UIViewController.allowFullScreenInteractivePopKey) as? Bool {
return allowed
} else {
return true
}
}
set {
objc_setAssociatedObject(self, &UIViewController.allowFullScreenInteractivePopKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
这样我们就可以随意更改isFullScreenInteractivePopAllowed属性来控制任意一个ViewController是否允许全屏返回了。
导航栏
我们可以修改导航栏的背景色和标题颜色,先对UIColor进行扩展
extension UIColor {
convenience init(R: CGFloat, G: CGFloat, B: CGFloat, A: CGFloat = 1) {
self.init(red: R / 255, green: G / 255, blue: B / 255, alpha: A)
}
open class var weChat: UIColor {
return UIColor.init(R: 29, G: 29, B: 29)
}
}
然后把导航栏的背景色修改成微信的背景色并显示标题
extension UINavigationBar {
func myInit() {
isTranslucent = false
tintColor = UIColor.white
barTintColor = UIColor.weChat
titleTextAttributes = [.foregroundColor : UIColor.white]
}
}
class MyNavigationController: UINavigationController, UIGestureRecognizerDelegate {
var fullScreenPopGestureRecorgnizer: UIPanGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.myInit()
addFullScreenPopGesture()
}
private func addFullScreenPopGesture() {
let target = interactivePopGestureRecognizer?.delegate
fullScreenPopGestureRecorgnizer = UIPanGestureRecognizer.init(target: target,
action: Selector.init(("handleNavigationTransition:")))
view.addGestureRecognizer(fullScreenPopGestureRecorgnizer)
fullScreenPopGestureRecorgnizer.delegate = self
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == fullScreenPopGestureRecorgnizer {
if viewControllers.count <= 1 {
return false
}
let velocity = fullScreenPopGestureRecorgnizer.velocity(in: view)
if velocity.x <= 0 {
return false
}
if let topVC = topViewController {
return topVC.isFullScreenInteractivePopAllowed
}
}
return true
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
}
然后在ViewController中设置当前视图控制器标题
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Hello World"
view.backgroundColor = UIColor.white
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onClickBackground))
view.addGestureRecognizer(tap)
}
运行应用,现在每一个视图控制器上都有一个标题,并且返回按钮上不再显示Back,而是显示上一层视图控制器的标题,这是默认的行为,如果我们需要自定义返回按钮的标题,可以通过修改navigationItem.backBarButtonItem来实现(不要直接去获取navigationItem.backBarButtonItem,因为如果我们不自定义,该值是空的)
extension UINavigationItem {
/** 自定义返回按钮标题*/
var backBarTitle: String? {
get {
return backBarButtonItem?.title
}
set {
if let backBar = backBarButtonItem {
backBar.title = newValue
} else {
let backBar = UIBarButtonItem()
backBar.title = newValue
backBarButtonItem = backBar
}
}
}
func setDefaultBackBarTitle() {
backBarTitle = "返回"
}
}
一般自定义返回按钮的标题就够了,返回按钮的样式也是可以自定义的,方法一样,也是创建一个自定义的UIBarButtonItem然后赋值给navigationItem.backBarButtonItem,这里就不一一举例了。
导航栏右侧按钮
我们可以在导航栏右上方添加一个或者多个菜单按钮,在ViewController的viewDidLoad方法中添加代码:
let moreActionsItem = UIBarButtonItem.init(title: "更多", style: .plain, target: nil, action: nil)
navigationItem.rightBarButtonItem = moreActionsItem
导航栏右上方出现了一个按钮,按钮的行为这里就不写了,为了方便我用文字的样式,一般我们都用图片,详情见UIBarButtonItem的初始化方法,下面我们放两个按钮:
let shareItem = UIBarButtonItem.init(title: "分享", style: .plain, target: nil, action: nil)
let moreActionsItem = UIBarButtonItem.init(title: "更多", style: .plain, target: nil, action: nil)
navigationItem.rightBarButtonItems = [shareItem, moreActionsItem]
定制导航栏标题
我们目前只做到了给导航栏设置文本标题,也就是navigationItem.title,有时候无法满足我们的需求,比如微信的导航栏标题,不仅有文字,文字前面有转圈的菊花,后面还有一些状态图标标识着免打扰以及听筒播放语音消息等等,同时文字之后还有附加的不允许被省略号代替的群成员数量,因此我们需要定制这样的视图,并让其自适应布局,我们需要用到navigationItem.titleView,这篇文章我们只讨论定制导航栏标题的外层布局规则,在下一篇我们具体实现上述需求。
自定义的navigationItem.titleView的布局一直是一件头疼的事,因为它的布局由navigationBar控制,我们自己无法完全掌控,而且在不同的系统版本中,navigationBar的内部实现机制不一样,要实现一个简单的自定义视图很简单,一旦要通用起来,就会发现当内容长度变化时,或者横竖屏切换时,或者导航栏有左右菜单栏时,很难做到自适应居中布局,下面我们一步一步来实现。
首先我们封装一个标题栏视图类,暂时我们不进行定制,只将其背景色调成红色以便观察其在导航栏中的布局表现:
首先我们封装一个导航栏标题容器视图类,暂时我们不进行定制,只将背景色设置成红色来观察导航栏标题在导航栏中的布局表现:
class NavigationTitleContainerView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
}
}
然后在ViewController中使用该视图作为导航栏标题视图:
class ViewController: UIViewController {
let label = UILabel()
let titleView = NavigationTitleContainerView()
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.titleView = titleView
titleView.size = CGSize.init(width: 200, height: 44)
titleView.backgroundColor = UIColor.red
navigationItem.setDefaultBackBarTitle()
view.backgroundColor = UIColor.white
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
let shareItem = UIBarButtonItem.init(title: "分享", style: .plain, target: nil, action: nil)
let moreActionsItem = UIBarButtonItem.init(title: "更多", style: .plain, target: nil, action: nil)
navigationItem.rightBarButtonItems = [shareItem, moreActionsItem]
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onClickBackground))
view.addGestureRecognizer(tap)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.centerX = view.width / 2
label.top = 0
}
@objc private func onClickBackground() {
navigationController?.pushViewController(ViewController(), animated: true)
}
}
这里我们重点关注titleView.size,可以自行调整尺寸观察它的布局变化,下面阐述其布局规则:
- 如果titleView的尺寸为零,导航栏不显示titleView
- titleView垂直居中显示
- titleView水平尽量居中,这取决于返回按钮以及菜单栏占用了多少地方,如果titleView的宽度超出了剩余的宽度,将自动调整为剩余宽度
- titleView宽度最大不会超过初始设置的宽度,所以为了适配横屏,尽量让初始frame的宽度超过横屏的宽度
也就是说,虽然我们可以指定titleView的初始尺寸,但是导航栏会自行根据以上规则调整其尺寸,我们可以在titleView的layoutSubviews方法中观察其尺寸变化以及确定其最终尺寸,同时在该方法中调整子视图的布局,这样不管返回按钮有多长,菜单栏占多宽,都可以自适应布局。
定制导航栏标题视图的方式就是,以NavigationTitleContainerView作为容器,向其中添加子视图,并在layoutSubviews方法中对子视图进行布局,使子视图的宽度不得超过容器的宽度,同时让子视图尽量靠近屏幕中央,下面我们用黄色的区域来表示子视图,并通过代码控制其尽量居中:
class NavigationTitleContainerView: UIView {
let titleView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
titleView.size = CGSize.init(width: 200, height: 44)
titleView.backgroundColor = UIColor.yellow
addSubview(titleView)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func layoutSubviews() {
super.layoutSubviews()
//垂直居中
titleView.centerY = height / 2
//根据屏幕宽度计算出子视图在容器中的中央位置
guard let theWindow = window else { return }
let windowCenter = CGPoint.init(x: theWindow.width / 2, y: 0)
let theCenter = theWindow.convert(windowCenter, to: self)
titleView.centerX = theCenter.x
//由于导航栏左右菜单栏的占位可能导致子视图超出容器范围,将子视图限定在容器中,达到尽量居中的效果
if titleView.left < 0 {
titleView.left = 0
} else if titleView.right > width {
titleView.right = width
}
}
}
现在运行应该,自己随意设置菜单栏,观察标题视图的效果,不管横屏竖屏,已经可以自适应居中了,在下一篇中我们来定制标题视图,无非就是定制容器中的子视图了。
标签控制器
一个标准的iOS应用,底部往往都有一个菜单栏,每个菜单栏指向一个界面,这就是标签控制器UITabBarController的用处,标签控制器没有多少要讲的,因为很简单,定制需求不那么大,按照标准来实现就行了,下面就把我们的应用界面放在标签控制器上
class MainViewController: UITabBarController {
let nc1 = MyNavigationController.init(rootViewController: ViewController())
let vc2 = ViewController()
let vc3 = ViewController()
let vc4 = ViewController()
override func viewDidLoad() {
super.viewDidLoad()
let tabBarNormalAttribute = [NSAttributedStringKey.foregroundColor : UIColor.gray]
let tabBarSelectedAttribute = [NSAttributedStringKey.foregroundColor : UIColor.blue]
let tabBarItem1 = UITabBarItem()
tabBarItem1.title = "聊天"
tabBarItem1.setTitleTextAttributes(tabBarNormalAttribute, for: .normal)
tabBarItem1.setTitleTextAttributes(tabBarSelectedAttribute, for: .selected)
nc1.tabBarItem = tabBarItem1
let tabBarItem2 = UITabBarItem()
tabBarItem2.title = "联系人"
tabBarItem2.setTitleTextAttributes(tabBarNormalAttribute, for: .normal)
tabBarItem2.setTitleTextAttributes(tabBarSelectedAttribute, for: .selected)
vc2.tabBarItem = tabBarItem2
let tabBarItem3 = UITabBarItem()
tabBarItem3.title = "发现"
tabBarItem3.setTitleTextAttributes(tabBarNormalAttribute, for: .normal)
tabBarItem3.setTitleTextAttributes(tabBarSelectedAttribute, for: .selected)
vc3.tabBarItem = tabBarItem3
let tabBarItem4 = UITabBarItem()
tabBarItem4.title = "我"
tabBarItem4.setTitleTextAttributes(tabBarNormalAttribute, for: .normal)
tabBarItem4.setTitleTextAttributes(tabBarSelectedAttribute, for: .selected)
vc4.tabBarItem = tabBarItem4
viewControllers = [nc1, vc2, vc3, vc4]
}
}
我们暂时没那么多界面显示,也没有图标,先这样将就一下,然后打开AppDelegate.swift文件,修改代码如下:
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
window = UIWindow.init(frame: UIScreen.main.bounds)
window!.backgroundColor = UIColor.white
window!.rootViewController = MainViewController()
window!.makeKeyAndVisible()
return true
}
现在运行应用,底部的标签来就出来了,点击标签栏会显示对应的视图控制器,我们先看第一个视图控制器,这是我们前面一直在编写的导航控制器,只不过现在把它放在标签控制器里进行展示。
现在点击导航控制器的空白跳转到下一个界面,会发现底部的标签来还在,这往往是我们不希望看到的现象,只需要设置hidesBottomBarWhenPushed属性就行了,打开ViewController.swift文件并修改代码如下:
class ViewController: UIViewController {
let label = UILabel()
let titleView = NavigationTitleView()
override func loadView() {
super.loadView()
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.titleView = titleView
titleView.size = CGSize.init(width: 100, height: 60)
navigationItem.setDefaultBackBarTitle()
view.backgroundColor = UIColor.white
view.addSubview(label)
label.text = "Hello World"
label.sizeToFit()
let shareItem = UIBarButtonItem.init(title: "分享", style: .plain, target: nil, action: nil)
let moreActionsItem = UIBarButtonItem.init(title: "更多", style: .plain, target: nil, action: nil)
navigationItem.rightBarButtonItems = [shareItem, moreActionsItem]
let tap = UITapGestureRecognizer.init(target: self, action: #selector(onClickBackground))
view.addGestureRecognizer(tap)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.centerX = view.width / 2
label.top = 0
}
@objc private func onClickBackground() {
let nextVC = ViewController()
nextVC.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(nextVC, animated: true)
}
}
上一篇:iOS界面开发—开篇
当前篇:iOS界面开发—导航控制器和标签控制器
下一篇:iOS界面开发—定制导航栏标题