SwiftUI: componentes NavigationView y NavigationLink

Qué son NavigationView y NavigationLink

NavigationView

El componente NavigationView es un contenedor de vistas que permite crear una pila de navegación de pantallas que permite navegar entre ellas. Es un componente equivalente a UINavigationController de UIKit.

Aquí podéis consultar la documentación oficial

Es un componente nativo del sistema muy importante que normalmente estará presente en el 100% de los proyectos. Visualmente el componente tiene 3 partes:

  • NavigationBar. Barra superior que a su vez se compone de tres partes:
    • Leading Buttons. Botones para realizar acciones sobre la pantalla. Por lo general, aquí encontraremos el botón back que proporciona el propio sistema cuando estamos en un nivel de profundidad de la pila de pantallas superior a la primera.
    • Title. Título de la pantalla. Puede componerse de dos textos en el caso de los NavigationBar de tipo large
    • Trailing Buttons: Botones para realizar acciones sobre la pantalla.
  • Container. Vista a mostrar es la pantalla que debemos cargar con la información que queramos.
  • Toolbar. Barra inferior que permite añadir más botones para realizar acciones sobre la pantalla.

Estas propiedades se pueden personalizar a través de unos modificadores, que veremos más adelante.

Para usar el componente lo haremos de la siguiente forma:

NavigationView {
    ContentView() //This is your View to present
}

El componente solo tiene un closure de tipo ViewBuilder en el que se debe incluir la vista que queremos mostrar. Encapsulando una vista dentro del NavigationView tendremos la capacidad de mostrar u ocultar el NavigationBar y el Toolbar a través de modificadores y realizar la navegación entre pantallas.

struct ContentView: View {
    var body: some View {
        NavigationView {
            Group {
                Text("Hello, World!")
            }
            .navigationBarTitle("Simple", displayMode: .inline)
            .navigationBarItems(trailing:
                                    HStack {
                                        Button("Button 1") {
                                            //Do something
                                        }
                                        Button("Button 2") {
                                            //Do something
                                        }
                                    })
            .toolbar(items: {
                ToolbarItem(placement: .bottomBar) {
                    HStack {
                        Button("First") {
                            //Do something
                        }
                        Button("Second") {
                            //Do something
                        }
                    }
                }
            })
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(Color.yellow.opacity(0.3))
            
        }
    }
}

El NavigatinoView solo se incluye en la pantalla que inicia la navegación y no es necesario que las vistas a las que se navega incluyan otro NavigationView, ya que iniciaría una pila de navegación nueva.

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Text("First View")
                NavigationLink("Go to second view", destination: ContentSecondView())
            }
            .navigationBarTitle("First", displayMode: .inline)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(Color.yellow.opacity(0.3))
            
        }
    }
}

//No contains NavigationView
struct ContentSecondView: View {
    var body: some View {
        Group {
            Text("Second View")
        }
        .navigationBarTitle("Second", displayMode: .inline)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color.green.opacity(0.3))
    }
}

NavigationLink

Para realizar la navegación entre pantallas tendremos que usar el componente NavigationLink. Este componente es equivalente a usar los métodos pushViewController o popViewController de UINavigationController de UIKit.

Aquí podéis consultar la documentación oficial

Tiene el aspecto visual de un botón, pero su acción esta prefijada para que realice una navegación a la vista indicada.

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Text("First View")
                NavigationLink("Go to second view", destination: ContentSecondView())
            }
            .navigationBarTitle("First", displayMode: .inline)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(Color.yellow.opacity(0.3))
            
        }
    }
}

struct ContentSecondView: View {
    var body: some View {
        Group {
            Text("Second View")
        }
        .navigationBarTitle("Second", displayMode: .inline)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color.green.opacity(0.3))
    }
}

En este ejemplo el punto de entrada de la aplicación es la vista ContentView. Esta vista tiene un NavigationLink que navegará a la pantalla ContentSecondView y desde esta se podrá volver a la pantalla anterior pulsando sobre el botón < First, que aparecerá en el NavigationBar a la izquierda. Este botón lo proporciona el propio NavigationView al realizar la navegación.

Tal y como se ve, el funcionamiento de NavigationLink es muy sencillo en su forma básica, pero hay ocasiones que necesitaremos poder realizar una navegación o volver atrás sin tener que pulsar los botones que nos proporciona la propia SDK.

