Custom back button for NavigationView's navigation bar in SwiftUI
Asked Answered
F

14

86

I want to add a custom navigation button that will look somewhat like this:

desired navigation back button

Now, I've written a custom BackButton view for this. When applying that view as leading navigation bar item, by doing:

.navigationBarItems(leading: BackButton())

...the navigation view looks like this:

current navigation back button

I've played around with modifiers like:

.navigationBarItem(title: Text(""), titleDisplayMode: .automatic, hidesBackButton: true)

without any luck.

Question

How can I...

  1. set a view used as custom back button in the navigation bar? OR:
  2. programmatically pop the view back to its parent?
    When going for this approach, I could hide the navigation bar altogether using .navigationBarHidden(true)
Farnesol answered 12/6, 2019 at 22:58 Comment(1)
Improved version. (Swift, iOS 13 beta 4) #56854328Mallette
U
157

TL;DR

Use this to transition to your view:

NavigationLink(destination: SampleDetails()) {}

Add this to the view itself:

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

Then, in a button action or something, dismiss the view:

presentationMode.wrappedValue.dismiss()

Full code

From a parent, navigate using NavigationLink

 NavigationLink(destination: SampleDetails()) {}

In DetailsView hide navigationBarBackButton and set custom back button to leading navigationBarItem,

struct SampleDetails: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var btnBack : some View { Button(action: {
        self.presentationMode.wrappedValue.dismiss()
        }) {
            HStack {
            Image("ic_back") // set image here
                .aspectRatio(contentMode: .fit)
                .foregroundColor(.white)
                Text("Go back")
            }
        }
    }
    
    var body: some View {
            List {
                Text("sample code")
        }
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: btnBack)
    }
}
Understand answered 21/8, 2019 at 14:44 Comment(11)
this works great except presentationMode.value is now presentationMode.wrappedValue, however, this seems to disable the default swipe to go back behavior. any idea on how to enable it again?Seigniorage
any idea on how to add the swipe back?Virgievirgil
Thanks , work fine, but we have to take care with the image size. Always remember .resizable() method for custom frame size.Verdure
fixes swipe: https://mcmap.net/q/152987/-swiftui-navigationbarbackbuttonhidden-swipe-back-gestureForwhy
When you want to perform something .onAppear in Parent, with actual back button navigation it will work. But with your code back button, it will not workJilleen
the back button position is a bit right compared with expected, so I have to use .offset(x, -10). Then came another problem: there's no response if you touch the back button since you have the offset now. How can I solve the problem?Ephemera
@Ephemera I found wrapping the Button in an HStack with negative padding kept the touchable area the same. (If you set a background on your button, you'll see a difference compared to when you offset or pad the button itself.)Hilaire
With this you can get the original sized arrow: Image(systemName: "chevron.backward").imageScale(Image.Scale.large)Pyjamas
This solution has been deprecated for iOS 15. Use DismissAction now https://mcmap.net/q/151509/-custom-back-button-for-navigationview-39-s-navigation-bar-in-swiftuiHygienics
how do i make it so when you click the back button it will also refresh the home pageGoldberg
You can bind clouser or delegate on the back or even viewWillApper and ViewDidApper called and the previous controller. You can manage your insideUnderstand
L
35

SwiftUI - iOS 17

It looks like you can now combine the navigationBarBackButtonHidden and .toolbar to get the effect you're trying to achieve.

Code

struct Navigation_CustomBackButton: View {
    var body: some View {
        NavigationStack {
            NavigationLink("Go To Detail",
                           destination: Navigation_CustomBackButton_Detail())
            .font(.title)
            .navigationTitle("Navigation Views")
        }
    }
}
// Second Screen
struct Navigation_CustomBackButton_Detail: View {
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        VStack {
        }
        .navigationTitle("Detail View")
        .navigationBarBackButtonHidden(true)
        .toolbar {
            ToolbarItem(placement: .topBarLeading) {
                Button(action: {
                    dismiss()
                }) {
                    Label("Back", systemImage: "arrow.left.circle")
                }
            }
        }
    }
}

Example

