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

El modelo: no animas la vista, animas el cambio de estado

Por qué en SwiftUI la animación es una propiedad de la transacción y no de la vista: `withAnimation` frente al modificador `animation`, qué significa que un atributo sea interpolable, y por qué este modelo regala interrupciones correctas.

⏱ 17 min

Todo el mundo llega a SwiftUI buscando el modificador que anima una vista, y ese modificador no existe. Lo que existe es algo más extraño y mucho más potente: una animación no es un atributo de la vista, es un atributo del cambio. No dices “muévete de aquí a allá en tres décimas”; declaras cómo se ve la interfaz en cada estado posible y marcas como animado el instante en que el estado salta de uno a otro. SwiftUI encuentra las diferencias y las interpola. Interiorizar esa inversión es la frontera entre pelearse con las animaciones y componerlas.

🎯 Al terminar esta lección sabrás
  • Explicar la animación como propiedad de la transacción y no de la vista.
  • Distinguir el ámbito de withAnimation del de animation(_:value:).
  • Identificar qué atributos son interpolables y cuáles saltan en seco.
  • Redirigir y desactivar animaciones manipulando la Transaction.

Una animación es una propiedad de la transacción

Cuando modificas una propiedad marcada con @State, SwiftUI invalida la vista, vuelve a evaluar body y compara el árbol de atributos resultante con el anterior. Ese ciclo ocurre siempre, haya animación o no. Lo único que añade animar es un dato adjunto a la actualización: la Transaction.

Una transacción es el contexto que acompaña a un cambio de estado mientras desciende por el árbol de vistas. Transporta, entre otras cosas, una Animation opcional. Si al terminar la reevaluación algún atributo interpolable ha cambiado y la transacción trae animación, SwiftUI no salta al valor final: engancha un temporizador al refresco de pantalla y publica valores intermedios hasta llegar.

De ahí se sigue lo que desconcierta a quien viene de UIKit o de CSS: la animación no vive en la vista que se mueve, vive en el punto donde el estado cambió. Dos botones distintos pueden mover el mismo rectángulo con dos curvas distintas sin que el rectángulo sepa nada de ninguna.

struct Panel: View {
    @State private var expandido = false

    var body: some View {
        VStack(spacing: 24) {
            RoundedRectangle(cornerRadius: expandido ? 32 : 8)
                .fill(.tint)
                .frame(height: expandido ? 260 : 72)

            Button("Suave")    { withAnimation(.smooth) { expandido.toggle() } }
            Button("Elástico") { withAnimation(.bouncy) { expandido.toggle() } }
        }
        .padding()
    }
}

El rectángulo se declara una sola vez y no menciona la palabra animación. La coreografía la decide quien pulsa.

flowchart TB
A[Cambio de estado dentro de withAnimation] --> B[Se abre una transaccion con animacion]
B --> C[SwiftUI vuelve a evaluar body]
C --> D[Compara atributos animables antes y despues]
D --> E[Interpola animatableData en cada refresco]
E --> F[La vista se redibuja en cada paso intermedio]

Dos ámbitos: transacción frente a subárbol

withAnimation tiene ámbito temporal: abre una transacción animada, ejecuta el bloque y la cierra. Cualquier cambio de estado que ocurra dentro, y cualquier vista del árbol que dependa de él por lejos que esté, hereda esa animación. Es la herramienta correcta cuando quien sabe cómo debe sentirse el cambio es quien lo provoca.

El modificador animation(_:value:) tiene ámbito estructural: se instala en un punto del árbol, solo afecta al subárbol que modifica y solo se dispara cuando el valor que vigila cambia. Es la herramienta correcta cuando quien sabe cómo debe moverse algo es el propio componente, no quien le pasa los datos.

// La animación la decide quien provoca el cambio
withAnimation(.snappy) { seleccion = .siguiente }

// La animación la decide el componente, sea quien sea el que cambie el dato
Circle()
    .fill(activo ? .green : .secondary)
    .scaleEffect(activo ? 1.2 : 1)
    .animation(.smooth(duration: 0.25), value: activo)

Y una regla de precedencia que ahorra horas de depuración: para el subárbol al que se aplica, el modificador gana sobre la animación heredada de la transacción. Si envuelves en withAnimation(.bouncy) un cambio que atraviesa una vista con animation(.linear, value:), ese trozo se moverá lineal y el resto rebotará.

⚠️
La versión de un solo argumento está obsoleta por buenas razones

El antiguo animation(_:) sin value: capturaba cualquier transacción que pasara por ese punto del árbol, incluidas las que no tenían nada que ver con él: rotación del dispositivo, cambio de tamaño dinámico de texto, aparición del teclado, inserción de una fila lejana. Producía movimientos fantasma imposibles de rastrear porque el culpable no estaba en el fichero que mirabas. Usa siempre value:, y que ese valor sea exactamente el que quieres vigilar y ninguno más.

