Skip to content

Instantly share code, notes, and snippets.

@Josscii
Last active August 28, 2021 17:49
Show Gist options
  • Save Josscii/1a9d04a5058454d7607df1de45c06f83 to your computer and use it in GitHub Desktop.
Save Josscii/1a9d04a5058454d7607df1de45c06f83 to your computer and use it in GitHub Desktop.
iOS Navigation Related Cheat Sheet

iOS Navigation Related Cheat Sheet

NavigationItem

每个 vc 都有自己的 navigationItem。

// 隐藏 push 的下一个 vc 的 back title。
navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)

// 其他赋值没有任何作用
navigationItem.backBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil)

// 更改当前页面的 title
navigationItem.title = "Hello"

// hidesBackButton 设置为 true 会导致滑动返回失效
navigationItem.hidesBackButton = true

// 默认情况下,设置 leftBarButtonItem 会替换 backBarButtonItem,并且会导致滑动返回消失
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil)

// 设置 leftItemsSupplementBackButton 为 true 可以规避这种情况
navigationItem.leftItemsSupplementBackButton = true

NavigationBar

navigationBar 维护了一个 navigationItem 的栈。

// navigationBar 的 isTranslucent 默认为 true,会有一个毛玻璃透明效果
navigationController?.navigationBar.isTranslucent = false

// 通过 barTintColor 来更改 navigationBar 的颜色
navigationController?.navigationBar.barTintColor = .red

// 通过 tintColor 来更改 navigationItem 上的 barButtomItem 的颜色
navigationController?.navigationBar.tintColor = .red

// 通过 titleTextAttributes 来更改 title 的属性
navigationController?.navigationBar.titleTextAttributes = [NSFontAttributeName: UIFont.systemFont(ofSize: 20)]

// 通过 backIndicatorImage 来统一改变 back button 的图片
navigationController?.navigationBar.backIndicatorImage = UIImage(named: "back")
navigationController.navigationBar.backIndicatorTransitionMaskImage = UIImage(named: "back")

改变 backBarButtonItem image 的位置

这个需求会经常碰到,但是实现起来却不简单,因为并没有这样的 api 供我们使用,但是能实现的方式也有几种。

第一种,用 leftBarButtonItem 来代替,这种方法的缺点很明显,就是会引起滑动返回失效,并且需要调整 titleEdgeInsets。(用 spacer Item 在 iOS 11 上已经失效了,因为 iOS 11 用了 stackView 来重新实现 items 的排列)

let button = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 44))
button.setTitle("back", for: .normal)
button.setTitleColor(.black, for: .normal)
button.contentHorizontalAlignment = .left
button.titleEdgeInsets.left = -8
button.backgroundColor = .red
button.addTarget(self, action: #selector(pop), for: .touchUpInside)
let leftbarbuttonitem = UIBarButtonItem(customView: button)
navigationItem.leftBarButtonItem = leftbarbuttonitem

第二种是遍历子视图来调整位置,这个我没试过,但是这种方法太 tricky,指不定什么时候苹果就换实现了。

最后一种方法是可以让设计给我们切左边留白的图(当然我们也可以自己绘制),这样的话,左边的距离就可以控制了,缺点是没法比预定的位置更小。

extension UINavigationItem {
    /// 去掉 backBarButtonItem 的标题
    func removeBackTitle() {
        backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
    }
}

extension UINavigationBar {
    /// 设置 backBarButtonItem 的图标并调整位置
    ///
    /// - Parameters:
    ///   - image: 图标
    ///   - leftMargin: 左边距边缘的位置
    func setBackImage(image: UIImage, leftMargin: CGFloat) {
        let realMargin = leftMargin-8
        let contextSize = CGSize(width: realMargin+image.size.width, height: image.size.height)
        UIGraphicsBeginImageContextWithOptions(contextSize, false, 0)
        image.draw(at: CGPoint(x: realMargin, y: 0))
        let finalImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        backIndicatorImage = finalImage
        backIndicatorTransitionMaskImage = finalImage
    }
}

NavigationBar 的隐藏和显示

有四种方式:

第一种是在 viewWillAppear 里面调用下面方法,有较好的过渡动画,即 navigationBar 会跟着 VC 移动。注意,如果 navigationBar 从有到无会导致滑动返回失效,从无到有则没有影响

navigationController?.setNavigationBarHidden(true, animated: true)

第二种方法是设置 isNavigationBarHidden 的值,这种方式不但没有过渡动画,而且会导致滑动返回失效。

navigationController?.isNavigationBarHidden = false

第三种是直接隐藏 navigationBar,没有过渡动画,但不会影响滑动返回。

navigationController?.navigationBar.isHidden = true

第四种是将 navigationBar 变透明,保证 isTranslucent 为 true,然后设置以下代码:

navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
navigationController?.navigationBar.shadowImage = UIImage()

禁用某个页面的滑动返回

有时候你需要禁用某些页面的滑动返回,最好的做法是这样:

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    navigationController?.interactivePopGestureRecognizer.enabled = false
}
override func viewWillDisappear(animated: Bool) {
    super.viewDidDisappear(animated)
    navigationController?.interactivePopGestureRecognizer.enabled = true
}