Here is what it looks like (excerpt from the "SwiftUI Views Mastery" book): Custom Back Button in SwiftUI

👉 Note the warning on that page: You will lose swipe-back functionality if that is important to your users.

Lavellelaven answered 22/9, 2019 at 1:26 Comment(2)
This disables swipe to go back.Darin
Also this doesn't work in my case when I'm using a UIKitish navigation controller, with contained SwiftUI views as vcs. In one such subview I need to hide the nav bar completely, but still implement the back button in SwiftUI and still I want to keep the swipe-to-go-back feature functioning.Christ
H
26

iOS 15+

presentationMode.wrappedValue.dismiss() is now deprecated.

It's replaced by DismissAction

private struct SheetContents: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Button("Done") {
            dismiss()
        }
    }
}

You can create a custom back button that will use this dismiss action

struct CustomBackButton: View {
    let dismiss: DismissAction
    
    var body: some View {
        Button {
            dismiss()
        } label: {
            Image("...custom back button here")
        }
    }
}

then attach it to your view.

.navigationBarBackButtonHidden(true) // Hide default button
.navigationBarItems(leading: CustomBackButton(dismiss: self.dismiss)) // Attach custom button
Hygienics answered 21/6, 2022 at 16:21 Comment(5)
could you explain the correlation between the two block of codes?Cozy
@JavierHeisecke The top code block is just a standalone example of how to dismiss. The bottom two show how to add a custom back button that will dismiss when tapped.Hygienics
Straight to point. Correct answer!Masterwork
@Hygienics Doesn't work anymore :/Eohippus
@Eohippus What do you mean? We're actively using this in production with several hundred users. Check your iOS version. Are you in a NavigationView/NavigationStack? Make a minimal example. Comment out everything in your entire app besides the main WindowGroup and put this in to see if it's still broken.Hygienics
G
11

Based on other answers here, this is a simplified answer for Option 2 working for me in XCode 11.0:

struct DetailView: View {
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    var body: some View {

        Button(action: {
           self.presentationMode.wrappedValue.dismiss()
        }) {
            Image(systemName: "gobackward").padding()
        }
        .navigationBarHidden(true)

    }
}

Note: To get the NavigationBar to be hidden, I also needed to set and then hide the NavigationBar in ContentView.

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: DetailView()) {
                    Text("Link").padding()
                }
            } // Main VStack
            .navigationBarTitle("Home")
            .navigationBarHidden(true)

        } //NavigationView
    }
}
Grig answered 29/9, 2019 at 22:44 Comment(1)
Worked like a charm in XCode 11! ThanksChamaeleon
M
9

You can use UIAppearance for this:

if let image = UIImage(named: "back-button") {
    UINavigationBar.appearance().backIndicatorImage = image
    UINavigationBar.appearance().backIndicatorTransitionMaskImage = image
}

This should be added early on in your app like App.init. This also preserves the native swipe back functionality.

Magnesite answered 15/3, 2021 at 19:22 Comment(0)
V
8

Here's a more condensed version using principles shown in the other comments to change only the text of the button. The chevron.left icon can also be easily replaced with another icon.

Create your own button, then assign it using .navigationBarItems(). I found the following format most nearly approximated the default back button.

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

    var backButton : some View {
        Button(action: {
            self.presentationMode.wrappedValue.dismiss()
        }) {
            HStack(spacing: 0) {
                Image(systemName: "chevron.left")
                    .font(.title2)
                Text("Cancel")
            }
        }
    }

Make sure you use .navigationBarBackButtonHidden(true) to hide the default button and replace it with your own!

        List(series, id:\.self, selection: $selection) { series in
            Text(series.SeriesLabel)
        }
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: backButton)
Vauntcourier answered 30/6, 2021 at 19:41 Comment(1)
This works well but is there a better way so that backButton can be shared over several views?Last
N
6

I expect you want to use custom back button in all navigable screens, so I wrote custom wrapper based on @Ashish answer.

struct NavigationItemContainer<Content>: View where Content: View {
    private let content: () -> Content
    @Environment(\.presentationMode) var presentationMode

