iOS界面开发—导航控制器和标签控制器

上一篇: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)
    }

}

现在点击界面任何地方,都会弹出一个新的界面,点击返回后返回到上一个界面,这就是导航控制器的作用。

iOS界面开发—导航控制器和标签控制器_第1张图片
屏幕快照 2018-02-06 下午6.01.44.png

返回手势

在默认的情况下,导航控制器支持左侧边缘手势返回,很多应用会支持全屏手势返回,我们在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,可以自行调整尺寸观察它的布局变化,下面阐述其布局规则:

  1. 如果titleView的尺寸为零,导航栏不显示titleView
  2. titleView垂直居中显示
  3. titleView水平尽量居中,这取决于返回按钮以及菜单栏占用了多少地方,如果titleView的宽度超出了剩余的宽度,将自动调整为剩余宽度
  4. 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界面开发—定制导航栏标题

你可能感兴趣的:(iOS界面开发—导航控制器和标签控制器)