SwiftUI から UINavigationItem にアクセスして UINavigationBar をカスタマイズする

背景

SwiftUI から NavigationBar の background color や shadow color 等をカスタマイズする方法として、よく紹介されているのは以下のような方法。

let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .red
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance

だが、この方法はすべての画面に影響を及ぼすため、全画面共通の Navigation Bar の見た目では無い限りは使い勝手が悪い。
UIKit では UINavigationItem にアクセスしてプロパティを変更することで、その画面 (UINavigationController) に影響を閉じ込めることができた。

なので SwiftUI でも UINavigationItem にアクセスできるような UIHostingController wrapper を作成する。

使い方

@Environment(\.navigationItem) で取ってくる

struct InnerView: View {
  @Environment(\.navigationItem) var navigationItem // : UINavigationItem?

  var body: some View {
    Text("Hello, world!")
      .onAppear {
        navigationItem.title = "Title"
        let appearance = UINavigationBarAppearance()
			  appearance.configureWithOpaqueBackground()
				appearance.backgroundColor = .red
				navigationItem?.standardAppearance = appearance
				navigationItem?.scrollEdgeAppearance = appearance
				navigationItem?.compactAppearance = appearance
      }
  }
}

実装

import SwiftUI
import TinyConstraints
import UIKit

/// SwiftUI において ``UINavigationItem`` にアクセスするための Wrapper
/// ``UIHostingController`` の代わりに使うことで ``EnvironmentValues.navigationItem`` を経由して SwiftUI View で操作が可能になる
public final class NavigationItemHost<Content: View>: UIViewController {
    private let rootView: Content

    @available(*, unavailable)
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public init(rootView: Content) {
        self.rootView = rootView
        super.init(nibName: nil, bundle: nil)
    }

    override public func viewDidLoad() {
        super.viewDidLoad()
        let rootViewWithItem = rootView
            .environment(\.navigationItem, navigationItem)
        let host = UIHostingController(rootView: rootViewWithItem)
        addChild(host)
        view.addSubview(host.view)
        host.didMove(toParent: self)
        host.view.edgesToSuperview()
    }
}

public struct UINavigationItemKey: EnvironmentKey {
    public static var defaultValue: UINavigationItem?
}

public extension EnvironmentValues {
    var navigationItem: UINavigationItem? {
        get { self[UINavigationItemKey.self] }
        set { self[UINavigationItemKey.self] = newValue }
    }
}