Posts

Animaciones por fases en SwiftUI: secuencias con PhaseAnimator

Arturo Rivas Arias

Las animaciones más habituales de SwiftUI interpolan entre dos estados. Una vista de tarjeta pasa de estar contraída a expandida, un botón cambia de color o una vista aparece o desaparece modificando su opacidad. Sin embargo, algunos efectos necesitan varios pasos en un orden concreto: elevar un elemento, girarlo, hacerlo rebotar y devolverlo a su posición inicial.

Antes de iOS 17, coordinar una secuencia de este tipo solía requerir varios valores de estado, retrasos artificiales o llamadas encadenadas a withAnimation. El resultado podía funcionar visualmente, pero la progresión de la animación quedaba repartida entre el estado de la vista y el código encargado de cambiarlo.

PhaseAnimator ofrece un modelo más declarativo. En lugar de programar cuándo comienza cada paso, definimos una colección ordenada de fases y describimos el aspecto completo de la vista en cada una de ellas. SwiftUI se ocupa de interpolar los cambios y avanzar a la siguiente fase cuando termina la transición actual.

Esta API está disponible desde iOS 17, iPadOS 17, macOS 14, watchOS 10 y tvOS 17, además de visionOS 1.

Qué representa una fase

Una fase es un valor que describe un estado discreto de la animación. Puede ser algo tan sencillo como un Bool cuando solo existen dos estados, aunque para secuencias más expresivas suele resultar preferible utilizar un enum.

Por ejemplo, un indicador de grabación puede permanecer en reposo, expandirse, alcanzar su máxima intensidad y desvanecerse antes de comenzar de nuevo.

private enum RecordingPhase: CaseIterable, Equatable {
    case resting
    case expanding
    case bright
    case fading

    var scale: CGFloat {
        switch self {
        case .resting: 1
        case .expanding: 1.25
        case .bright: 1.15
        case .fading: 1
        }
    }

    var opacity: Double {
        switch self {
        case .resting: 0.65
        case .expanding: 0.9
        case .bright: 1
        case .fading: 0.55
        }
    }

    var shadowRadius: CGFloat {
        switch self {
        case .resting: 0
        case .expanding: 8
        case .bright: 14
        case .fading: 2
        }
    }
}

Las fases deben cumplir Equatable, mientras que CaseIterable no es un requisito de la API. En este ejemplo se añade para obtener allCases y conservar en un único lugar tanto los estados como el orden en el que deben ejecutarse.

También es importante entender que cada caso representa la apariencia completa de la vista en ese instante, no solamente la propiedad que cambia respecto al paso anterior. Por eso todas las propiedades calculadas devuelven un valor para todas las fases.

Una animación que se repite automáticamente

La primera variante de phaseAnimator recorre las fases continuamente mientras la vista permanece en pantalla.

struct RecordingIndicator: View {
    var body: some View {
        Circle()
            .fill(.red)
            .frame(width: 18, height: 18)
            .phaseAnimator(RecordingPhase.allCases) { circle, phase in
                circle
                    .scaleEffect(phase.scale)
                    .opacity(phase.opacity)
                    .shadow(
                        color: .red.opacity(0.45),
                        radius: phase.shadowRadius
                    )
            }
    }
}

El bloque de contenido recibe dos parámetros. El primero es una representación de la vista modificada y el segundo contiene la fase activa. Los efectos deben aplicarse sobre esa representación para que SwiftUI pueda generar una versión diferente del contenido para cada paso.

Cuando RecordingIndicator aparece, SwiftUI utiliza el primer elemento de la colección como estado inicial. Inmediatamente después inicia la transición hacia el segundo. Al finalizar, continúa con el tercero y repite el proceso hasta volver al principio.

El orden de la colección es, por tanto, parte del comportamiento. También debe contener al menos una fase. Si está vacía, SwiftUI registra un aviso en tiempo de ejecución y sustituye el contenido por una advertencia visual.

Una curva diferente para cada transición

