DEV Community

GoyesDev
GoyesDev

Posted on

[SUI] Master detail con NavigationSplitView

NavigationSplitView permite encajar dos o tres vistas en una pantalla bajo el patrón "sidebar"/"content"/"detail". Si no caben, entonces el sistema se encarga de generar la navegación entre pantallas por su cuenta.

Un NavigationLink en la columna del "sidebar" actualiza el contenido de la columna "content", y un NavigationLink en "content" actualiza la columna "detail".

En este artículo vamos a ver cómo implementar el patrón master/detail con NavigationSplitView usando solo dos columnas.

Los datos

Para empezar, necesitamos una estructura de datos Identifiable que vamos a pintar en la lista de la sección "master", del "sidebar" del NavigationSpliView.

struct Item: Identifiable, Hashable {
  let id: UUID = UUID()
  let title: String
  let children: [Item]?
}
Enter fullscreen mode Exit fullscreen mode

Vamos a crear nuestro dataset. Intencionalmente se hace con tres niveles de jerarquía para que el mismo dataset sirva para el ejemplo de NavigationSplitView con tres columnas:

let items: [Item] = [
  .init(title: "Súpergrupo 1", children: [
    .init(title: "Grupo 1", children: [
      .init(title: "Hoja 1", children: nil),
      .init(title: "Hoja 2", children: nil),
      .init(title: "Hoja 3", children: nil),
    ])
  ]),
  .init(title: "Súpergrupo 2", children: [
    .init(title: "Grupo 2", children: [
      .init(title: "Hoja 4", children: nil),
      .init(title: "Hoja 5", children: nil),
      .init(title: "Hoja 6", children: nil),
    ]),
    .init(title: "Grupo 3", children: [
      .init(title: "Hoja 6", children: nil),
    ]),
  ]),
  .init(title: "Súpergrupo 3", children: [
    .init(title: "Grupo 4", children: [
      .init(title: "Hoja 7", children: nil),
    ]),
    .init(title: "Grupo 5", children: [
      .init(title: "Hoja 7", children: nil),
      .init(title: "Hoja 8", children: nil),
    ])
  ]),
]
Enter fullscreen mode Exit fullscreen mode

Vista detalle

Cuando seleccionemos un elemento de la lista del "sidebar" queremos mostrar la vista DetailView, que recibe un Item y pinta:

  1. Un título general ("Vista detalle")
  2. El nombre de la clase de tamaño horizontal horizontalSizeClass actual - "Regular" o "Compact".
  3. El title del Item
  4. Un botón, construido con un NavigationLink, que al ser presionado navega hacia una vista trivial compuesta de un Text que muestra el título del primer hijo del Item seleccionado. Este NavigationLink servirá como ejemplo para ilustrar cómo se efectúa una navegación dentro de la vista "detail". Si no se quiere navegar en esta sección del NavigationSplitView simplemente se puede borrar el NavigationLink.

NOTA DE VITAL IMPORTANCIA: Si se agrega un NavigationLink SE DEBE envolver con un NavigationStack en algún punto de la jerarquía (esto lo vamos a hacer en ContentView)

struct DetailView: View {
  @Environment(\.horizontalSizeClass) var horizontalSizeClass
  let item: Item

  var body: some View {
    VStack {
      Text("Vista detalle")
        .font(.title)
        .bold()
      Text(horizontalSizeClass == .compact ? "Compacto" : "Regular")
      Text(item.title)
      NavigationLink("Navegar al primer hijo") {
        Text("El primer hijo de \(item.title) es \(item.children?.first?.title ?? "nulo")")
      }

    }
    .navigationTitle("DetailView: \(item.id)")
  }
}
Enter fullscreen mode Exit fullscreen mode

Vista de relleno

Cuando no haya ningún elemento seleccionado en el sidebar, vamos a mostrar una vista Placeholder.

struct Placeholder: View {
  var body: some View {
    Text("Placeholder")
  }
}
Enter fullscreen mode Exit fullscreen mode

Asumiendo que DetailView no tiene ningún NavigationLink en su interior, el Placeholder se puede usar de la siguiente manera:

NavigationSplitView {
// ...
} detail: {
  if let selectedId, let selected = items.first(where: { $0.id == selectedId }) {
    DetailView(item: selected)
  } else {
    Placeholder()
  }
}
Enter fullscreen mode Exit fullscreen mode

En este caso, primero se debe buscar el Item asociado al id seleccionado (selectedId) para poder pintar a DetailView. Si selectedId es nil, entonces se pinta la vista Placeholder.

Navegación dentro del "Detail"

Si se usa un NavigationLink en una vista, se debe envolver con un NavigationStack en algún punto. La implementación más directa sería envolver el DetailView con un NavigationStack dentro del llamado del bloque detail del NavigationSplitView:

NavigationSplitView {
// ...
} detail: {
  if let selectedId, let selected = items.first(where: { $0.id == selectedId }) {
    NavigationStack {
      DetailView(item: selected)
    }
  } else {
    Placeholder()
  }
}
Enter fullscreen mode Exit fullscreen mode

En caso de que se necesite controlar el NavigationPath de forma programática, entonces se le puede inyectar uno al NavigationStack sin problema.

