Search code examples
iosswiftuinavigationbaruistatusbaruiappearance

Globally apply a gradient to the navigation bar and handle orientation changes


I need to apply a gradient globally to my status and navigation bars and have it adjust properly to orientation changes. Because I want this to be global, I'm trying to use UIAppearance. Surprisingly, UIAppearance doesn't make this very easy.

It looks great in Portrait, but the gradient is too tall when in Landscape so you can't see the whole thing:

Comparison of portrait and landscape gradient nav bar

Here's my code to this point:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let navigationBarAppearance = UINavigationBar.appearance()
    navigationBarAppearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
    navigationBarAppearance.isTranslucent = false
    navigationBarAppearance.tintColor = UIColor.white

    let status_height = UIApplication.shared.statusBarFrame.size.height
    let gradientLayer = CAGradientLayer(frame: CGRect(x: 0, y: 0, width: 64, height: status_height + 44), colors: [UIColor.init(hex: "005382"), UIColor.init(hex: "00294B")])
    let layerImage = gradientLayer.createGradientImage()
    navigationBarAppearance.barTintColor = UIColor(patternImage: layerImage ?? UIImage())
}

and I'm using this extension:

extension CAGradientLayer {
  convenience init(frame: CGRect, colors: [UIColor]) {
    self.init()
    self.frame = frame
    self.colors = []
    for color in colors {
      self.colors?.append(color.cgColor)
    }
    startPoint = CGPoint(x: 0, y: 0)
    endPoint = CGPoint(x: 0, y: 1)
  }

  func createGradientImage() -> UIImage? {

    var image: UIImage? = nil

    UIGraphicsBeginImageContext(bounds.size)

    if let context = UIGraphicsGetCurrentContext() {
      render(in: context)
      image = UIGraphicsGetImageFromCurrentImageContext()
    }

    UIGraphicsEndImageContext()

    return image
  } 
}

I know I could check the orientation and then change the gradient accordingly but I'd need to do that on every view controller so that would defeat the purpose of using UIAppearance and being able to do it in one place.

Most of the SO threads I've found provide solutions for making the top bar's gradient at the view controller level, but not the global level.

EDIT: Tried answer from @Pan Surakami on my UITabBarController but I still have white navigation bars:

enter image description here

Here's my storybaord setup:

enter image description here

And code:

class MenuTabBarController: UITabBarController {
    var notificationsVM = NotificationsVModel()
    var hasNewAlerts: Bool = false

    override func viewDidLoad() {
      super.viewDidLoad()

      setTabs()

      styleUI()

      notificationsVM.fetchData { (success, newNotifications) in
            if success {
                self.hasNewAlerts = newNotifications.count > 0 ? true : false
                DispatchQueue.main.async {
                    if let tabBarItems = self.tabBar.items {
                        for (_, each) in tabBarItems.enumerated() {
                            if each.tag == 999 { //only update the alerts tabBarItem tag == '999'
                                self.updateAlertBadgeIcon(self.hasNewAlerts, each)
                            }
                        }
                    }
                }
            }
        }
    }

  fileprivate func setTabs() {
    let tab1 = GradientNavigationController(rootViewController: FeedViewController())
    let tab2 = GradientNavigationController(rootViewController: NotificationsTableViewController())
    let tab3 = GradientNavigationController(rootViewController: SearchViewController())
    let tab4 = GradientNavigationController(rootViewController: ComposeDiscussionViewController())
    UITabBarController().setViewControllers([tab1, tab2, tab3, tab4], animated: false)
  }

    func updateAlertBadgeIcon(_ hasAlerts: Bool, _ item: UITabBarItem) {
        if hasAlerts {
            item.image = UIImage(named: "alert-unselected-hasAlerts")?.withRenderingMode(UIImage.RenderingMode.alwaysOriginal)
            item.selectedImage = UIImage(named: "alert-selected-hasAlerts")?.withRenderingMode(UIImage.RenderingMode.alwaysOriginal)
        } else {
            hasNewAlerts = false
            item.image = UIImage(named: "alert-unselected-noAlerts")?.withRenderingMode(UIImage.RenderingMode.alwaysOriginal)
            item.selectedImage = UIImage(named: "alert-selected-noAlerts")?.withRenderingMode(UIImage.RenderingMode.alwaysOriginal)
        }
    }

    // UITabBarDelegate
    override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        if item.tag == 999 { //alerts tabBarItem tag == '999'
            updateAlertBadgeIcon(hasNewAlerts, item)
        }
      if item.tag == 0 { //Feed Item clicked
        if let feedNav = children[0] as? UINavigationController, let feedVC = feedNav.viewControllers[0] as? FeedViewController {
          feedVC.tableView.reloadData()
        }

      }
    }

    func styleUI() {
        UITabBar.appearance().backgroundImage = UIImage.colorForNavBar(color:.lightGrey4)
        UITabBar.appearance().shadowImage = UIImage.colorForNavBar(color:.clear) 
        tabBar.layer.shadowOffset = CGSize.zero
        tabBar.layer.shadowRadius = 2.0
        tabBar.layer.shadowColor = UIColor.black.cgColor
        tabBar.layer.shadowOpacity = 0.30
        UITabBarItem.appearance().setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.grey2,
                                                          NSAttributedString.Key.font: UIFont(name: "AvenirNext-DemiBold", size: 12) as Any],
                                                         for: .normal)
        UITabBarItem.appearance().setTitleTextAttributes([NSAttributedString.Key.foregroundColor : UIColor.darkSapphire,
                                                          NSAttributedString.Key.font: UIFont(name: "AvenirNext-DemiBold", size: 12) as Any],
                                                         for: .selected)
    }
}

Solution

  • One way to implement it is to subclass UINavigationController.

    1. Create a new subclass.
    class GradientNavigationController: UINavigationController {}
    
    1. Override traitCollectionDidChange method.
    class GradientNavigationController: UINavigationController {
        override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
            super.traitCollectionDidChange(previousTraitCollection)
    
            let status_height = UIApplication.shared.statusBarFrame.size.height
            let gradientLayer = CAGradientLayer(frame: CGRect(x: 0, y: 0, width: 64, height: status_height + 44), colors: [[UIColor.init(hex: "005382"), UIColor.init(hex: "00294B")])
            let layerImage = gradientLayer.createGradientImage()
            self.navigationBar.barTintColor = UIColor(patternImage: layerImage ?? UIImage())
        }
    }
    
    1. Use this subclass instead of UINavigationController. Either change custom subclass on storiboards or use it in code.

    EDIT: Your UITabBarController is configured by the storyboard. So setTabs() used this way has no sense. It just creates another copy of UITabBarController and then removes it. I showed it just as an example of embedding controllers.

    1. Remove the method setTabs().
    2. Open each storyboard which is linked to your tabs. (I cannot see how they are actually configured they are behind storyboard references.)
    3. Make sure that an initial controller is GradientNavigationController.

    enter image description here