wandres.dev
ANIMACIONES A FONDO · transiciones y física

Transiciones: aparecer, desaparecer y componer las tuyas

Qué dispara realmente una transición y por qué tan a menudo no ocurre: identidad e inserción en el árbol, transiciones asimétricas, composición, y escribir la tuya conformando el protocolo `Transition` con sus fases.

⏱ 16 min

Una animación normal interpola entre dos valores de un atributo que existe antes y después. Una transición resuelve un problema distinto y bastante más difícil: qué hacer cuando una vista no existía y ahora existe, o al revés. No hay valor anterior del que partir ni valor posterior al que llegar, así que SwiftUI necesita que le describas los dos extremos imaginarios del viaje. De ahí viene casi toda la frustración con las transiciones: no fallan por la curva, fallan porque la inserción que crees que ocurre no está ocurriendo.

🎯 Al terminar esta lección sabrás
  • Explicar qué dispara una transición y por qué depende de la identidad de la vista.
  • Componer transiciones combinadas y asimétricas con curvas propias.
  • Escribir una transición a medida conformando el protocolo Transition.
  • Diagnosticar las causas habituales de una transición que no se ejecuta.

Qué dispara realmente una transición

Para que una transición ocurra tienen que cumplirse tres condiciones a la vez, y basta que falle una para que la vista aparezca de golpe:

  1. La vista debe entrar o salir del árbol de vistas. No basta con que cambie: un opacity que pasa de 1 a 0 no es una salida, la vista sigue ahí.
  2. El modificador transition debe estar aplicado a la vista que se inserta o se elimina, no a su contenedor.
  3. El cambio de estado que provoca la inserción debe viajar dentro de una transacción animada.

La tercera es la que más se olvida y la más contraintuitiva: la transición describe el cómo, pero no aporta el cuándo. Sin animación en la transacción no hay tiempo que recorrer.

@State private var visible = false

var body: some View {
    VStack {
        if visible {
            Aviso()
                .transition(.move(edge: .top).combined(with: .opacity))
        }
        Button("Alternar") {
            withAnimation(.snappy) { visible.toggle() }   // sin esto, salta
        }
    }
}

Hay un cuarto factor más sutil: la identidad. SwiftUI decide que algo se ha insertado comparando identidades entre dos evaluaciones de body, no comparando contenidos. Una vista que conserva su identidad y solo cambia de datos nunca dispara una transición; una vista cuyo id cambia se considera destruida y recreada, y sí la dispara. Esa segunda propiedad es explotable a propósito:

Text(mensaje)
    .id(mensaje)                          // fuerza identidad nueva por mensaje
    .transition(.push(from: .bottom))     // cada cambio se lee como sustitución
flowchart TB
A[Cambia el estado] --> B[SwiftUI compara identidades del arbol]
B --> C{La vista existe en ambos lados}
C -->|Si| D[Se interpolan sus atributos animables]
C -->|No, es nueva| E[Se aplica la parte de insercion]
C -->|No, desaparece| F[Se aplica la parte de eliminacion]
E --> G{Hay animacion en la transaccion}
F --> G
G -->|Si| H[Transicion visible]
G -->|No| I[Aparece o desaparece de golpe]
style H fill:#a6e3a1,color:#11111b
style I fill:#eba0ac,color:#11111b

Asimetría: entrar y salir no son lo mismo

La simetría por defecto —salir es entrar del revés— es casi siempre una mentira de diseño. Un panel que entra deslizándose desde abajo llamando la atención no debería salir por abajo con la misma pompa: ya cumplió su función y su salida debe ser discreta. asymmetric separa las dos mitades, y cada una puede llevar además su propia curva:

extension AnyTransition {
    static var aviso: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .bottom)
                .combined(with: .opacity)
                .animation(.spring(duration: 0.45, bounce: 0.25)),
            removal: .opacity
                .animation(.easeOut(duration: 0.15))
        )
    }
}

Fíjate en animation aplicado dentro de la transición: fija la curva de esa mitad e ignora la que traiga la transacción. Es la forma correcta de encapsular una coreografía en un componente para que nadie de fuera pueda estropearla.

💡
Asimetría por defecto, no por excepción

Una heurística que sostiene bien: la entrada informa, la salida se aparta. Entradas en torno a tres o cuatro décimas, con algo de rebote y desplazamiento; salidas por debajo de dos décimas, casi siempre solo opacidad y sin rebote. Una salida lenta bloquea la atención del usuario en algo que él mismo acaba de decidir descartar, y se percibe como lentitud general de la app aunque la entrada sea impecable.

