【SwiftUI】ナビゲーションバーを完全カスタマイズ

はじめに

今回はSwiftUIにて、ナビゲーションバーを完全カスタマイズする方法についてご紹介したいと思います。

ここでは「SwiftUIでナビゲーションバーのカスタマイズにチャレンジしたけど行き詰まって、あちこちネットで調べてあれこれ試したけどやっぱり望みは叶えられなくて、もう力技でもなんでもいいから誰かなんとかしてくれ!」という最終手段をお求めの方を対象としております。

そのため、執筆時点のiOS13, 14に関しての動作確認は行っておりますが、この先のOSバージョンアップ等では動作しない可能性が多分に含まれていますこと、あらかじめご了承願います。

開発・実行環境は Xcode 12.4 / Swift 5.3 です。

こんなことができますという例

現在App Storeにて、自作アプリ「積む録」を公開しています。(宣伝)

このアプリはiOS13以上をサポートし、SwiftUIで作成を行っていますが、その中でこの記事で紹介するナビゲーションバーの改造技を使用しています。

ここで見ていただきたいのは、次の3点です。

  • 最初の画面で、ナビゲーションバーに独自Viewを載せている(UISearchBarとUIButton2つ)
  • リスト項目をタップして遷移した次の画面では、その項目と同じ色で着色している(UIAppearance不使用)
  • 戻るボタンのタイトルを消している(.navigationBarItems(leading:)不使用)

SwiftUIではなくStoryboard時代のUIKitオンリーでなら何てことはないものなのですが、SwiftUIでこれをやろうとするとだいぶ面倒くさい。
私自身、こういうことをやろうと思ってしまったがために、さんざんネット検索してあれこれ試行錯誤してかなりの時間を費やしてしまいました。

なので、これが他のエンジニアさんの時間節約となれば幸いです。

この記事用サンプルアプリの完成図

ここではコードをわかりやすくするため、以下のようなアプリを作ってみたいと思います。

最初に紹介した「積む録」みたいなものに加え、このサンプルでは3つ目の画面でナビゲーションバーの非表示化を行っています。

iOS14ではViewのnavigationBarHiddenを使えばそれだけで消せるのですが、iOS13だと上手く行かないことがあるので、その補完としての紹介になります。

では作っていきます

View

画面構成は以下のとおりです。

ContentView (ルート、ナビゲーションビュー設置)
 └ FirstView ─ SecondView ─ ThirdView (ナビゲーションビュー内のコンテンツたち)

それぞれソースを載せていきます。

ContentView

//
//  ContentView.swift
//

import SwiftUI

struct ContentView: View {

    @ObservedObject private var holder = NavigationControllerHolder()

    var body: some View {
        NavigationController {
            // UINavigationControllerインスタンスを保持
            // Keep the UINavigationController instance
            self.holder.navigationController = $0
            
        } content: {
            FirstView()
                .environmentObject(self.holder)
        }
        .edgesIgnoringSafeArea(.all)
    }
}

ルートに置くViewです。

カスタマイズのため、SwiftUIで用意されているNavigationViewは使わず、UINavigationControllerをUIViewControllerRepresentableでラップしたカスタムView(NavigationController)を使用しています。

また、作成したUINavigationControllerのインスタンスを保持するため、ObservableObject(NavigationControllerHolder)を1つ用意します。

それぞれソースは下の方にあります。

edgesIgnoringSafeAreaはステータスバーまで色を変えるためのものです。
iOS13をサポートするため、古いメソッドを使用しています。

FirstView

//
//  FirstView.swift
//

import UIKit
import SwiftUI

struct FirstView: View {

    @EnvironmentObject private var holder: NavigationControllerHolder

    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            Text("FirstView")

            NavigationLink("To Second1", destination: self.secondView1)

            NavigationLink("To Second2", destination: self.secondView2)