Cómo navegar sin pulsar un NavigationLink

Hay ocasiones que el desencadenante de la navegación puede ser algo ajeno a la pulsación del NavigationLink o al botón back del NavigationBar para volver atrás. Para estos casos tendremos que seguir usando el componente NavigationLink, pero con una serie de modificadores que nos permitirán activar o desactivar la navegación que ellos implementan.

struct ContentView: View {
    @State var navigateToSecond = false
    
    var body: some View {
        NavigationView {
            Group {
                VStack(spacing: 20) {
                    Text("First View")
                    Button("Go to second view") {
                        navigateToSecond = true
                    }
                }
                .background(
                    NavigationLink(
                        destination: Text("Second View"),
                        isActive: $navigateToSecond,
                        label: {
                            EmptyView()
                        })
                        .hidden()
                )
                .navigationBarTitle("First", displayMode: .inline)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                .background(Color.yellow.opacity(0.3))
            }
        }
    }
}

Como vemos, en esta ocasión el NavigationLink no se muestra porque devuelve una EmptyView. De esta forma es imposible pulsarlo.

También vemos que el parámetro isActive tiene una variable de estado de tipo Bool llamada navigateToSecond. Esta variable isActive es la encargada de indicar si se debe realizar la navegación (true) o no (false). Por lo tanto, lo único que necesitamos es un botón que modifique el estado de navigateToSecond a true para iniciar la navegación.

De la misma forma, si la pantalla destino (u otras partes del código) fuera capaz de modificar el valor de esta variable navigateToSecond a false, la navegación volvería atrás. Este caso lo vamos a ver a continuación.

Más ejemplos

Vamos a crear un ejemplo con tres pantallas donde no vamos a navegar con el NavigationLink directamente, ni con los botones back que crea el NavigationView.

  • ContentView: podrá navegar a ContentSecondView y a ContentThirdView.
  • ContentSecondView: podrá volver a atrás y navegar a ContentThirdView.
  • ContentThirdView: navegará atrás automáticamente pasados 5 segundos y podrá volver a la primera pantalla directamente cuando viene desde ContentSecondView.
import SwiftUI
import Combine

struct ContentView: View {
    @State var navigateToSecond = false
    @State var navigateToThird = false
    
    var body: some View {
        NavigationView {
            Group {
                VStack(spacing: 20) {
                    Text("First View")
                    Button("Go to second view") {
                        navigateToSecond = true
                    }
                    Button("Go to third view") {
                        navigateToThird = true
                    }
                }
                .background(
                    Group {
                        NavigationLink(
                            destination: ContentSecondView(navigateToSecond: $navigateToSecond),
                            isActive: $navigateToSecond,
                            label: {
                                EmptyView()
                            })
                        NavigationLink(
                            destination: ContentThirdView(navigateToThird: $navigateToThird),
                            isActive: $navigateToThird,
                            label: {
                                EmptyView()
                            })
                    }
                    .hidden()
                )
                .navigationBarTitle("First", displayMode: .inline)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                .background(Color.yellow.opacity(0.3))
            }
        }
    }
}

struct ContentSecondView: View {
    var navigateToSecond: Binding
    @State var navigateToThird = false
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Second View")
            Button("Go to third view") {
                navigateToThird = true
            }
            Button("Back") {
                navigateToSecond.wrappedValue = false
            }
        }
        .background(
            NavigationLink(
                destination: ContentThirdView(navigateToSecond: navigateToSecond, navigateToThird: $navigateToThird),
                isActive: $navigateToThird,
                label: {
                    EmptyView()
                })
        )
        .navigationBarTitle("Second", displayMode: .inline)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color.green.opacity(0.3))
    }
}

struct ContentThirdView: View {
    var navigateToSecond: Binding?
    @Binding var navigateToThird: Bool
    @ObservedObject private var vmTimer = TimerViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Third View")
            Text("Back automatically in \(5 - vmTimer.seconds) seconds")
                .onChange(of: vmTimer.seconds) { seconds in
                    if seconds == 5 {
                        navigateToThird = false
                    }
                }
            if let navigateToSecond = navigateToSecond {
                Button("Back to root") {
                    navigateToSecond.wrappedValue = false
                }
            }
        }
        .navigationBarTitle("Third", displayMode: .inline)
        .navigationBarBackButtonHidden(true)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color.red.opacity(0.3))
        .onAppear {
            self.vmTimer.setup()
        }
        .onDisappear {
            self.vmTimer.cleanup()
        }
        
    }
}

