在 Swift 4 中 NavigationBar 不同颜色时的转场

我们在项目中,往往会遇到两个 Navigation Bar 样式不同的问题,如果直接用苹果官方的控件,会出现各种各样的 bug,因为苹果官方的 Navigation Bar 是共用的,所以在两个不同样式的 Navigation Bar 中做转场操作的时候就会出现各种问题。

今天我们就用 Method Swizzling 的方式来修改一下系统的 Navigation Bar 转场时的样式,最终效果如下图所示:

设置导航栏的背景颜色


我们来为 UIViewController 添加一个扩展,用于存储与设置 Navigation Bar 各种属性。首先,我们来为我们的 UIViewController 添加一个 navBarBgColor 的计算属性。
这里运用了 runtime 的关联方法 objc_getAssociatedObject:objc_setAssociatedObject: 来存取所设置的背景颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extension UIViewController {
/// 设置导航栏的背景颜色
public var navBarBgColor: UIColor {
get {
if let color = objc_getAssociatedObject(self, &DefaultValue.navBarBgColor) as? UIColor {
return color
}
return DefaultValue.navBarBgColor
}
set {
objc_setAssociatedObject(self, &DefaultValue.navBarBgColor, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
navigationController?.navBarBackgroundColor(newValue)
}
}
}

这时我们就可以存储与设置导航栏的背景色了。

然后我们再为 NavigationController 添加一个扩展,用来调用 Navigationbar 的具体设置方法。

1
2
3
4
5
6
extension UINavigationController {
/// 设置背景色
fileprivate func navBarBackgroundColor(_ color: UIColor) {
navigationBar.backgroundColor(color)
}
}

之后我们在扩展 Navigationbar,添加具体的设置逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extension UINavigationBar {

/// 导航栏背景视图
fileprivate var backgroundView: UIView? {
get {
guard let bgView = objc_getAssociatedObject(self, &DefaultValue.backgroundView) as? UIView else {
return nil
}
return bgView
}
set {
objc_setAssociatedObject(self, &DefaultValue.backgroundView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}

/// 设置导航栏背景色
fileprivate func backgroundColor(_ color: UIColor) {

if backgroundView == nil {
// 添加一个透明背景的 image 到 _UIBarBackground
setBackgroundImage(UIImage(), for: .default)
let height = DeviceInfo.deviceName == .iPhoneX ? 64 : 88
backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: Int(bounds.width), height: height))
backgroundView?.autoresizingMask = .flexibleWidth
// _UIBarBackground 是 navigationBar 的第一个子视图
subviews.first?.insertSubview(backgroundView ?? UIView(), at: 0)
}
backgroundView?.backgroundColor = color
}
}

这里我们是直接插入一个 ViewNavigationBar_UIBarBackground 中,我们直接在这个 View 上面修改颜色即可。至此,设置 NavigationBar 的背景色告一段落,下来我们来看看转场如何做到颜色的均匀过度。

颜色过度 - runtime


要想做到在右滑进行 pop 操作的时候导航栏颜色均匀的过度需要替换一个系统方法 _updateInteractiveTransition:,这个方法是用来监听手势的返回进度的,我们可以在这个方法中计算每一个进度时候导航栏颜色的变化。

大家都知道,在 Objective-C 中进行 Method Swizzling 的时候都需要将替换逻辑放在 dispatch_once 中去执行,保证其执行一次。在 Swift 中,现在已经去掉了 dispatch_once 方法,那么我们应该如何做呢?

苹果文档说道,声明为 static letlazy 的变量具有 dispatch_once 的效果。所以我们就可以用更加 Swift 的方式去实现自己的逻辑,不需要去再造一个 dispatch_once 出来。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Swift 中 static let 具备 dispatch once 特性,所以可以用这种方式声明,
* 闭包的形式声明一个代码块,默认是懒加载
* 类似于 Objective-c 中的 dispatch once
*/
private static let swizzle: () = {
let needSwizzleSelectorAry = [
NSSelectorFromString("_updateInteractiveTransition:"),
#selector(popToViewController(_:animated:)),
#selector(popToRootViewController(animated:))
]
let swizzleSelectorAry = [
#selector(em_updateInteractiveTransition(_:)),
#selector(em_popToViewController(_:animated:)),
#selector(em_popToRootViewControllerAnimated(_:))
]
for sel in needSwizzleSelectorAry {
let str = ("em_" + sel.description).replacingOccurrences(of: "__", with: "_")
if let originMethod = class_getInstanceMethod(UINavigationController.self, sel),
let swizzleMethod = class_getInstanceMethod(UINavigationController.self, Selector(str)) {

method_exchangeImplementations(originMethod, swizzleMethod)
}
}
}()

这样就可以实现 dispatch_once 的效果。

接下来又是一个棘手的问题,Swift 中已经没有了 +load 方法怎么办,甚至 Swift 3.1 之后 +initialize 方法都已经不能用了。这可如何是好。经过 Google,发现了一个替代的方案,用 Swift 的协议来进行实现:Handling the Deprecation of initialize()

JORDAN SMITH 想法其实很简单,是通过 runtime 获取到所有类的列表,然后向所有遵循 SelfAware 协议的类发送消息,并且他把这些操作放到了 UIApplicationnext 属性的调用中,同时发现了 next 属性会在 applicationDidFinishLaunching 之前被调用。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private protocol SelfAware: class {
static func awake()
}

private class NothingToSeeHere {

static func harmlessFunction() {
let typeCount = Int(objc_getClassList(nil, 0))
let types = UnsafeMutablePointer<AnyClass?>.allocate(capacity: typeCount)
let autoreleasingTypes = AutoreleasingUnsafeMutablePointer<AnyClass>(types)
objc_getClassList(autoreleasingTypes, Int32(typeCount))
for index in 0..<typeCount { (types[index] as? SelfAware.Type)?.awake() }
types.deallocate()
}
}

extension UIApplication {

/// 启动只执行一次
private static let runOnce: Void = {
NothingToSeeHere.harmlessFunction()
}()

override open var next: UIResponder? {
/// Called before applicationDidFinishLaunching
UIApplication.runOnce
return super.next
}
}

在别人的帮助下,我们也算是优雅的实现了需要的功能,我们将 swizzle 方法放在 awake() 中去执行。

1
2
3
4
5
6
7
extension UINavigationController: SelfAware {
static func awake() {
/// 判断是否是其子类
guard self !== UINavigationController.self else { return }
self.swizzle
}
}

计算颜色的过度


接下来我们需要一个方法来计算颜色的过度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 计算颜色的过度
private func averageColor(fromColor: UIColor, toColor: UIColor, percent: CGFloat) -> UIColor {
var fromRed: CGFloat = 0
var fromGreen: CGFloat = 0
var fromBlue: CGFloat = 0
var fromAlpha: CGFloat = 0
fromColor.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)

var toRed: CGFloat = 0
var toGreen: CGFloat = 0
var toBlue: CGFloat = 0
var toAlpha: CGFloat = 0
toColor.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)