    private var btnBack : some View { Button(action: {
        self.presentationMode.wrappedValue.dismiss()
    }) {
        HStack {
            Image("back_icon") // set image here
                .aspectRatio(contentMode: .fit)
                .foregroundColor(.black)
            Text("Go back")
        }
        }
    }

    var body: some View {
        content()
            .navigationBarBackButtonHidden(true)
            .navigationBarItems(leading: btnBack)
    }

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

Wrap screen content in NavigationItemContainer:

Usage:

struct CreateAccountScreenView: View {
    var body: some View {
        NavigationItemContainer {
            VStack(spacing: 21) {
                AppLogoView()
                //...
            }
        }
    }
}
Neddra answered 1/12, 2019 at 18:17 Comment(1)
There's a problem in your CreateAccountScreenView. If you have another navigation trailing items within CreateAccountScreenView, then only trailing or leading navigation items. This is because you can not define navigationBarItems twice and only one worksEphemera
S
6

Swiping is not disabled this way.

Works for me. XCode 11.3.1

Put this in your root View

init() {
    UINavigationBar.appearance().isUserInteractionEnabled = false
    UINavigationBar.appearance().backgroundColor = .clear
    UINavigationBar.appearance().barTintColor = .clear
    UINavigationBar.appearance().setBackgroundImage(UIImage(), for: .default)
    UINavigationBar.appearance().shadowImage = UIImage()
    UINavigationBar.appearance().tintColor = .clear
}

And this in your child View

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

Button(action: {self.presentationMode.wrappedValue.dismiss()}) {
    Image(systemName: "gobackward")
}
Samellasameness answered 5/4, 2020 at 14:48 Comment(1)
Thanks! This is an excellent solution for iOS 13 and iOS 14. Working in Xcode 12.5.1Redletter
A
4

Really simple method. Only two lines code 🔥

@Environment(\.presentationMode) var presentationMode
self.presentationMode.wrappedValue.dismiss()

Example:

import SwiftUI

struct FirstView: View {
    @State var showSecondView = false
    
    var body: some View {
        NavigationLink(destination: SecondView(),isActive : self.$showSecondView){
            Text("Push to Second View")
        }
    }
}


struct SecondView : View{
    @Environment(\.presentationMode) var presentationMode

    var body : some View {    
        Button(action:{ self.presentationMode.wrappedValue.dismiss() }){
            Text("Go Back")    
        }    
    }
}
Arciform answered 12/7, 2020 at 16:14 Comment(0)
S
3

All of the solutions I see here seem to disable swipe to go back functionality to navigate to the previous page, so sharing a solution I found that maintains that functionality. You can make an extension of your root view and override your navigation style and call the function in the view initializer.

Sample View

struct SampleRootView: View {

    init() {
        overrideNavigationAppearance()
    }

    var body: some View {
        Text("Hello, World!")
    }
}

Extension

extension SampleRootView {
   func overrideNavigationAppearance() {
        let navigationBarAppearance = UINavigationBarAppearance()
        let barAppearace = UINavigationBar.appearance()
        barAppearace.tintColor = *desired UIColor for icon*
        barAppearace.barTintColor = *desired UIColor for icon*

        navigationBarAppearance.setBackIndicatorImage(*desired UIImage for custom icon*, transitionMaskImage: *desired UIImage for custom icon*)

        UINavigationBar.appearance().standardAppearance = navigationBarAppearance
        UINavigationBar.appearance().compactAppearance = navigationBarAppearance
        UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
   }
}

The only downfall to this approach is I haven't found a way to remove/change the text associated with the custom back button.

Seasick answered 16/1, 2020 at 6:3 Comment(0)
A
1

This solution works for iPhone. However, for iPad it won't work because of the splitView.

import SwiftUI

struct NavigationBackButton: View {
  var title: Text?
  @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>