class TimerViewModel: ObservableObject {
    @Published var seconds = 0
    var subscriber: AnyCancellable?

    func setup() {
        self.seconds = 0
        self.subscriber = Timer
            .publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .sink(receiveValue: { _ in
                self.seconds += 1
            })
    }

    func cleanup() {
        self.subscriber = nil
    }
}

Para realizar la navegación tendremos que crear los NavigationLink que necesitemos, pero en este caso los crearemos ocultos (devolviendo un EmptyView). Estos NavigationLink tienen un parámetro de tipo Bool llamado isActive que indica si se ha realizado la navegación o no. Este valor es clave para realizar la navegación:

  • Cuando el valor es true se realiza la navegación a la vista indicada en el NavigationLink.
  • Cuando el valor es false se regresa a la pantalla que contiene el NavigationLink.

Por lo tanto, este valor es una variable de estado que tendrá que pasarse a la pantalla de destino para que desde ésta podamos volver atrás 'programáticamente' 'seteando' su valor a false.

Esta variable de estado la podemos traspasar a la vista que deseemos (a través de varias vistas si queremos), lo que provocará que cuando pongamos su valor a false volvamos a la vista que contenga el NavigationLink, como en el ejemplo de la variable navigateToSecond de ContentThirdView.

Modificadores comunes para NavigationView

A parte de los modificadores que se explicarán a continuación, el componente NavigationView comparte los mismos métodos de personalización que el componente View y pueden ser consultados en el siguiente enlace.

navigationBarTitle

Permite indicar el título y el tipo de presentación de la barra de navegación superior.

NavigationView {
    VStack(spacing: 15) {
        Text("Hello, World!")
        Text("navigationBarTitle")
    }
    .navigationBarTitle("Custom title and displayMode", displayMode: .large)
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
    .background(Color.yellow.opacity(0.3))
}

El parámetro displayMode permite que la barra de navegación se muestre extendida (normalmente se usa para pantallas de primer nivel) o compacta.

Para que este modificador tenga efecto sobre el NavigationView se debe aplicar sobre una vista que esté dentro de él y no sobre el propio componente.

navigationBarTitleDisplayMode

Permite indicar el tipo de presentación de la barra de navegación superior.

NavigationView {
    VStack(spacing: 15) {
        Text("Hello, World!")
        Text("navigationBarTitleDisplayMode")
    }
    .navigationBarTitle("Custom displayMode")
    .navigationBarTitleDisplayMode(.inline)
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
    .background(Color.yellow.opacity(0.3))
}

El parámetro displayMode permite que la barra de navegación se muestre extendida (normalmente se usa para pantallas de primer nivel) o compacta.

Para que este modificador tenga efecto sobre el NavigationView se debe aplicar sobre una vista que esté dentro de él y no sobre el propio componente.

navigationViewStyle

Permite seleccionar el modo de presentación de la pila de navegación. Con este modificador podemos conseguir una presentación clásica como UINavigationController, donde solo se mostrará la vista que esté más arriba de la pila de navegación, o como un UISplitViewController, donde se mostrarán las vistas a doble columna cuando el contexto lo permita (cómo en iPad).

NavigationView {
    VStack(spacing: 15) {
        Text("Hello, World!")
        Text("navigationViewStyle")
    }
    .navigationViewStyle(DoubleColumnNavigationViewStyle())
    .navigationBarTitle("Custom navigationViewStyle")
    .navigationBarTitleDisplayMode(.inline)
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
    .background(Color.yellow.opacity(0.3))
}

Los valores de navigationViewStyle tienen que implementar el protocolo NavigationViewStyle. Podéis encontrar su documentación aquí.

Por defecto podemos encontrar las siguientes implementaciones:

  • StackNavigationViewStyle. Estilo que solo muestra la última vista del stack de navegación.
  • DoubleColumnNavigationViewStyle. Estilo que muestra una doble columna con un stack que navega a una vista de detalle.
  • DefaultNavigationViewStyle. Este estilo alterna entre los dos anteriores, dependiendo del dispositivo donde se ejecute. En iPhone usará StackNavigationViewStyle, mientras que en iPad usará DoubleColumnNavigationViewStyle.

Importante: actualmente el estilo DoubleColumnNavigationViewStyle tiene algunas limitaciones de uso que no lo equiparan completamente a un UISplitViewController. Podéis ver más información en este hilo.

navigationBarBackButtonHidden

Permite ocultar el botón de navegación que proporciona el componente NavigationView para volver a la pantalla anterior. Este botón nunca aparece cuando es la primera pantalla del stack.

struct ContentView: View {
    @State var showDetail: Bool = false
    var body: some View {
        VStack {
            NavigationView {
                VStack(spacing: 15) {
                    Text("Hello, World!")
                    Text("navigationBarBackButtonHidden")
                    NavigationLink("Show Detail", destination: ContentSecondView(showDetail: $showDetail), isActive: $showDetail)
                }
                .navigationViewStyle(StackNavigationViewStyle())
                .navigationBarTitle("First", displayMode: .inline)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                .background(Color.yellow.opacity(0.3))
                
            }
            .border(Color.black, width: 1)
        }
        .navigationBarTitle("Style 3", displayMode: .inline)
        .navigationColor(background: UIColor(red: 31/255, green: 155/255, blue: 222/255, alpha: 1), title: .white)
        .padding()
    }
}

struct ContentSecondView: View {
    @Binding var showDetail: Bool
    
    var body: some View {
        VStack {
                VStack(spacing: 15) {
                    Text("Hello, World!")
                    Text("navigationBarBackButtonHidden")
                    Button("Back") {
                        showDetail.toggle()
                    }
                }
                .navigationBarTitle("Second", displayMode: .inline)
                .navigationBarBackButtonHidden(true)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                .background(Color.yellow.opacity(0.3))
        }
    }
}

Para que este modificador tenga efecto sobre el NavigationView se debe aplicar sobre una vista que esté dentro de él, y no sobre el propio componente.

navigationBarItems

Permite personalizar los botones de la barra de navegación superior del NavigationView. Podemos personalizar los botones que aparecerán a la derecha o a la izquierda de la barra de navegación con vistas personalizadas.

NavigationView {
    VStack(spacing: 15) {
        Text("Hello, World!")
        Text("navigationBarItems")
    }
    .navigationViewStyle(StackNavigationViewStyle())
    .navigationBarTitle("First", displayMode: .inline)
    .navigationBarItems(leading: HStack {
        Button("Left 1") {
            //Do Something
        }
        .buttonStyle(PlainButtonStyle())
        Button("Left 2") {
            //Do Something
        }
    }, trailing: HStack {
        Button("Right 1") {
            //Do Something
        }
        Button("Right 2") {
            //Do Something
        }
    })
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
    .background(Color.yellow.opacity(0.3))
    
}

Para que este modificador tenga efecto sobre el NavigationView se debe aplicar sobre una vista que esté dentro de él, y no sobre el propio componente.

toolbar (iOS 14)

Este nuevo modificador introducido en iOS 14 nos permite mayor personalización sobre la barra de navegación del NavigationView. Actúa como un sustituto del modificador navigationBarItems y, además, permite un mayor nivel de personalización como, por ejemplo, añadir una toolbar inferior en la vista.

NavigationView {
    VStack(spacing: 15) {
        Text("Hello, World!")
        Text("navigationBarItems")
    }
    .navigationViewStyle(StackNavigationViewStyle())
    .navigationBarTitle("First", displayMode: .inline)
    .toolbar(content: {
        ToolbarItem(placement: .bottomBar) {
            HStack {
                Button("First") {
                    //Do something
                }
                Button("Second") {
                    //Do something
                }
            }
        }
        ToolbarItem(placement: .navigationBarTrailing) {
            HStack {
                Button("Right 1") {
                    //Do something
                }
                Button("Right 2") {
                    //Do something
                }
            }
        }
    })
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
    .background(Color.yellow.opacity(0.3))
    
}

Para que este modificador tenga efecto sobre el NavigationView se debe aplicar sobre una vista que esté dentro de él, y no sobre el propio componente.

Modificadores comunes para NavigationLink

A efectos visuales el componente NavigationLink se comporta como un Button, por lo que comparte los mismos modificadores que este componente. Puedes consultar su documentación aquí.

Cómo modificar el color del NavigationBar de NavigationView

Actualmente no hay una API para la personalización del NavigationBar de un NavigationView, por lo que para conseguir personalizar su aspecto visual tenemos que recurrir al protocolo UIViewControllerRepresentable, y así conseguir aplicar la personalización.

Nuestro objetivo será extraer el componente UINavigationBar que se crea cuando usamos un NavigationView, y de esta forma poder personalizarlo como si se tratase de un componente de UIKit.

import SwiftUI

struct NavigationConfigurator: UIViewControllerRepresentable {
    let backgroundColor: UIColor
    let titleColor: UIColor
    
    func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIViewController {
        context.coordinator
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext) {
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(backgroundColor: backgroundColor, titleColor: titleColor)
    }
    
    class Coordinator: UIViewController {
        let backgroundColor: UIColor
        let titleColor: UIColor
        
        init(backgroundColor: UIColor, titleColor: UIColor) {
            self.backgroundColor = backgroundColor
            self.titleColor = titleColor
            super.init(nibName: nil, bundle: nil)
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            
            var items = [UINavigationController]()
            
            if let sv = self.splitViewController {
                if let nc = sv.viewControllers.last as? UINavigationController, !items.contains(nc), sv.viewControllers.first != self.navigationController {
                    items.append(nc)
                }
            }
            
            if let nc = self.navigationController, !items.contains(nc) {
                items.append(nc)
            }
            
            applyStyle(items)
        }
        
        private func applyStyle(_ items: [UINavigationController]) {
            items.forEach{ nc in
                //Without this, appearance not applied
                let last = nc.navigationBar.barTintColor
                nc.navigationBar.barTintColor = .red
                nc.navigationBar.barTintColor = .white
                nc.navigationBar.barTintColor = last
                //----------
                
                let navBarAppearance = UINavigationBarAppearance()
                navBarAppearance.configureWithOpaqkground()
                navBarAppearance.titleTextAttributes = [.foregroundColor: self.titleColor]
                navBarAppearance.largeTitleTextAttributes = [.foregroundColor: self.titleColor]
                navBarAppearance.backgroundColor = self.backgroundColor
                
                nc.navigationBar.standardAppearance = navBarAppearance
                nc.navigationBar.scrollEdgeAppearance = navBarAppearance
                nc.navigationBar.compactAppearance = navBarAppearance
            }
            
            DispatchQueue.main.async {
                items.forEach{ nc in
                    nc.navigationBar.tintColor = self.titleColor
                }
            }
            view.setNeedsLayout()
            view.layoutIfNeeded()
            
        }
    }
}

En nuestro ejemplo el componente NavigationConfigurator permitirá personalizar el color de fondo y del texto. Para ello, en el método viewWillAppear extraemos el UINavigationController que tiene la vista para poder aplicarle los estilos en el método applyStyle.

Para usarlo vamos a crear una extensión de View para que sea más fácil.

import SwiftUI

extension View {
    func navigationColor(background: UIColor, title: UIColor) -> some View {
        return self
            .background(NavigationConfigurator(backgroundColor: background, titleColor: title))
    }
}

De esta forma sólo tendríamos que invocar al modificador navigationColor sobre una vista que contenga el NavigationView.

struct ContentFirstView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 20) {
                Text("Hello, World!")
                NavigationLink("Go to Second", destination: ContentSecondView())
            }
            .navigationBarTitle("First", displayMode: .large)
            .navigationColor(background: UIColor.green.withAlphaComponent(0.5), title: .white)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(Color.yellow.opacity(0.3))
            
        }
    }
}

struct ContentSecondView: View {
    var body: some View {
        Group {
            Text("Hello, World!")
        }
        .navigationBarTitle("Second", displayMode: .inline)
        .navigationColor(background: UIColor.blue.withAlphaComponent(0.5), title: .white)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color.red.opacity(0.3))
        
    }
}

Ejemplo

Puedes encontrar este ejemplo en  github.com  bajo el apartado  NavigationView

Rafael Fernández,
iOS Tech Lider