Si no indicamos ninguna animación, SwiftUI aplica la animación predeterminada. El modificador acepta un segundo bloque con el que podemos decidir cómo se alcanza cada fase.

private extension RecordingPhase {
    var animation: Animation? {
        switch self {
        case .resting:
            .easeOut(duration: 0.25).delay(1.2)
        case .expanding:
            .easeOut(duration: 0.35)
        case .bright:
            .spring(duration: 0.3, bounce: 0.2)
        case .fading:
            .easeIn(duration: 0.4)
        }
    }
}

struct RecordingIndicator: View {
    var body: some View {
        Circle()
            .fill(.red)
            .frame(width: 18, height: 18)
            .phaseAnimator(RecordingPhase.allCases) { circle, phase in
                circle
                    .scaleEffect(phase.scale)
                    .opacity(phase.opacity)
                    .shadow(
                        color: .red.opacity(0.45),
                        radius: phase.shadowRadius
                    )
            } animation: { phase in
                phase.animation
            }
    }
}

La fase recibida por el bloque de animación es el destino de la transición. La curva asociada a .expanding controla cómo se pasa de .resting a .expanding, mientras que la configurada para .resting se utiliza al volver al inicio de la secuencia.

Este detalle permite cambiar tanto la curva como la duración de cada paso. Además, los retrasos forman parte de la transición, por lo que resultan útiles para introducir una pausa sin crear temporizadores ni modificar manualmente el estado. El valor de retorno es opcional: devolver nil hace que el cambio hacia una fase concreta se produzca sin animación.

SwiftUI no trabaja con una línea de tiempo global en este caso. Cada transición comienza cuando termina la anterior. Por eso, modificar la duración o el retraso de una fase también altera la duración total y el ritmo de toda la secuencia.

Ejecutar la secuencia en respuesta a un evento

Una animación continua encaja bien con indicadores de actividad o estados que necesitan llamar la atención mientras permanezcan activos. Otras animaciones solo deberían ejecutarse después de una interacción, como añadir un artículo al carrito o confirmar una operación.

Para esos casos existe la variante que recibe un trigger.

private enum CartFeedbackPhase: CaseIterable, Equatable {
    case resting
    case pressed
    case lifted
    case settled

    var scale: CGFloat {
        switch self {
        case .resting, .settled: 1
        case .pressed: 0.88
        case .lifted: 1.18
        }
    }

    var verticalOffset: CGFloat {
        switch self {
        case .resting, .pressed, .settled: 0
        case .lifted: -10
        }
    }

    var rotation: Angle {
        switch self {
        case .resting, .pressed, .settled: .zero
        case .lifted: .degrees(-6)
        }
    }

    var animation: Animation {
        switch self {
        case .resting:
            .smooth(duration: 0.2)
        case .pressed:
            .easeOut(duration: 0.12)
        case .lifted:
            .spring(duration: 0.28, bounce: 0.35)
        case .settled:
            .spring(duration: 0.35, bounce: 0.18)
        }
    }
}

El valor utilizado como disparador también debe cumplir Equatable. SwiftUI no interpreta su contenido ni lo utiliza para seleccionar una fase: únicamente detecta que ha cambiado y pone en marcha la secuencia.

struct AddToCartButton: View {
    @State private var addCount = 0

    let addItem: () -> Void

    var body: some View {
        Button {
            addItem()
            addCount += 1
        } label: {
            Label("Añadir al carrito", systemImage: "cart.badge.plus")
                .font(.headline)
                .padding(.horizontal, 18)
                .padding(.vertical, 12)
        }
        .buttonStyle(.borderedProminent)
        .phaseAnimator(
            CartFeedbackPhase.allCases,
            trigger: addCount
        ) { button, phase in
            button
                .scaleEffect(phase.scale)
                .offset(y: phase.verticalOffset)
                .rotationEffect(phase.rotation)
        } animation: { phase in
            phase.animation
        }
    }
}

El contador no representa el estado persistente del carrito, sino la aparición de un evento. Cada incremento produce un valor diferente, incluso cuando el usuario pulsa varias veces seguidas. Separar el disparador del estado real evita que la animación dependa de que otro dato vuelva primero a su valor inicial.