  var body: some View {
    ZStack {
      VStack {
        ZStack {
          HStack {
            Button(action: {
              self.presentationMode.wrappedValue.dismiss()
            }) {
              Image(systemName: "chevron.left")
                .font(.title)
                .frame(width: 44, height: 44)
              title
            }
            Spacer()
          }
        }
        Spacer()
      }
    }
    .zIndex(1)
    .navigationBarTitle("")
    .navigationBarHidden(true)
  }
}

struct NavigationBackButton_Previews: PreviewProvider {
  static var previews: some View {
    NavigationBackButton()
  }
}
Abscind answered 5/5, 2020 at 10:38 Comment(0)
R
1

On iOS 14+ it's actually very easy using presentationMode variable

In this example NewItemView will get dismissed on addItem completion:

struct NewItemView: View {
    @State private var itemDescription:String = ""
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    
    
    var body: some View {
        VStack {
            TextEditor(text: $itemDescription)
        }.onTapGesture {
            hideKeyboard()
        }.toolbar {
            
            ToolbarItem {
                Button(action: addItem){
                    Text("Save")
                }
            }
            
        }.navigationTitle("Add Question")
        
    }
    private func addItem() {
        // Add save logic
        // ...
        
        // Dismiss on complete
        presentationMode.wrappedValue.dismiss()
    }
    
    private func hideKeyboard() {
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

struct NewItemView_Previews: PreviewProvider {
    static var previews: some View {
        NewItemView()
    }
}

In case you need the parent (Main) view:

struct SampleMainView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \DbQuestion.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>
    
    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("This is item detail page")
                    } label: {
                        Text("Item at \(item.id)")
                    }
                }
                
            }
            .toolbar {
                ToolbarItem {
                        // Creates a button on toolbar
                        NavigationLink {
                            // New Item Page
                            NewItemView()
                        } label: {
                            Text("Add item")
                        }
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
            }.navigationTitle("Main Screen")
            
        }
    }
}
Rapt answered 2/6, 2022 at 7:45 Comment(0)
E
0

I found this: https://ryanashcraft.me/swiftui-programmatic-navigation/

It does work, and it may lay the foundation for a state machine to control what is showing, but it is not a simple as it was before.

import Combine
import SwiftUI

struct DetailView: View {
    var onDismiss: () -> Void

    var body: some View {
        Button(
            "Here are details. Tap to go back.",
            action: self.onDismiss
        )
    }
}

struct RootView: View {
    var link: NavigationDestinationLink<DetailView>
    var publisher: AnyPublisher<Void, Never>

    init() {
        let publisher = PassthroughSubject<Void, Never>()
        self.link = NavigationDestinationLink(
            DetailView(onDismiss: { publisher.send() }),
            isDetail: false
        )
        self.publisher = publisher.eraseToAnyPublisher()
    }

    var body: some View {
        VStack {
            Button("I am root. Tap for more details.", action: {
                self.link.presented?.value = true
            })
        }
            .onReceive(publisher, perform: { _ in
                self.link.presented?.value = false
            })
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            RootView()
        }
    }
}

If you want to hide the button then you can replace the DetailView with this:

struct LocalDetailView: View {
    var onDismiss: () -> Void

    var body: some View {
        Button(
            "Here are details. Tap to go back.",
            action: self.onDismiss
        )
            .navigationBarItems(leading: Text(""))
    }
}
Endstopped answered 12/7, 2019 at 14:17 Comment(3)
You know you can edit your answers? Don't post a new one but click the little edit button at the bottom of your answer. Then, delete this one.Farnesol
Improved version. (Swift, iOS 13 beta 4) #56854328Mallette
Interesting that I get blasted for posting a link to the point of getting my account severely limited, but this last answer gets through perfectly.Endstopped
O
0

Just write this:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {

        }.onAppear() {
            UINavigationBar.appearance().tintColor = .clear
            UINavigationBar.appearance().backIndicatorImage = UIImage(named: "back")?.withRenderingMode(.alwaysOriginal)
            UINavigationBar.appearance().backIndicatorTransitionMaskImage = UIImage(named: "back")?.withRenderingMode(.alwaysOriginal)
        }
    }
}
Ori answered 31/8, 2021 at 12:31 Comment(1)
it kinda "works" but the new back button image is not aligned correctly. The code might need some adjustments. Also, maybe it might be better to put it inside the init() {}?Examen

© 2022 - 2024 — McMap. All rights reserved.