边缘滑动返回手势失效的原因探讨及解决方案

前面已经谈到了隐藏 navigationBar 会引起边缘滑动返回失效,另外还有给 leftBarButtonItem 重新赋值也会引起失效。

navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil)

那么如何解决这个问题呢?

navigationController 有个属性叫 interactivePopGestureRecognizer,就是用来控制滑动返回的,而通过断点、打印和 runtime header,我们知道它的 target 和 delegate 都是 navigationController 的私有属性 __cachedInteractionController,它的类型是一个私有类 _UINavigationInteractiveTransition,而且在引起滑动返回失效的 vc 里面查看发现,它的 delegate 和 isEnabled 并没有变化,可以猜想的是在手势的 delegate 方法里做了判断。比如:

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    // 在这里判断手势是否失效
}

所以我们只要在滑动返回失效的 vc 里面将手势的 delegate 置空,然后在其他页面重新赋值为原来的值就行了。

class NavigationViewController: UINavigationController {
    
    // 添加一个属性用来存手势的 delegate
    var popDelegate: UIGestureRecognizerDelegate!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        
        popDelegate = interactivePopGestureRecognizer?.delegate
        delegate = self
    }
}

extension NavigationViewController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        // 对于 rootViewController 做特殊处理
        guard viewController != navigationController.viewControllers[0] else {
            interactivePopGestureRecognizer?.delegate = popDelegate
            return
        }
    
        if isNavigationBarHidden || viewController.navigationItem.leftBarButtonItem != nil {
            interactivePopGestureRecognizer?.delegate = nil
        } else {
            interactivePopGestureRecognizer?.delegate = popDelegate
        }
    }
}

这里将它的 delegate 置空可能引起的问题的是,当边缘滑动手势和其他手势冲突的时候,边缘滑动手势也会失效,比如 scrollView 的横向滑动,所以这里还可以为了防止这种情况做处理:

class AvoidGestureScrollView: UIScrollView, UIGestureRecognizerDelegate {
    // 防止边缘滑动手势和 scrollView 的滑动手势冲都可以同时被识别
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if otherGestureRecognizer is UIScreenEdgePanGestureRecognizer {
            return true
        }
        
        return false
    }
    
    // 边缘滑动手势优先识别
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if otherGestureRecognizer is UIScreenEdgePanGestureRecognizer {
            return true
        }
        
        return false
    }
}

或者也可以反过来对 UIScreenEdgePanGestureRecognizer 的 delelgate 作处理。

这种方法基本上没有 bug,也没有侵入性。另外还有一个方法是,新建一个手势,通过 KVC 取值,将它的 target 和 action 设为 interactivePopGestureRecognizer 的,这样有一个好处是可以做到全屏返回。这里详细介绍了这种方案。

实现 NavigationBar 有无互换的方案

上面探讨的内容已经给出了一个方案,就是通过在 viewWillAppear 里调用 setNavigationBarHidden 方法来实现有动画的 navigationBar 的有无切换,并且给出了滑动返回失效的解决方案。但是这种方案无法实现 NavigationBar 滑动渐变的需求。

另外一种方案是将 navigationBar 透明后,在需要的界面自己加上假的 navigationBar,这样比较好控制,但不足时需要写一些代码。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment