[SUI] Master detail con NavigationSplitView

# swiftui# swift# ios
[SUI] Master detail con NavigationSplitViewGoyesDev

NavigationSplitView permite encajar dos o tres vistas en una pantalla bajo el patrón...

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:

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. El título del Item
  2. Un botón con un NavigationLink, que navega hacia una vista minúcula 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.
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

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 escape 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.

// ⚠️ 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

Navegación manual entre sidebar y detail

Un NavigationStack dentro del "sidebar" también puede navegar usando un NavigationLink, pero presentando el contenido en la región del "detail". Esto se logra modificando el NavigationLink con isDetailLink(_:), pasando true como argumento, o simplemente no modificándolo.

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.

// ⚠️ 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

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

  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(path: $navigationPath) {
          DetailView(item: selected)
        }
      } else {
        Placeholder()
      }
    }
    .onChange(of: selectedId) {
      navigationPath = NavigationPath()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Bibliografía