            // UIViewControllerのライフサイクルイベントを検知するための空のViewController
            // Empty ViewController for detecting UIViewController lifecycle events
            ViewController(viewWillAppear: viewWillAppear)
                .frame(width: 0, height: 0)
        }
    }

    var secondView1: some View {
        SecondView(title: "Second Red", font: UIFont(name: "AmericanTypewriter", size: 20)!, color: .red)
            .environmentObject(self.holder)
    }

    var secondView2: some View {
        SecondView(title: "Second Blue", font: UIFont(name: "GillSans-UltraBold", size: 20)!, color: .blue)
            .environmentObject(self.holder)
    }

    func viewWillAppear(_ viewController: UIViewController) {

        guard let navigationController = viewController.navigationController else {
            return
        }

        navigationController.navigationBar.shadowImage = nil
        navigationController.navigationBar.tintColor = .white
        navigationController.navigationBar.barTintColor = .white

        // TitleViewは一度だけ設定すればいいので、作成済みならここで終了
        // TitleView is needed to make once only, so return this method if already made
        guard let parentViewController = viewController.parent, parentViewController.navigationItem.titleView == nil else {
            return
        }

        let nib = UINib(nibName: "NavigationHeaderView", bundle: nil)
        let view = nib.instantiate(withOwner: nil, options: nil).first as! NavigationHeaderView
        view.frame = navigationController.navigationBar.bounds

        parentViewController.navigationItem.titleView = view
    }
}

ViewControllerなるものが置かれていますが、これはUIViewControllerをUIViewControllerRepresentableでラップしたもので、UIViewControllerのライフサイクルイベント(viewWillAppearとか)を検知してそのタイミングで何か処理したいためのものです。
何か表面に出したいものではないので、frameでサイズを0にしています。

これもソースは後ほど。

そしてそれで検知したviewWillAppearのタイミングで、ナビゲーションバーに対してもろもろ設定を行っています。
TitleView(ナビゲーションバーに独自のViewを置くためのプロパティ)は最初の一度だけ行えばいいのでその判定を入れています。

ちなみになぜviewWillAppearなのかというと、viewDidLoadではいろいろ間に合わず独自のViewが載らないためです。

そのほかには、画面遷移のためのNavigationLinkを2つ置いています。
それぞれ次の画面のヘッダータイトルやその色、フォントを渡すようにしています。

SecondView

//
//  SecondView.swift
//

import SwiftUI

struct SecondView: View {

    @EnvironmentObject private var holder: NavigationControllerHolder

    var title: String
    var font: UIFont
    var color: UIColor

    var body: some View {

        self.configureNavigationBar()

        return VStack(alignment: .center, spacing: 16) {
            Text("SecondView")

            NavigationLink("To Third", destination: ThirdView().environmentObject(self.holder))
        }
        .navigationBarTitle(Text(self.title), displayMode: NavigationBarItem.TitleDisplayMode.inline)        
    }

    func configureNavigationBar() {

        guard let navigationController = holder.navigationController else {
            return
        }

        navigationController.navigationBar.shadowImage = UIImage()
        navigationController.navigationBar.tintColor = .white
        navigationController.navigationBar.barTintColor = self.color

        navigationController.navigationBar.titleTextAttributes = [
            .font: self.font,
            .foregroundColor: UIColor.white,
        ]

        navigationController.topViewController?.navigationItem.backButtonTitle = ""
    }
}

UINavigationControllerのholderを引き継いで、ここではView.bodyの冒頭でナビゲーションバーの設定変更を行っています。

どうしてFirstViewがviewWillAppearでSecondViewがView.bodyなんだと思うかと思いますが、ここをviewWillAppearにするとタイミングによっては色の変更が有効になる前に画面遷移が完了してしまうことがあるため(あったため)です。

逆にFirstViewの色変更をView.bodyにすると、SecondViewから戻ってくるタイミングではbodyが呼ばれず、SecondViewの色を引きずってしまう、なんてことになるためです。

ここらへんは非常にタイミングや場合によるため、必要に応じて調整してください。

ThirdView

//
//  ThirdView.swift
//

import SwiftUI

struct ThirdView: View {

    @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
    @EnvironmentObject private var holder: NavigationControllerHolder

    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            Text("ThirdView")

            Button {
                self.presentationMode.wrappedValue.dismiss()
            } label: {
                Text("Back")
            }