let nowRed = fromRed + (toRed - fromRed) * percent
let nowGreen = fromGreen + (toGreen - fromGreen) * percent
let nowBlue = fromBlue + (toBlue - fromBlue) * percent
let nowAlpha = fromAlpha + (toAlpha - fromAlpha) * percent

return UIColor(red: nowRed, green: nowGreen, blue: nowBlue, alpha: nowAlpha)
}

实现我们自己的 _updateInteractiveTransition: 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// 用于替换系统的 _updateInteractiveTransition: 方法,监听返回手势进度
@objc func em_updateInteractiveTransition(_ percentComplete: CGFloat) {
guard self.isKind(of: EMNavigationController.self) else { return }
let topVC = self.topViewController
/// transitionCoordinator 带有两个 VC 的转场上下文
if let coor = topVC?.transitionCoordinator,
let fromVC = coor.viewController(forKey: .from) as? EMViewController,
let toVC = coor.viewController(forKey: .to) as? EMViewController {

let fromAlpha = fromVC.navBarBgAlpha
let toAlpha = toVC.navBarBgAlpha
let nowAlpha = fromAlpha + (toAlpha - fromAlpha) * percentComplete
self.navBarBackgroundAlpha(nowAlpha)

let fromTintColor = fromVC.navBarTintColor
let toTintColor = toVC.navBarTintColor
let nowTintColor = averageColor(fromColor: fromTintColor, toColor: toTintColor, percent: percentComplete)
self.navBarTintColor(nowTintColor)

let fromColor = fromVC.navBarBgColor
let toColor = toVC.navBarBgColor
let nowColor = averageColor(fromColor: fromColor, toColor: toColor, percent: percentComplete)
self.navBarBackgroundColor(nowColor)
}
em_updateInteractiveTransition(percentComplete)
}

至此,我们的过度效果算是搞定一半,还有另外一半就是处理手势取消了。我们需要在 navigationBar:shouldPopItem: 方法中来处理我们的手势取消操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
extension UINavigationController: UINavigationBarDelegate {

/// pop
public func navigationBar(_ navigationBar: UINavigationBar,
shouldPop item: UINavigationItem) -> Bool {

if let topVC = topViewController,
let coor = topVC.transitionCoordinator,
coor.initiallyInteractive {

if #available(iOS 10.0, *) {
coor.notifyWhenInteractionChanges({ (context) in
self.dealInteractionChanges(context)
})
} else {
coor.notifyWhenInteractionEnds({ (context) in
self.dealInteractionChanges(context)
})
}
return true
}

let itemCount = navigationBar.items?.count ?? 0
let n = viewControllers.count >= itemCount ? 2 : 1
let popToVC = viewControllers[viewControllers.count - n]

popToViewController(popToVC, animated: true)
return true
}

/// 处理返回手势中断的情况
private func dealInteractionChanges(_ context: UIViewControllerTransitionCoordinatorContext) {
/// 设置动画
let animations: (UITransitionContextViewControllerKey) -> () = { [weak self] in
if let vc = context.viewController(forKey: $0) as? EMViewController {
self?.updateAllStyle(vc)
}
}

if context.isCancelled {
/// 手势取消
let cancelDuration: TimeInterval = context.transitionDuration * Double(context.percentComplete)
UIView.animate(withDuration: cancelDuration) {
animations(.from)
}
} else {
/// 手势完成
let finishDuration: TimeInterval = context.transitionDuration * Double(1 - context.percentComplete)
UIView.animate(withDuration: finishDuration) {
animations(.to)
}
}
}
}

现在,我们的导航栏过度效果基本完成,还有一些其他的细节处理,请看我的 GitHub 源码吧。

参考文档

  • 如何优雅地在Swift4中实现Method Swizzling
  • Handling the Deprecation of initialize()
  • Swift 3.1 deprecates initialize(). How can I achieve the same thing?
  • Whither dispatch_once in Swift 3?
  • 导航栏的平滑显示和隐藏 - 个人页的自我修养(1)
  • 超简单!!! iOS设置状态栏、导航栏按钮、标题、颜色、透明度,偏移等