4

I'm working on a project that requires a custom navigation bar that will have custom buttons and title styling, while also allowing an accessory view below the main nav portion.

Essentially, I'd like to abstract away the need to choose the custom back button based on the presentation style. If it's presented in a sheet, I plan to show an X icon. If it is pushed onto a navigation view I want to show a back error. If it's a root view I want to hide the button altogether.

I've mapped the presentationMode environment variable however when I access the isPresented value I always get true, even on the root view of my app.

Here's a general idea of what I'm working on:

import SwiftUI

struct CustomNavigationBar<Content>: View where Content: View {

    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    private let title: LocalizedStringKey
    private let content: (() -> Content)?

    private var backButton: AnyView? {

        let button = Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
            // custom image extension, just resolves to a back icon
            Image.Icons.arrowBack
        }

        if (presentationMode.wrappedValue.isPresented) {
            return AnyView(button)
        } else {
            return nil
        }
    }

    public init(_ title: LocalizedStringKey, content: (() -> Content)? = nil) {
        self.title = title
        self.content = content
    }

    var body: some View {
        VStack {
            content?()
            Divider().foregroundColor(.gray)
        }.navigationBarTitle(title, displayMode: .large)
        .frame(minHeight: 96)
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: backButton)
    }
}

Does anyone have any experience or tips for accessing a view's place in the presentation hierarchy with SwiftUI? Thanks!

Ben M
  • 2,405
  • 1
  • 20
  • 19
  • `presentationMode` is a binding that allows the view to close itself. Look into preferencekey as a way for children to send data up the view hierarchy – New Dev Apr 15 '21 at 17:48

1 Answers1

3

You can use SwiftUI-Introspect, used to "Introspect underlying UIKit components from SwiftUI".

Here is a working example of what you are looking for. It is an interactive example, so you can click through the different modes.

import Introspect
import SwiftUI

/* ... */

struct ContentView: View {
    
    @State private var testing = 1
    private let thingsToTest = 3
    
    var body: some View {
        VStack {
            Picker("Testing", selection: $testing) {
                ForEach(1 ... thingsToTest, id: \.self) { index in
                    Text("\(index)")
                        .tag(index)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
            
            Divider()
            
            Spacer()
            
            switch testing {
            case 1:
                PresentationReader { kind in
                    Text("Hello! Kind: \(kind.rawValue)")
                }
                
            case 2:
                NavigationView {
                    PresentationReader { kind in
                        Text("Hello! Kind: \(kind.rawValue)")
                    }
                }
                
            case 3:
                Text("Parent")
                    .sheet(isPresented: .constant(true)) {
                        PresentationReader { kind in
                            Text("Hello! Kind: \(kind.rawValue)")
                        }
                    }
                
            default:
                fatalError("Unavailable")
            }
            
            Spacer()
        }
    }
}
enum Kind: String {
    case navigationView
    case root
    case sheet
}


struct PresentationReader<Content: View>: View {
    typealias PresentedContent = (Kind) -> Content
    
    @State private var kind: Kind = .root
    private let content: PresentedContent
    
    init(@ViewBuilder content: @escaping PresentedContent) {
        self.content = content
    }
    
    var body: some View {
        content(kind)
            .presentationReader(kind: $kind)
    }
}


extension View {
    func presentationReader(kind: Binding<Kind>) -> some View {
        self
            .introspectViewController { vc in
                let rootVC = UIApplication.shared.windows.first?.rootViewController
                let isRoot = vc === rootVC
                var isHosted: Bool { Introspect.findHostingView(from: vc.view) != nil }
                
                if isRoot {
                    kind.wrappedValue = .root
                } else if isHosted {
                    kind.wrappedValue = .navigationView
                } else {
                    kind.wrappedValue = .sheet
                }
            }
    }
}

It works by getting the current view controller the view is in.

  • If the class reference of the root view controller is the same as the current root view controller, this is the root view (meaning it isn't embedded in a NavigationView or .sheet(...)).
  • If this is not the root, we then check if this view is embedded in a hosting view. If it is, it is in a NavigationView.
  • If the view is neither the root or in a NavigationView, it is therefore in a .sheet(...).

This is now what your CustomNavigationBar will look like with these 3 changes:

struct CustomNavigationBar<Content>: View where Content: View {

    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @State private var kind: Kind = .root  // <--- CHANGE #1
    
    private let title: LocalizedStringKey
    private let content: (() -> Content)?

    private var backButton: AnyView? {

        let button = Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
            // custom image extension, just resolves to a back icon
            Image.Icons.arrowBack
        }

        if kind == .navigationView {  // <--- CHANGE #2
            return AnyView(button)
        } else {
            return nil
        }
    }

    public init(_ title: LocalizedStringKey, content: (() -> Content)? = nil) {
        self.title = title
        self.content = content
    }

    var body: some View {
        VStack {
            content?()
                .presentationReader(kind: $kind)  // <--- CHANGE #3
            
            Divider().foregroundColor(.gray)
        }.navigationBarTitle(title, displayMode: .large)
        .frame(minHeight: 96)
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: backButton)
    }
}
George
  • 25,988
  • 10
  • 79
  • 133
  • 1
    Wow that's a very interesting library. Thanks for the help George, this solution works. – Ben M Apr 15 '21 at 20:38
  • 1
    @BenM Glad it works! I quite like this package, it's one of the very few dependencies I use. Also great for fixing small SwiftUI bugs and doing more with SwiftUI than you are limited to. – George Apr 15 '21 at 20:41
  • Definitely. I have a few other solutions where I ended up writing a UIViewRepresentable implementation that I might switch over to this library with a SwiftUI impl. I really appreciate you taking the time to write up that example. – Ben M Apr 16 '21 at 06:06