Escribir la tuya con el protocolo Transition

Las transiciones compuestas cubren mucho, pero se agotan enseguida. El protocolo Transition permite describir cualquier efecto en función de la fase del viaje. Solo hay que implementar body(content:phase:), donde phase es un TransitionPhase con tres valores: willAppear, identity y didDisappear.

struct Persiana: Transition {
    var eje: Axis = .horizontal

    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .opacity(phase.isIdentity ? 1 : 0)
            .blur(radius: phase.isIdentity ? 0 : 8)
            .rotation3DEffect(
                .degrees(phase.isIdentity ? 0 : 75),
                axis: eje == .horizontal ? (x: 0, y: 1, z: 0) : (x: 1, y: 0, z: 0),
                perspective: 0.5
            )
            .scaleEffect(phase.isIdentity ? 1 : 0.9)
    }
}

// Uso
Tarjeta()
    .transition(Persiana(eje: .vertical))

La clave es que la vista se declara una sola vez y los tres estados salen de la misma expresión. Cuando entrada y salida deben divergir sin recurrir a asymmetric, está phase.value, que vale -1 al entrar, 0 en identidad y 1 al salir; multiplicarlo por un desplazamiento produce la coreografía clásica de una pila de navegación en una sola línea:

func body(content: Content, phase: TransitionPhase) -> some View {
    content
        .offset(x: phase.value * 60)   // entra por la izquierda, sale por la derecha
        .opacity(phase.isIdentity ? 1 : 0)
}

Por qué tu transición no se ejecuta

Casi todos los casos se reducen a cuatro diagnósticos, y ninguno tiene que ver con la transición en sí.

🎯

El modificador está en el sitio equivocado

Puesto en el contenedor en vez de en la vista condicional. El contenedor no se inserta nunca, así que su transición no se evalúa jamás.

⏱️

Falta la transacción animada

El estado cambió fuera de withAnimation y sin animation(_:value:) en un ancestro. La transición está bien descrita y no tiene tiempo en el que ocurrir.

🪪

La identidad no cambió

La vista se reutilizó en vez de recrearse. Si quieres que el cambio se lea como sustitución, fuérzalo con id.

📦

El contenedor impone la suya

List y las pilas perezosas aplican sus propias animaciones de fila y pueden recortar o ignorar la tuya. Prueba el efecto fuera del contenedor antes de culpar al código.

Añade un quinto sospechoso menos obvio: el recorte. Si un ancestro tiene clipped, un frame fijo o un fondo con esquinas redondeadas, la vista puede estar animándose perfectamente fuera del área visible. Antes de reescribir la transición, quita el recorte y mira.

Una transición es una animación sobre una vista que no existe

La rareza conceptual que hay que aceptar es esta: durante una transición de entrada, SwiftUI anima una vista que en el estado anterior no formaba parte del árbol, y durante una de salida mantiene viva y renderizada una vista que en el estado actual ya no está declarada. No hay dos valores entre los que interpolar; hay un valor y una ausencia. Lo que hace el protocolo Transition es fabricar el otro extremo: te pide que describas el aspecto que tendría la vista en un estado hipotético donde su presencia vale cero, y a partir de ahí el problema vuelve a ser una interpolación normal entre dos conjuntos de atributos animables. Por eso una transición no es un tipo especial de animación sino un generador de estados fantasma, y por eso su parámetro fundamental no es el tiempo sino la fase. De esta relectura salen las tres reglas prácticas de golpe: la transición vive en la vista que aparece porque solo esa vista tiene un estado fantasma que describir; necesita una transacción animada porque ella aporta la geometría del viaje pero no su duración; y se dispara por identidad y no por contenido porque solo la identidad distingue “el mismo objeto con otro valor” de “un objeto nuevo donde había otro”.

⚔️ Aparecer con criterio
  1. Muestra un aviso con move combinado con opacity y comprueba qué pasa al olvidar withAnimation.
  2. Mueve el modificador transition al contenedor y verifica que deja de funcionar; explica por qué.
  3. Escribe una transición asimétrica con entrada elástica y salida de menos de dos décimas.
  4. Implementa el protocolo Transition para un efecto de persiana con rotation3DEffect y desenfoque.
  5. Usa phase.value para que una vista entre por un lado y salga por el contrario sin recurrir a asymmetric.