Skip to content

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

轩辕十四
Published date:

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

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

设置导航栏的背景颜色


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

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 的具体设置方法。

extension UINavigationController {
    /// 设置背景色
    fileprivate func navBarBackgroundColor(_ color: UIColor) {
        navigationBar.backgroundColor(color)
    }
}

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

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 出来。代码如下:

/**
 * 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 之前被调用。

代码如下:

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() 中去执行。

extension UINavigationController: SelfAware {
    static func awake() {
        /// 判断是否是其子类
        guard self !== UINavigationController.self else { return }
        self.swizzle
    }
}

计算颜色的过度


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

// 计算颜色的过度
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: 方法。

/// 用于替换系统的 _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: 方法中来处理我们的手势取消操作。

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 源码吧。

参考文档

Previous
iOS 11 中 UIRefreshControll 消失【译】
Next
iOS 配置测试用推送通知证书