Tampoco es necesario envolver addCount += 1 en withAnimation. Las curvas que gobiernan la secuencia ya están definidas por phaseAnimator, y añadir otra animación a la misma actualización puede dificultar el razonamiento sobre el resultado.

PhaseAnimator frente a withAnimation

withAnimation sigue siendo la mejor opción cuando una interacción provoca un único cambio de estado. Su cometido es añadir una animación a la transacción en la que se modifica ese estado.

PhaseAnimator, en cambio, describe estados intermedios que SwiftUI recorre por nosotros. No reemplaza las animaciones explícitas, sino que evita tener que mantener propiedades como currentStep, encadenar finalizaciones o introducir llamadas a Task.sleep para coordinar una secuencia visual.

La diferencia también afecta al diseño del estado. Las fases no deberían convertirse en parte del modelo de negocio de la aplicación. Son una descripción visual local, por lo que normalmente conviene declararlas cerca del componente que las utiliza.

Cuándo utilizar KeyframeAnimator

Las fases funcionan especialmente bien cuando varias propiedades cambian juntas para formar estados completos. Al pasar de una fase a otra, la escala, la posición, la rotación y la opacidad comienzan su transición al mismo tiempo y comparten la animación asociada a ese paso.

Si cada propiedad necesita su propia línea de tiempo, KeyframeAnimator es una herramienta más adecuada. Los fotogramas clave permiten, por ejemplo, iniciar una rotación antes que el desplazamiento, mantener la opacidad durante parte del recorrido y hacer que la escala termine más tarde mediante pistas independientes.

Una buena regla práctica consiste en elegir PhaseAnimator cuando la secuencia puede explicarse como una lista de estados y recurrir a KeyframeAnimator cuando necesita describirse como varias pistas temporales coordinadas.

Detener una animación continua

La variante continua no incluye una propiedad de activación. Si el indicador solo debe animarse mientras una operación está en curso, la propia jerarquía de vistas puede decidir si muestra la versión animada o una representación estática.

struct RecordingStatus: View {
    let isRecording: Bool

    var body: some View {
        HStack(spacing: 10) {
            if isRecording {
                RecordingIndicator()
            } else {
                Circle()
                    .fill(.secondary)
                    .frame(width: 18, height: 18)
            }

            Text(isRecording ? "Grabando" : "Grabación detenida")
        }
    }
}

Cuando isRecording pasa a false, SwiftUI retira RecordingIndicator de la jerarquía y la secuencia termina con él. Este enfoque mantiene la condición de negocio fuera de la definición de las fases.

Respeta al reducción de movimiento

Una animación que se repite continuamente puede resultar molesta o incómoda para personas sensibles al movimiento. SwiftUI expone la preferencia del sistema mediante accessibilityReduceMotion, que permite sustituir el efecto por una versión estática o mucho más discreta.

struct AccessibleRecordingStatus: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        HStack(spacing: 10) {
            if reduceMotion {
                Circle()
                    .fill(.red)
                    .frame(width: 18, height: 18)
            } else {
                RecordingIndicator()
            }

            Text("Grabando")
        }
        .accessibilityElement(children: .combine)
    }
}

El texto sigue comunicando el estado aunque la animación desaparezca. El movimiento debe reforzar el significado de la interfaz, nunca ser la única forma de transmitirlo.

Conclusión

PhaseAnimator encaja en el punto intermedio entre una transición sencilla y una animación con fotogramas clave. Permite expresar secuencias completas como una colección de estados, asignar una curva distinta a cada transición y decidir si el recorrido se repite o se inicia como respuesta a un evento.

La clave para utilizarlo correctamente consiste en tratar cada fase como una descripción completa de la vista, mantener el disparador separado del estado persistente y escoger KeyframeAnimator cuando las propiedades necesiten tiempos independientes. Con ese modelo, muchas animaciones que antes exigían temporizadores y coordinación manual se convierten en una parte declarativa y localizada de la interfaz.