            ViewController(viewDidAppear: self.viewDidAppear)
                .frame(width: 0, height: 0)
        }
        .navigationBarTitle("", displayMode: .inline) // <- a workaround to disappear the bar in iOS 13
        .navigationBarHidden(true) // <- enough only this in iOS 14
    }

    func viewDidAppear(_ viewController: UIViewController) {

        if #available(iOS 14, *) {
            return
        }

        DispatchQueue.main.async {
            viewController.navigationController?.setNavigationBarHidden(true, animated: false)
        }
    }
}

アプリによっては画面遷移の途中でナビゲーションバーを非表示にしたい場合もあるかと思います。
ですが、navigationBarHiddenだけではiOS13で非表示になってくれません。
(ネットで挙がっているワークアラウンドもいろいろ試してみましたが、私の場合はうまくいきませんでした)

そこで、FirstViewにも登場したViewControllerを使用して、UINavigationController.setNavigationBarHiddenを直接呼び出します。

しかし、SwiftUIの中で働いているもののせいかこれだけではダメ(一瞬消えるけど、すぐ復活する)で、ネットにあるワークアラウンド + viewDidAppear(Willではないです)で、さらにDispatchQueueのasyncをかましてあげることで、ようやく妥協できる動きになりました。(iOS13 / iPhone SE 1st 調べ)

これぞ力技。

NavigationHeaderView

上記FirstViewにてナビゲーションバーに載せている独自Viewです。

XIBファイルを使用していますが、こちらはUISearchBarとUIButtonを載せているだけなので省略します。
AutoLayoutのConstraintsは設計時だけで、すべて「Remove at build time」にチェックを入れています。
これはAutoLayoutを使うと上手くレイアウトされてくれなかったためです。

//
//  NavigationHeaderView.swift
//

import UIKit

protocol NavigationHeaderViewDelegate: NSObjectProtocol {
    func navigationHeaderViewOnTapFilter(_ view: NavigationHeaderView)
    func navigationHeaderViewOnTapAppSettings(_ view: NavigationHeaderView)
    func navigationHeaderView(_ view: NavigationHeaderView, searchBarTextDidEndEditing text: String?)
}

class NavigationHeaderView: UIView, UISearchBarDelegate {

    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var filterButton: UIButton!
    @IBOutlet weak var settingsButton: UIButton!

    weak var delegate: NavigationHeaderViewDelegate?

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    init() {
        super.init(frame: .zero)
        initialize()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        initialize()
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        initialize()
    }

    private func initialize() {

        backgroundColor = .white

        searchBar.translatesAutoresizingMaskIntoConstraints = true
        searchBar.placeholder = "Search"

        filterButton.translatesAutoresizingMaskIntoConstraints = true
        filterButton.tintColor = .darkGray

        settingsButton.translatesAutoresizingMaskIntoConstraints = true
        settingsButton.tintColor = .darkGray
    }

    override func layoutSubviews() {
        let offsetX: CGFloat = 8
        let spacing: CGFloat = 8
        let width: CGFloat = bounds.width
        let controlHeight: CGFloat = 44
        let buttonWidth: CGFloat = 36

        var frame = CGRect(x: width - offsetX - buttonWidth, y: 0, width: buttonWidth, height: controlHeight)
        settingsButton.frame = frame

        frame.origin.x -= spacing + buttonWidth
        filterButton.frame = frame

        frame.size.width = frame.minX - spacing * 2
        frame.origin.x = offsetX
        searchBar.frame = frame
    }

    @IBAction func onTapFilter(_ sender: UIButton) {
        delegate?.navigationHeaderViewOnTapFilter(self)
    }

    @IBAction func onTapAppSettings(_ sender: UIButton) {
        delegate?.navigationHeaderViewOnTapAppSettings(self)
    }

    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        delegate?.navigationHeaderView(self, searchBarTextDidEndEditing: searchBar.text)
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        searchBar.endEditing(true)
    }

    func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool {
        return true
    }
}

UIViewControllerRepresentable

