Kevin Renskers
Kevin Renskers

Reputation: 5912

Show leading navigationBarItems button only if shown as a modal

I have a view that can be shown either as a modal, or simply pushed onto a navigation stack. When it's pushed, it has the back button in the top left, and when it's shown as a modal, I want to add a close button (many of my testers were not easily able to figure out that they could slide down the modal and really expected an explicit close button).

Now, I have multiple problems.

  1. How do I figure out if a View is shown modally or not? Or alternatively, if it's not the first view on a navigation stack? In UIKit there are multiple ways to easily do this. Adding a presentationMode @Environment variable doesn't help, because its isPresented value is also true for pushed screens. I could of course pass in a isModal variable myself but it seems weird that's the only way?
  2. How do I conditionally add a leading navigationBarItem? The problem is that if you give nil, even the default back button is hidden.

Code to copy and paste into Xcode and play around with:

import SwiftUI

struct ContentView: View {
  @State private var showModal = false

  var body: some View {
    NavigationView {
      VStack(spacing: 20) {
        Button("Open modally") {
          self.showModal = true
        }

        NavigationLink("Push", destination: DetailView(isModal: false))
      }
      .navigationBarTitle("Home")
    }
    .sheet(isPresented: $showModal) {
      NavigationView {
        DetailView(isModal: true)
      }
    }
  }
}

struct DetailView: View {
  @Environment(\.presentationMode) private var presentationMode
  let isModal: Bool

  var body: some View {
    Text("Hello World")
      .navigationBarTitle(Text("Detail"), displayMode: .inline)
      .navigationBarItems(leading: closeButton, trailing: deleteButton)
  }

  private var closeButton: some View {
    Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
      Image(systemName: "xmark")
        .frame(height: 36)
    }
  }

  private var deleteButton: some View {
    Button(action: { print("DELETE") }) {
      Image(systemName: "trash")
        .frame(height: 36)
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

If I change closeButton to return an optional AnyView? and then return nil when isModal is false, I don't get a back button at all. I also can't call navigationBarItems twice, once with a leading and once with a trailing button, because the latter call overrides the first call. I'm kinda stuck here.

Upvotes: 1

Views: 1149

Answers (3)

user3441734
user3441734

Reputation: 17544

I don't see any trouble, just add Dismiss button to your navigation bar. You only have to rearrange your View hierarchy and there is no need to pass any binding to your DetailView

import SwiftUI

struct DetailView: View {
    var body: some View {
        Text("Detail View")
    }
}
struct ContentView: View {
    @State var sheet = false
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Button("Open modally") {
                    self.sheet = true
                }

                NavigationLink("Push", destination: DetailView())
            }.navigationBarTitle("Home")
        }
        .sheet(isPresented: $sheet) {
            NavigationView {
                DetailView().navigationBarTitle("Title").navigationBarItems(leading: Button(action: {
                    self.sheet.toggle()
                }, label: {
                    Text("Dismiss")
                }))
            }
        }
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

enter image description here

You still can dismiss it with swipe down, you can add some buttons (as part of DetailView declaration) ... etc.

When pushed, you have default back button, if shown modaly, you have dismiss button indeed.

UPDATE (based od discussion)

.sheet(isPresented: $sheet) {
    NavigationView {
        GeometryReader { proxy in
            DetailView().navigationBarTitle("Title")
                .navigationBarItems(leading:
                    HStack {

                        Button(action: {
                            self.sheet.toggle()
                        }, label: {
                            Text("Dismiss").padding(.horizontal)
                        })
                        Color.clear
                        Button(action: {

                        }, label: {
                            Image(systemName: "trash")
                                .imageScale(.large)
                                .padding(.horizontal)
                        })

                    }.frame(width: proxy.size.width)
            )
        }
    }
}

enter image description here

finally I suggest you to use

extension View {
    @available(watchOS, unavailable)
    public func navigationBarItems<L, T>(leading: L?, trailing: T) -> some View where L : View, T : View {
        Group {
            if leading != nil {
                self.navigationBarItems(leading: leading!, trailing: trailing)
            } else {
                self.navigationBarItems(trailing: trailing)
            }
        }
    }
}

Upvotes: 1

Asperi
Asperi

Reputation: 257789

Whenever we provide .navigationBarItems(leading: _anything_), ie anything, the standard back button has gone, so you have to provide your own back button conditionally.

The following approach works (tested with Xcode 11.2 / iOS 13.2)

    .navigationBarItems(leading: Group {
        if isModal {
            closeButton
        } else {
            // custom back button here calling same dismiss
        }
    }, trailing: deleteButton)

Update: alternate approach might be as follows (tested in same)

var body: some View {
    VStack {
        if isModal {
            Text("Hello")
                .navigationBarItems(leading: closeButton, trailing: deleteButton)
        } else {
            Text("Hello")
                .navigationBarItems(trailing: deleteButton)
        }
    }
    .navigationBarTitle("Test", displayMode: .inline)
}

Upvotes: 0

Kevin Renskers
Kevin Renskers

Reputation: 5912

Okay, I managed it. It's not pretty and I am very much open to different suggestions, but it works 😅

import SwiftUI

extension View {
  func eraseToAnyView() -> AnyView {
    AnyView(self)
  }

  public func conditionalNavigationBarItems(_ condition: Bool, leading: AnyView, trailing: AnyView) -> some View {
    Group {
      if condition {
        self.navigationBarItems(leading: leading, trailing: trailing)
      } else {
        self
      }
    }
  }
}

struct ContentView: View {
  @State private var showModal = false

  var body: some View {
    NavigationView {
      VStack(spacing: 20) {
        Button("Open modally") {
          self.showModal = true
        }

        NavigationLink("Push", destination: DetailView(isModal: false))
      }
      .navigationBarTitle("Home")
    }
    .sheet(isPresented: $showModal) {
      NavigationView {
        DetailView(isModal: true)
      }
    }
  }
}

struct DetailView: View {
  @Environment(\.presentationMode) private var presentationMode
  let isModal: Bool

  var body: some View {
    Text("Hello World")
      .navigationBarTitle(Text("Detail"), displayMode: .inline)
      .navigationBarItems(trailing: deleteButton)
      .conditionalNavigationBarItems(isModal, leading: closeButton, trailing: deleteButton)
  }

  private var closeButton: AnyView {
    Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
      Image(systemName: "xmark")
        .frame(height: 36)
    }.eraseToAnyView()
  }

  private var deleteButton: AnyView {
    Button(action: { print("DELETE") }) {
      Image(systemName: "trash")
        .frame(height: 36)
    }.eraseToAnyView()
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Upvotes: 1

Related Questions