Qué es exactamente animable

SwiftUI no interpola vistas: interpola números. Un atributo se puede animar si su valor conforma a Animatable, un protocolo cuyo único requisito es exponer animatableData con un tipo que conforme a VectorArithmetic, es decir, algo que se pueda sumar, restar y escalar. Double, CGFloat, CGSize, CGPoint, Angle, Color, EdgeInsets y los AnimatablePair que los combinan cumplen.

Lo que no cumple, salta. El texto de un Text, el nombre de un símbolo, el número de elementos de una colección: no hay punto medio entre “Hola” y “Adiós”, así que SwiftUI corta en seco.

Interpolable

Tamaños, posiciones, opacidades, rotaciones, radios, colores, insets. Todo lo que se puede escribir como vector se recorre valor a valor.

🧱

Discreto

Contenido textual, símbolos, identidad de las vistas, estructura del árbol. Cambia de golpe salvo que le pongas encima una transición o un contentTransition.

Puedes ampliar el conjunto conformando tus propios tipos. El caso canónico es una forma cuyo parámetro geométrico se construye dentro de path(in:), donde SwiftUI ya no puede adivinar qué número hay que recorrer:

struct Sector: Shape, Animatable {
    var fin: Angle

    var animatableData: Double {
        get { fin.degrees }
        set { fin = .degrees(newValue) }
    }

    func path(in r: CGRect) -> Path {
        var p = Path()
        let centro = CGPoint(x: r.midX, y: r.midY)
        p.move(to: centro)
        p.addArc(center: centro, radius: r.width / 2,
                 startAngle: .degrees(-90),
                 endAngle: fin - .degrees(90),
                 clockwise: false)
        return p
    }
}

Sin esa propiedad, cambiar fin dentro de withAnimation haría que el sector apareciera de golpe en su ángulo final: el Path ya construido es opaco para el interpolador.

Redirigir y desactivar

Aquí el modelo cobra su beneficio real. Como la animación no reproduce un guion sino que persigue el estado actual, cambiar de idea a mitad de camino no la rompe: la redirige. Si el usuario pulsa dos veces seguidas, SwiftUI no encadena dos animaciones ni espera a que la primera acabe; recalcula el destino y, si la curva es un muelle, arrastra la velocidad que el objeto ya llevaba. El movimiento resultante es continuo, sin el tirón característico de los sistemas basados en guiones fijos.

A veces necesitas justo lo contrario: que un cambio concreto no se anime aunque viaje dentro de una transacción animada. El modificador transaction interviene el contexto a su paso:

VStack {
    Cabecera()
        .transaction { t in t.animation = nil }   // este trozo salta
    Contenido()                                   // este hereda la animación
}

// O al revés: forzar un cambio deliberadamente instantáneo desde el origen
withTransaction(Transaction(animation: nil)) {
    irAlFinal.toggle()
}
La animación es una anotación sobre el tiempo, no un objeto que reproduces

El salto conceptual definitivo es dejar de pensar en la animación como una cosa que existe, se lanza, dura y termina, y empezar a verla como una anotación semántica sobre una transición de estado: la etiqueta que le dices al sistema que ponga a un cambio para que no ocurra instantáneamente. Nada en tu código guarda una animación en marcha; lo que hay es un estado destino y un motor que, en cada refresco de pantalla, resuelve dónde debería estar cada atributo interpolable dado el tiempo transcurrido y la curva anotada. Por eso las animaciones de SwiftUI son interrumpibles, reversibles y componibles gratis: no hay guion que abortar ni fotograma que rebobinar, solo un destino que cambió y un integrador que recalcula. Y por eso mismo los fallos más comunes no son fallos de animación sino de estado: cuando algo salta cuando debería fluir, casi nunca falta una curva, sobra un cambio de identidad, falta una conformidad a Animatable, o el valor que vigilas no es el que de verdad cambia. Depura siempre el estado primero; la curva es lo último.

⚔️ Desmonta el modelo con las manos
  1. Declara un rectángulo cuyo tamaño dependa de un @State y muévelo desde dos botones con curvas distintas: comprueba que la vista no cambia entre ambos casos.
  2. Sustituye uno de ellos por animation(_:value:) en la vista y verifica la regla de precedencia envolviendo el cambio en un withAnimation diferente.
  3. Anima un Text cuyo contenido cambie y observa el salto; después añade contentTransition(.numericText()) y compara.
  4. Escribe una Shape con un parámetro Double sin conformar a Animatable, comprueba que salta, y arréglalo con animatableData.
  5. Usa transaction para que una cabecera quede inmóvil mientras el resto de la pantalla se reorganiza animadamente.