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.
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.
- 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:
- La vista debe entrar o salir del árbol de vistas. No basta con que cambie: un
opacityque pasa de 1 a 0 no es una salida, la vista sigue ahí. - El modificador
transitiondebe estar aplicado a la vista que se inserta o se elimina, no a su contenedor. - 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:#11111bAsimetrí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.
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.
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”.
- Muestra un aviso con
movecombinado conopacityy comprueba qué pasa al olvidarwithAnimation. - Mueve el modificador
transitional contenedor y verifica que deja de funcionar; explica por qué. - Escribe una transición asimétrica con entrada elástica y salida de menos de dos décimas.
- Implementa el protocolo
Transitionpara un efecto de persiana conrotation3DEffecty desenfoque. - Usa
phase.valuepara que una vista entre por un lado y salga por el contrario sin recurrir aasymmetric.