Con la parte 2 tenemos una lista sólida: paginada, con error visible y reintento. Ahora abrimos el detalle. La lista ya trae id, nombre, tipos y sprite, pero el detalle enseña más: estadísticas, habilidades, altura y peso. Eso se pide por separado al /pokemon/{id} de la PokeAPI.
El interés real del post no es esa segunda petición, sino cómo se modela la navegación en TCA. En la serie de PokeTracker usamos NavigationLink(value:) con navigationDestination(for:), que es lo idiomático en SwiftUI puro. En TCA la navegación vive en el estado, y eso cambia cómo pensamos el flujo.
Por qué navegación como estado
Un NavigationLink decide navegar en el momento del toque. Es rápido de escribir y funciona, pero deja el estado de la app repartido: la vista sabe si el detalle está abierto, el estado no. Consecuencias:
- No puedes abrir el detalle desde código sin trucos.
- Un deep link que quiera aterrizar en el detalle tiene que reproducir el toque.
- Un test de “abrir el detalle” requiere el simulador.
- Restaurar la app en el detalle tras un cierre es difícil.
TCA propone que el detalle sea un valor en el estado del padre. Si está presente, se muestra; si es nil, se cierra. Abrir es asignar, cerrar es poner a nil. Todo lo demás se hereda gratis: se puede abrir desde código, un deep link solo tiene que crear el estado correcto, un test lo verifica sin tocar la pantalla, y la restauración es serializar y deserializar el estado.
El destino como enum
Aunque de momento solo tengamos un destino (el detalle), lo modelo como un enum. La razón es que las apps rara vez se quedan en una sola pantalla presentable, y agregar una segunda (por ejemplo, una hoja modal de compartir) sobre un enum es trivial. Sobre un @Presents var detail: PokemonDetailFeature.State? de un solo tipo, obliga a refactor.
El patrón moderno de TCA es:
@Reducer
enum Destination {
case detail(PokemonDetailFeature)
}
Esa macro genera Destination.State y Destination.Action con un caso por cada reducer hijo. En el padre queda así:
@Reducer
struct PokemonListFeature {
@ObservableState
struct State: Equatable {
var loadState: LoadState<[Pokemon]> = .idle
var isLoadingMore = false
var canLoadMore = true
@Presents var destination: Destination.State?
}
enum Action {
// ... las acciones de la parte 2 ...
case rowTapped(Pokemon)
case destination(PresentationAction<Destination.Action>)
}
}
@Presents marca que ese estado es presentable, y PresentationAction es un wrapper que agrega automáticamente el caso .dismiss para cerrar. En el body del reducer, ifLet hace el trabajo real:
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
// ... casos de la parte 2 ...
case let .rowTapped(pokemon):
state.destination = .detail(PokemonDetailFeature.State(pokemon: pokemon))
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination) {
Destination()
}
}
Ese ifLet hace tres cosas que a mano se olvidan y no volvemos a hablar de ellas:
- Corre el reducer del hijo solo cuando hay algo presentado.
- Cancela automáticamente todos los efectos del hijo cuando el usuario descarta la pantalla. Si el detalle estaba en medio de una petición y el usuario vuelve, esa petición se cancela sola.
- Le da al hijo la posibilidad de descartarse a sí mismo con
@Dependency(\.dismiss).
Ese punto 2 es el que en la Pokédex MVVM tocaba a mano: cancelar el Task en onDisappear. Aquí lo hace el framework.
El DetailFeature
Ya con el estado inicial (el Pokemon de la lista), el hijo pide el detalle completo:
@Reducer
struct PokemonDetailFeature {
@ObservableState
struct State: Equatable {
let pokemon: Pokemon
var extra: LoadState<PokemonDetail> = .idle
}
enum Action {
case onAppear
case detailReceived(PokemonDetail)
case detailFailed(String)
case closeTapped
}
@Dependency(\.pokemonClient) var pokemonClient
@Dependency(\.dismiss) var dismiss
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
if case .loaded = state.extra { return .none }
state.extra = .loading
let id = state.pokemon.id
return .run { [pokemonClient] send in
let detail = try await pokemonClient.fetchDetail(id)
await send(.detailReceived(detail))
} catch: { error, send in
await send(.detailFailed(error.localizedDescription))
}
case let .detailReceived(detail):
state.extra = .loaded(detail)
return .none
case let .detailFailed(message):
state.extra = .failed(message)
return .none
case .closeTapped:
return .run { _ in await self.dismiss() }
}
}
}
}
Dos cosas importantes.
Reusamos el LoadState<PokemonDetail> de la parte 2. Es la ventaja de haber hecho ese enum genérico: cualquier feature que carga algo de red usa el mismo tipo, y su vista sabe qué esperar.
@Dependency(\.dismiss) var dismiss es la magia que permite al hijo cerrarse sin conocer al padre. Cuando el hijo hace await self.dismiss(), TCA propaga eso al padre, que pone su destination en nil. El hijo nunca importa PokemonListFeature, y por eso podría reutilizarse mañana desde otra pantalla que también lo presente.
Añadimos fetchDetail al cliente:
struct PokemonClient {
var fetchList: (_ limit: Int, _ offset: Int) async throws -> [Pokemon]
var fetchDetail: (_ id: Int) async throws -> PokemonDetail
}
Las implementaciones live y test se completan igual que en la parte 1.
La vista del detalle
struct PokemonDetailView: View {
@Bindable var store: StoreOf<PokemonDetailFeature>
var body: some View {
List {
Section {
AsyncImage(url: store.pokemon.spriteURL) { $0.resizable().scaledToFit() }
placeholder: { ProgressView() }
.frame(height: 160)
}
Section("Información") {
switch store.extra {
case .idle, .loading:
HStack { ProgressView(); Text("Cargando detalle…") }
case let .loaded(detail):
LabeledContent("Altura", value: "\(detail.height) dm")
LabeledContent("Peso", value: "\(detail.weight) hg")
LabeledContent("Habilidades", value: detail.abilities.joined(separator: ", "))
case let .failed(message):
Text(message).foregroundStyle(.red)
}
}
}
.navigationTitle(store.pokemon.name.capitalized)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cerrar") { store.send(.closeTapped) }
}
}
.onAppear { store.send(.onAppear) }
}
}
El mismo patrón de la parte 2: switch exhaustivo sobre el LoadState. La cabecera con el sprite y el nombre se muestra siempre porque esos datos ya vinieron con el Pokemon de la lista; solo la sección “Información” espera al detalle completo.
La lista, presentando el detalle
En la vista de la lista solo hace falta cablear el NavigationLink (o el Button, según prefieras el estilo) para disparar la acción, y agregar el modificador que presenta el destino:
struct PokemonListView: View {
@Bindable var store: StoreOf<PokemonListFeature>
var body: some View {
Group {
switch store.loadState {
// ... como en la parte 2 ...
case let .loaded(pokemons):
loadedList(pokemons)
// ...
}
}
.navigationDestination(
item: $store.scope(state: \.destination?.detail, action: \.destination.detail)
) { detailStore in
PokemonDetailView(store: detailStore)
}
.onAppear { store.send(.onAppear) }
}
private func loadedList(_ pokemons: [Pokemon]) -> some View {
List {
ForEach(pokemons) { pokemon in
PokemonRow(pokemon: pokemon)
.contentShape(Rectangle())
.onTapGesture {
store.send(.rowTapped(pokemon))
}
.onAppear {
if pokemon.id == pokemons.last?.id {
store.send(.reachedEnd)
}
}
}
}
}
}
Fíjate que la fila no está envuelta en un Button: .contentShape(Rectangle()) + .onTapGesture es el patrón que funciona dentro de un List. Un Button con .buttonStyle(.plain) parece la opción natural, pero el List intercepta el tap y la acción nunca llega al reducer. Lo comprobé ejecutando la app: hasta cambiar a .onTapGesture, tocar una fila no navegaba.
El otro truco es $store.scope(state: \.destination?.detail, action: \.destination.detail): pide al store el slot del destino y el caso .detail del enum, y devuelve un binding que la API de SwiftUI entiende. Si el enum tuviera otros casos (.share, .editor), cada modificador de presentación (sheet, popover, etc.) apuntaría a su caso.
Un apunte más sobre el enum de destino: el @Reducer enum no propaga Equatable a Destination.State automáticamente. Si el estado del padre lo requiere (nuestro caso), añade la conformidad en una extensión:
extension Destination.State: Equatable {}
La sintaxis @Reducer(state: .equatable) enum está deprecada.
Un flujo probable en un test
Aquí es donde la navegación como estado paga. En la parte 6 haremos tests de flujo completos, pero conviene ver por adelantado por qué esto es distinto:
@Test
func abrirDetalleYCerrarlo() async {
let pokemon = Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"])
let store = await TestStore(
initialState: PokemonListFeature.State(loadState: .loaded([pokemon]))
) {
PokemonListFeature()
}
await store.send(.rowTapped(pokemon)) {
$0.destination = .detail(PokemonDetailFeature.State(pokemon: pokemon))
}
await store.send(.destination(.dismiss)) {
$0.destination = nil
}
}
No hay XCUITest, no hay esperas, no hay simulador. Es una función que verifica en milisegundos que tocar una fila abre exactamente el detalle correcto, y que el dismiss limpia el estado.
Un apunte sobre la caché de imágenes
En el detalle mostramos el sprite otra vez, y aquí es donde la caché de imágenes de la parte 7 de PokeTracker se agradece: sin ella, al abrir el detalle SwiftUI vuelve a descargar la imagen aunque la acabemos de mostrar en la fila. La caché es exactamente el mismo componente que en la Pokédex MVVM; vive fuera del reducer y se conecta sin fricción.
Lo que llevamos
- La navegación es un valor en el estado del padre, presente vía
@Presents+ enumDestination. ifLetconecta al hijo y cancela sus efectos cuando se descarta.- El hijo se descarta a sí mismo con
@Dependency(\.dismiss)sin conocer al padre. - El detalle carga datos extra reusando
LoadState<PokemonDetail>. - La navegación se testea sin abrir el simulador.
Qué sigue
En la parte 4 llega el estado compartido: los favoritos. Marcarás una estrella en el detalle y la verás aparecer en la lista sin bindings a mano ni notificaciones. Es el caso donde @Shared con .fileStorage reemplaza lo que en la Pokédex MVVM hicimos con SwiftData en la parte 6, con una fricción sensiblemente menor.
Fuentes: Tree-based navigation (docs) · repo de la app