上記View内にて登場したNavigationControllerViewControllerのソースです。

NavigationController

//
//  NavigationController.swift
//

import UIKit
import SwiftUI

struct NavigationController<Content>: UIViewControllerRepresentable where Content : View {

    typealias ConfigureAction = (UINavigationController) -> Void

    var configure: ConfigureAction?
    var content: Content

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    init(configure: ConfigureAction? = nil, @ViewBuilder content: () -> Content) {
        self.configure = configure
        self.content = content()
    }

    func makeUIViewController(context: Context) -> UINavigationController {
        let hostingControlelr = UIHostingController(rootView: content)
        let navigationController = UINavigationController(rootViewController: hostingControlelr)

        configure?(navigationController)

        return navigationController
    }

    func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
    }
}

ViewController

//
//  ViewController.swift
//

import UIKit
import SwiftUI
import Combine

struct ViewController: UIViewControllerRepresentable {

    typealias Event = (UIViewController) -> Void

    var viewDidLoad: Event? = nil
    var viewWillAppear: Event? = nil
    var viewDidAppear: Event? = nil
    var viewWillDisappear: Event? = nil
    var viewDidDisappear: Event? = nil

    func makeCoordinator() -> Coordinator {
        Coordinator(controller: self)
    }

    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = CustomViewController()
        viewController.delegate = context.coordinator

        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        context.coordinator.controller = self
    }
}

extension ViewController {
    final class Coordinator: NSObject, CustomViewControllerDelegate {
        var controller: ViewController

        init(controller: ViewController) {
            self.controller = controller
        }

        func viewControllerViewDidLoad(_ viewController: UIViewController) {
            self.controller.viewDidLoad?(viewController)
        }

        func viewControllerViewWillAppear(_ viewController: UIViewController) {
            self.controller.viewWillAppear?(viewController)
        }

        func viewControllerViewDidAppear(_ viewController: UIViewController) {
            self.controller.viewDidAppear?(viewController)
        }

        func viewControllerViewWillDisappear(_ viewController: UIViewController) {
            self.controller.viewWillDisappear?(viewController)
        }

        func viewControllerViewDidDisappear(_ viewController: UIViewController) {
            self.controller.viewDidDisappear?(viewController)
        }
    }
}

fileprivate protocol CustomViewControllerDelegate: NSObjectProtocol {
    func viewControllerViewDidLoad(_ viewController: UIViewController)
    func viewControllerViewWillAppear(_ viewController: UIViewController)
    func viewControllerViewDidAppear(_ viewController: UIViewController)
    func viewControllerViewWillDisappear(_ viewController: UIViewController)
    func viewControllerViewDidDisappear(_ viewController: UIViewController)
}

fileprivate class CustomViewController: UIViewController {

    weak var delegate: CustomViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        delegate?.viewControllerViewDidLoad(self)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        delegate?.viewControllerViewWillAppear(self)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        delegate?.viewControllerViewDidAppear(self)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        delegate?.viewControllerViewWillDisappear(self)
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        delegate?.viewControllerViewDidDisappear(self)
    }
}

特にどうということはないかと思いますので、解説は省きます。

ObservableObject

NavigationControllerHolder

UINavigationControllerのインスタンスを保持するためのObservableObjectです。

//
//  NavigationControllerHolder.swift
//

import UIKit
import SwiftUI

class NavigationControllerHolder: ObservableObject {
    weak var navigationController: UINavigationController? = nil
}

強参照を避けるためweakにしていますが、ナビゲーションの子ビューの中で使用する分にはインスタンスが消えることもないため、問題はないかと思います。
(逆に強参照だと解放するタイミングが難しい)

おわりに

今回の内容は、これにて以上となります。

冒頭でも述べましたが、SwiftUIでナビゲーションバーをいい感じにしたいと思ったばかりに、なかなかの苦労をかけさせられました。

ブログの投稿やStack Overflowなどで様々なアイデアを出してくれているエンジニアの皆様に感謝しつつ、それに報いるためにこちらからもひとつご参考になればと思い、これを記します。

この記事が誰かのお役に立てれば幸いです。