7

I've got a super simple SwiftUI master-detail app:

import SwiftUI

struct ContentView: View {
    @State private var imageNames = [String]()

    var body: some View {
        NavigationView {
            MasterView(imageNames: $imageNames)
                .navigationBarTitle(Text("Master"))
                .navigationBarItems(
                    leading: EditButton(),
                    trailing: Button(
                        action: {
                            withAnimation {
                                // simplified for example
                                self.imageNames.insert("image", at: 0)
                            }
                        }
                    ) {
                        Image(systemName: "plus")
                    }
                )
        }
    }
}

struct MasterView: View {
    @Binding var imageNames: [String]

    var body: some View {
        List {
            ForEach(imageNames, id: \.self) { imageName in
                NavigationLink(
                    destination: DetailView(selectedImageName: imageName)
                ) {
                    Text(imageName)
                }
            }
        }
    }
}

struct DetailView: View {

    var selectedImageName: String

    var body: some View {
        Image(selectedImageName)
    }
}

I'm also setting the appearance proxy on the SceneDelegate for the navigation bar's colour"

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        let navBarAppearance = UINavigationBarAppearance()
        navBarAppearance.configureWithOpaqueBackground()
        navBarAppearance.shadowColor = UIColor.systemYellow
        navBarAppearance.backgroundColor = UIColor.systemYellow
        navBarAppearance.shadowImage = UIImage()
        UINavigationBar.appearance().standardAppearance = navBarAppearance
        UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Now, what I'd like to do is for the navigation bar's background colour to change to clear when the detail view appears. I still want the back button in that view, so hiding the navigation bar isn't really an ideal solution. I'd also like the change to only apply to the Detail view, so when I pop that view the appearance proxy should take over and if I push to another controller then the appearance proxy should also take over.

I've been trying all sorts of things: - Changing the appearance proxy on didAppear - Wrapping the detail view in a UIViewControllerRepresentable (limited success, I can get to the navigation bar and change its colour but for some reason there is more than one navigation controller)

Is there a straightforward way to do this in SwiftUI?

KerrM
  • 5,139
  • 3
  • 35
  • 60

3 Answers3

8

Update: in iOS 16 we have a new modifier .toolbarBackground() that allows us to set custom background of a navigation bar.

For older iOS Versions: I prefer using ViewModifer for this. Below is my custom ViewModifier

struct NavigationBarModifier: ViewModifier {

var backgroundColor: UIColor?

init(backgroundColor: UIColor?) {
    self.backgroundColor = backgroundColor
    
    let coloredAppearance = UINavigationBarAppearance()
    coloredAppearance.configureWithTransparentBackground()
    coloredAppearance.backgroundColor = backgroundColor
    coloredAppearance.titleTextAttributes = [.foregroundColor: UIColor.white]
    coloredAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
    
    UINavigationBar.appearance().standardAppearance = coloredAppearance
    UINavigationBar.appearance().compactAppearance = coloredAppearance
    UINavigationBar.appearance().scrollEdgeAppearance = coloredAppearance
    UINavigationBar.appearance().tintColor = .white
}

func body(content: Content) -> some View {
    ZStack{
        content
        VStack {
            GeometryReader { geometry in
                Color(self.backgroundColor ?? .clear)
                    .frame(height: geometry.safeAreaInsets.top)
                    .edgesIgnoringSafeArea(.top)
                Spacer()
            }
        }
    }
}}

You can also initialize it with different text color and tint color for your bar, I have added static color for now.

You can call this modifier from any. In your case

    NavigationLink(
destination: DetailView(selectedImageName: imageName)
    .modifier(NavigationBarModifier(backgroundColor: .green))

)

Below is the screenshot. Detail View with green navigation bar

user832
  • 796
  • 5
  • 18
  • Thanks for your reply @user832 -- this is a really cool solution. If you wanted to have a consistent color for all navigation bars and only override one of the ones in your app, would you still have to add the modifier on all views? – KerrM May 27 '20 at 08:55
  • @KerrM I generally apply this modifier to my NavigationView, thus it remains consistent for all Views inside that navigation view, which makes sense as Navigation View contains my other Views. But if you apply it to an independent view, you will have to add this modifier on all views. If you check body property of this modifier you will observe that it only makes changes to the content it is applied to. – user832 May 27 '20 at 16:05
  • Thanks for the super-quick reply @user832. I must be doing something wrong because when I apply the view modifier to the NavigationView it doesn't cover the navigation bar, it only covers the status bar. Additionally, when the navigation bar goes from large to inline modes (i.e. on a list when scrolling), the background color doesn't shrink with the navigation bar. I think this is a good solution, I'm going to keep looking into how to make it work with these two uses. – KerrM May 28 '20 at 09:36
  • @KerrM You can add your updated code in your question and I would love to help. As there are many unexplained concepts in SwiftUI. – user832 May 28 '20 at 16:04
  • Hey @user832, thanks for your help. I've uploaded the project here: https://github.com/kerrmarin/swiftui-navigation – KerrM Jun 01 '20 at 14:53
  • @KerrM My bad, if u see in my old code I was setting `coloredAppearance.backgroundColor = .clear` . I have updated my code. – user832 Jun 01 '20 at 21:50
3

In my opinion, this is the straightforward solution in SwiftUI.

problem: framework adds back buttom in the DetailView solution: Custom back button and nav bar are rendered