Tener en cuenta, al manipular el NavigationPath manualmente, que al cambiar el selectedId muy seguramente sea necesario reiniciar el path. Una solución a este problema podría ser observar cambios sobre selectedId con onChange(of:initial:_:) para re-inicializar la variable navigationPath como se muestra a continuación:

@State private var navigationPath = NavigationPath()
// ...
NavigationSplitView {
// ...
} detail: {
  if let selectedId, let selected = items.first(where: { $0.id == selectedId }) {
    NavigationStack(path: $navigationPath) {
      DetailView(item: selected, path: $navigationPath)
    }
  } else {
    Placeholder()
  }
}
.onChange(of: selectedId) {
  navigationPath = NavigationPath()
}
Enter fullscreen mode Exit fullscreen mode

NOTA: si no importa manejar el NavigationPath y tampoco se necesita guardar su estado, se puede prescindir de él, y del código agregado para re-inicializarlo.

Sidebar

La barra lateral se puede construir con una lista simple, que almacena un solo valor seleccionado. Tener presente que al pasar selection al List, su tipo de dato debe corresponder al del identificador de los elementos de la lista, en este caso Item.ID. Si solo se quiere seleccionar un elemento, la propiedad será opcional. Si se quisieran seleccionar varios, se usaría Set<Item.ID>. Lo importante a destacar acá es que la selección de elementos de un List se hace con IDENTIFICADORES.

// ...
@State private var selectedId: Item.ID?

// ...

List(items, selection: $selectedId) { supergroup  in
  Text(supergroup.title)
}
.listStyle(.sidebar) // Esto no es necesario, solo ilustrativo en el Preview
.navigationTitle("Supergroups")
Enter fullscreen mode Exit fullscreen mode

Navegación dentro del mismo sidebar

Aunque lo siguiente escapa de la definición original del patrón master/detail, la vista del sidebar puede navegar hacia otra dentro del mismo sidebar, si se envuelve dentro de un NavigationStack. En este caso ya no importa tanto el selection del List.

Para generar la navegación interna dentro del sidebar se debe usar un NavigationLink y modificarlo con isDetailLink(_:), pasando false como argumento. Recordar que para agregar el NavigationLink debemos envolver todo con un NavigationStack.

// ⚠️ Se envuelve con NavigationStack
NavigationStack {
  List(items, selection: $selectedId) { supergroup  in
    Text(supergroup.title)
  }
  .listStyle(.sidebar)
  .navigationTitle("Supergroups")

  NavigationLink("Navegacion interna 1") {
    Text("Estoy en otro lado 1")
  }
  .isDetailLink(false)

  NavigationLink("Navegacion interna 2") {
    Text("Estoy en otro lado 2")
  }
  .isDetailLink(false)
}
Enter fullscreen mode Exit fullscreen mode

Al presionar alguno de los NavigationLinks ocurre una navegación dentro del mismo "sidebar".

Navegación manual entre sidebar y detail

Un NavigationStack dentro del "sidebar" también puede navegar a través de un NavigationLink, pero presentando el contenido en la región del "detail". Esto se logra modificando el NavigationLink con isDetailLink(_:), pasando true como argumento. Este es su valor por defecto, así que incluso también se consigue el mismo resultado si no se aplica el modificador.

Por otro lado, es necesario atrapar el enlace de navegación con navigationDestination(for:destination:) sobre el NavigationStack responsable de envolver el NavigationLink en cuestión. Dentro del bloque destination se define la vista destino, como lo haríamos en el bloque detail de NavigationSplitView.

// ⚠️ Se envuelve con NavigationStack
NavigationStack {
  // ...

  NavigationLink("Navegacion sobre detail 1", value: items.first!.id)
  .isDetailLink(true)
}
.navigationDestination(for: UUID.self) { id in
  Text("Destino manual de detalle")
}
Enter fullscreen mode Exit fullscreen mode

Montaje de la estructura principal

Vistas las partes anteriores, a continuación se presenta el código completo de ContentView, donde se relacionan el NavigationSplitView el "sidebar" y el "detail":

struct ContentView: View {
  @State private var selectedId: Item.ID?
  @State private var visibility: NavigationSplitViewVisibility = .automatic

  var body: some View {
    NavigationSplitView(columnVisibility: $visibility) {
      NavigationStack {
        List(items, selection: $selectedId) { supergroup  in
          Text(supergroup.title)
        }
        .listStyle(.sidebar)
        .navigationTitle("Supergroups")

        NavigationLink("Navegacion sobre detail 1", value: items.first!.id)
          .isDetailLink(true)

        NavigationLink("Navegacion interna 1") {
          Text("Estoy en otro lado 1")
        }
        .isDetailLink(false)

        NavigationLink("Navegacion interna 2") {
          Text("Estoy en otro lado 2")
        }
        .isDetailLink(false)
      }
      .navigationDestination(for: UUID.self) { id in
        Text("Destino manual de detalle")
      }
    } detail: {
      if let selectedId, let selected = items.first(where: { $0.id == selectedId }) {
        NavigationStack {
          DetailView(item: selected)
        }
      } else {
        Placeholder()
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Al seleccionar un elemento de la lista del sidebar, se pinta en el detalle la vista DetailView.

Al presionar el botón de navegación dentro de DetailView se navega a la siguiente vista, dentro de "detail".

Si se presiona uno de los NavigationLinks internos del "sidebar", ocurre una navegación interna dentro del "sidebar":


Bibliografía

Top comments (0)