struct DetailView: View {
var selectedImageName: String
@Environment(\.presentationMode) var presentationMode

var body: some View {
    CustomizedNavigationController(imageName: selectedImageName) { backButtonDidTapped in
        if backButtonDidTapped {
            presentationMode.wrappedValue.dismiss()
        }
    } // creating customized navigation bar
    .navigationBarTitle("Detail")
    .navigationBarHidden(true) // Hide framework driven navigation bar
 }
}

If framework driven navigation bar is not hidden in the detail view, we get two navigation bars like this: double nav bars

Using UINavigationBar.appearance() is quite unsafe in scenarios like when we want to present both of these Master and Detail views within a popover. There is a chance that all other nav bars in our application might acquire the same navbar configuration of the Detail view.

struct CustomizedNavigationController: UIViewControllerRepresentable {
let imageName: String
var backButtonTapped: (Bool) -> Void

class Coordinator: NSObject {
    var parent: CustomizedNavigationController
    var navigationViewController: UINavigationController
    
    init(_ parent: CustomizedNavigationController) {
        self.parent = parent
        let navVC = UINavigationController(rootViewController: UIHostingController(rootView: Image(systemName: parent.imageName).resizable()
                                                                                    .aspectRatio(contentMode: .fit)
                                                                                    .foregroundColor(.blue)))
        navVC.navigationBar.isTranslucent = true
        navVC.navigationBar.tintColor = UIColor(red: 41/255, green: 159/244, blue: 253/255, alpha: 1)
        navVC.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.red]
        navVC.navigationBar.barTintColor = .yellow
        navVC.navigationBar.topItem?.title = parent.imageName
        self.navigationViewController = navVC
    }
    
    @objc func backButtonPressed(sender: UIBarButtonItem) {
        self.parent.backButtonTapped(sender.isEnabled)
    }
}

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

func makeUIViewController(context: Context) -> UINavigationController {
    // creates custom back button 
    let navController = context.coordinator.navigationViewController
    let backImage = UIImage(systemName: "chevron.left")
    let backButtonItem = UIBarButtonItem(image: backImage, style: .plain, target: context.coordinator, action: #selector(Coordinator.backButtonPressed))
    navController.navigationBar.topItem?.leftBarButtonItem = backButtonItem
    return navController
}

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

Here is the link to view the full code.

1

I ended up creating a custom wrapper that shows a UINavigationBar that isn't attached to the current UINavigationController. It's something like this:

final class TransparentNavigationBarContainer<Content>: UIViewControllerRepresentable where Content: View {

    private let content: () -> Content

    init(content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIViewController {
        let controller = TransparentNavigationBarViewController()

        let rootView = self.content()
            .navigationBarTitle("", displayMode: .automatic) // needed to hide the nav bar
            .navigationBarHidden(true)
        let hostingController = UIHostingController(rootView: rootView)
        controller.addContent(hostingController)
        return controller
    }

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

final class TransparentNavigationBarViewController: UIViewController {

    private lazy var navigationBar: UINavigationBar = {
        let navBar = UINavigationBar(frame: .zero)
        navBar.translatesAutoresizingMaskIntoConstraints = false

        let navigationItem = UINavigationItem(title: "")

        navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.left"),
                                                           style: .done,
                                                           target: self,
                                                           action: #selector(back))

        let appearance = UINavigationBarAppearance()
        appearance.backgroundImage = UIImage()
        appearance.shadowImage = UIImage()
        appearance.backgroundColor = .clear
        appearance.configureWithTransparentBackground()
        navigationItem.largeTitleDisplayMode = .never
        navigationItem.standardAppearance = appearance
        navBar.items = [navigationItem]
        navBar.tintColor = .white
        return navBar
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.translatesAutoresizingMaskIntoConstraints = false

        self.view.addSubview(self.navigationBar)

        NSLayoutConstraint.activate([
            self.navigationBar.leftAnchor.constraint(equalTo: view.leftAnchor),
            self.navigationBar.rightAnchor.constraint(equalTo: view.rightAnchor),
            self.navigationBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
        ])
    }

    override func didMove(toParent parent: UIViewController?) {
        super.didMove(toParent: parent)

        guard let parent = parent else {
            return
        }

        NSLayoutConstraint.activate([
            parent.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            parent.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            parent.view.topAnchor.constraint(equalTo: self.view.topAnchor),
            parent.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
    }

    @objc func back() {
        self.navigationController?.popViewController(animated: true)
    }

    fileprivate func addContent(_ contentViewController: UIViewController) {
        contentViewController.willMove(toParent: self)
        self.addChild(contentViewController)
        contentViewController.view.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(contentViewController.view)

        NSLayoutConstraint.activate([
            self.view.topAnchor.constraint(equalTo: contentViewController.view.safeAreaLayoutGuide.topAnchor),
            self.view.bottomAnchor.constraint(equalTo: contentViewController.view.bottomAnchor),
            self.navigationBar.leadingAnchor.constraint(equalTo: contentViewController.view.leadingAnchor),
            self.navigationBar.trailingAnchor.constraint(equalTo: contentViewController.view.trailingAnchor)
        ])

        self.view.bringSubviewToFront(self.navigationBar)
    }
}

There are some improvements to be done, like showing custom navigation bar buttons, supporting "swipe-to-go-back" and a couple other things.

KerrM
  • 5,139
  • 3
  • 35
  • 60