Ir al contenido
charmotion

Reducir movimiento en SwiftUI y UIKit

Por el equipo de Charmotion · Actualizado el

Ilustración de portada de la guía de iOS y Swift: Reducir movimiento en SwiftUI y UIKit, con personajes de demostración de Charmotion

En SwiftUI, lee @Environment(\.accessibilityReduceMotion) var reduceMotion; se actualiza en vivo cuando el usuario cambia el ajuste. En UIKit, consulta UIAccessibility.isReduceMotionEnabled y observa UIAccessibility.reduceMotionStatusDidChangeNotification. Cuando esté activado, reemplaza los loops o los movimientos grandes por una pose fija o un fundido cruzado, y pausa las vistas de Rive y Lottie.

Puntos clave

  • accessibilityReduceMotion está disponible desde iOS 13, watchOS 6 y visionOS 1; isReduceMotionEnabled, desde iOS 8.
  • SwiftUI vuelve a renderizar automáticamente cuando cambia el ajuste; UIKit necesita la notificación de cambio.
  • El nuevo runtime de Rive tiene .paused(_:) en RiveUIViewRepresentable e isPaused en RiveUIView.
  • Conserva los cambios de estado y elimina el movimiento: una mascota debería seguir mostrando "éxito" como pose, no desaparecer.
  • Omite por completo las intros decorativas y las celebraciones puntuales.

¿Cómo leo Reducir movimiento en SwiftUI?

Declara el valor de entorno en cualquier vista. Es true cuando Ajustes → Accesibilidad → Movimiento → Reducir movimiento está activado, y SwiftUI vuelve a evaluar el body cuando cambia.

struct PulsingBadge: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var saved = 0

    var body: some View {
        Image(systemName: "checkmark.seal.fill")
            .font(.largeTitle)
            .phaseAnimator([false, true], trigger: saved) { content, lifted in
                content.scaleEffect(lifted && !reduceMotion ? 1.2 : 1)
            }
            .onTapGesture { saved += 1 }
    }
}

Para animaciones explícitas, elige una animación más suave o ninguna en lugar de bifurcar toda la vista:

withAnimation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.3)) {
    isExpanded.toggle()
}

// Or swap movement for a fade
.transition(reduceMotion ? .opacity : .move(edge: .bottom))

Los principios generales (qué cuenta como movimiento, WCAG 2.3.3) se tratan en animación accesible y movimiento reducido; esta guía se centra en el código para plataformas de Apple.

¿Cómo leo Reducir movimiento en UIKit y detecto los cambios?

UIAccessibility.isReduceMotionEnabled devuelve el valor actual. No te notifica, así que observa UIAccessibility.reduceMotionStatusDidChangeNotification para actualizar una pantalla que ya está visible:

import UIKit
import RiveRuntime

final class MascotViewController: UIViewController {
    private let riveView = RiveUIView(rive: nil)
    private let stillView = UIImageView(image: UIImage(named: "mascot-idle"))
    private var motionObserver: NSObjectProtocol?

    override func viewDidLoad() {
        super.viewDidLoad()
        // ... add riveView and stillView in the same frame, load the Rive file ...
        applyMotionPreference()
        motionObserver = NotificationCenter.default.addObserver(
            forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in self?.applyMotionPreference() }
        }
    }

    deinit {
        if let motionObserver { NotificationCenter.default.removeObserver(motionObserver) }
    }

    private func applyMotionPreference() {
        let reduce = UIAccessibility.isReduceMotionEnabled
        riveView.isPaused = reduce
        riveView.isHidden = reduce
        stillView.isHidden = !reduce
    }
}

En las llamadas a UIView.animate, comprueba el indicador en el momento de la llamada y usa un fundido cruzado más corto, o establece el estado final sin animación.

¿Qué debería hacer una mascota de marca cuando Reducir movimiento está activado?

Quitar el movimiento pero conservar el significado. Una mascota que informa un estado ("cargando", "listo", "algo salió mal") debería seguir informándolo.

Movimiento completoAlternativa reducida
Loop en reposo (idle) de respiración o parpadeoPose fija en reposo
Loop de cargaPose fija de "pensando" más un texto o indicador de progreso
Reacción puntual de éxito / errorCambiar a una imagen fija alegre o triste con un fundido corto, más una etiqueta de texto
Animación de entrada (llega volando, rebota)Aparición con fundido cruzado
Intro de inicioOmitirla, o una imagen fija mientras carga
Loop de attention para un elemento nuevoPose fija de attention y una insignia

La forma más sencilla de implementarlo es un diccionario que asocie cada mood con una imagen fija, usando fotogramas del mismo personaje para que la versión reducida se vea intencional:

struct MascotView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @ObservedObject var character: CharacterModel   // Rive controller that keeps the view model instance
    let mood: Int

    var body: some View {
        ZStack {
            if reduceMotion {
                Image("mascot-mood-\(mood)")
                    .resizable()
                    .scaledToFit()
                    .id(mood)
                    .transition(.opacity)
            } else {
                RiveUIViewRepresentable(rive: character.rive)
            }
        }
        .animation(.easeInOut(duration: 0.2), value: mood)
        .onChange(of: mood) { _, newMood in character.setMood(newMood) }
    }
}
success: un trigger que se reproduce una vez y luego vuelve al estado de ánimo actual.

¿Cómo pauso Rive o Lottie con Reducir movimiento?

Pausar congela el fotograma actual, lo cual es aceptable para un loop decorativo si ese fotograma se lee como una pose. Cada runtime tiene un interruptor directo:

// Rive, new Apple runtime (SwiftUI)
RiveUIViewRepresentable(rive: character.rive)
    .paused(reduceMotion)

// Rive, new runtime (UIKit)
riveView.isPaused = UIAccessibility.isReduceMotionEnabled

// Rive, legacy runtime
reduceMotion ? riveViewModel.pause() : riveViewModel.play()

// Lottie
LottieView(animation: .named("idle"))
    .playbackMode(reduceMotion
        ? .paused(at: .progress(0))
        : .playing(.fromProgress(0, toProgress: 1, loopMode: .loop)))

Dos advertencias:

  • Un fotograma pausado puede quedar a mitad de movimiento. Si pausas una vista de Rive después de que se haya estado reproduciendo, puede detenerse en una posición intermedia poco natural. Para evitarlo, cambia el mood a un estado tranquilo (idle) antes de pausar, o muestra una imagen fija.
  • No dispares triggers mientras está en pausa. Una máquina de estados en pausa no avanza, así que las reacciones puntuales no se reproducirán. Muestra el resultado en la imagen fija o en texto.

Lottie 4.3.0 y versiones posteriores también pueden reproducir automáticamente un marcador llamado reduced motion cuando Reducir movimiento está activado, si el diseñador lo incluye en el archivo.

¿Debería seguir mostrando la intro de inicio con Reducir movimiento?

No. Una intro es decorativa por definición, y omitirla es la mejora más clara para quienes activaron el ajuste. Condiciona el overlay al valor de entorno:

struct RootView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var showIntro = true

    var body: some View {
        ZStack {
            ContentView()
            if showIntro && !reduceMotion {
                IntroView { withAnimation { showIntro = false } }
                    .transition(.opacity)
                    .zIndex(1)
            }
        }
    }
}

El patrón completo, incluida la coincidencia con la pantalla de inicio estática, está en splash screen animada en SwiftUI.

Si el personaje tiene significado en tu app, las poses fijas pueden salir directamente de su set de animaciones: en todos los planes de pago, cada set incluye un póster fijo de cada animación junto al .riv, y exporta las 17 como MP4, GIF y WebM/MOV transparentes bajo demanda, así tienes una imagen fija a juego para cada estado de ánimo.

¿Cómo aplico Reducir movimiento a las transiciones y los efectos de scroll?

La animación del personaje es solo una parte del movimiento de una app. Las transiciones que proporciona el sistema son responsabilidad de la plataforma; cada efecto personalizado que agregues te toca adaptarlo a ti.

  • Transiciones de inserción y eliminación: cambia .move, .slide y .scale por .opacity.
  • Efectos controlados por el scroll (scrollTransition, headers con parallax): devuelve el efecto identidad cuando se reduce el movimiento.
  • Animaciones hero con matchedGeometryEffect: mantén el cambio de estado pero elimina la animación pasando nil a withAnimation.
  • Efectos de símbolos: prefiere efectos sin desplazamiento, como un cambio de color, en lugar de rebotes.
struct CardRow: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    let title: String

    var body: some View {
        Text(title)
            .padding()
            .scrollTransition { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0.6)
                    .scaleEffect(reduceMotion || phase.isIdentity ? 1 : 0.92)
            }
    }
}

Una pequeña función auxiliar mantiene la decisión coherente en toda la app:

extension Animation {
    static func motionAware(_ animation: Animation, reduceMotion: Bool) -> Animation? {
        reduceMotion ? nil : animation
    }
}

withAnimation(Animation.motionAware(.bouncy, reduceMotion: reduceMotion)) { selected = item }

¿Cómo pruebo Reducir movimiento rápidamente?

Tres formas, de la más rápida a la más lenta:

  • Vistas previas de SwiftUI: accessibilityReduceMotion es de solo lectura, así que .environment(\.accessibilityReduceMotion, true) no compila. Lee el valor en una vista padre y pasa un parámetro reduceMotion: Bool a la vista que quieres previsualizar; luego muestra ambos valores uno al lado del otro.
  • Accessibility Inspector: en Xcode → Open Developer Tool → Accessibility Inspector, selecciona el simulador y activa o desactiva Reduce Motion en su panel de ajustes mientras la app se ejecuta.
  • Ajustes del simulador o del dispositivo: Ajustes → Accesibilidad → Movimiento → Reducir movimiento.

Revisa cada pantalla en ambos estados: que los loops se detengan, que no desaparezca nada importante y que, al cambiar el ajuste con una pantalla abierta, esta se actualice (SwiftUI lo hace automáticamente; UIKit solo si observas la notificación).

Preguntas frecuentes

¿accessibilityReduceMotion está disponible en watchOS y visionOS?

Sí. Apple indica que el valor de entorno accessibilityReduceMotion de SwiftUI está disponible en iOS 13 y posteriores, watchOS 6 y posteriores, y visionOS 1. En UIKit, UIAccessibility.isReduceMotionEnabled está disponible desde iOS 8, y reduceMotionStatusDidChangeNotification te avisa cuando el usuario cambia el ajuste mientras tu app se está ejecutando.

¿Reducir movimiento debe detener toda la animación?

No. Te pide reducir el movimiento, sobre todo los desplazamientos grandes, en loop o con parallax. Los fundidos cortos, los cambios de color y los cambios de estado suelen estar bien. Mantén la respuesta visual que indica al usuario que algo pasó, pero sin desplazamientos, escalados ni rebotes, para que nadie pierda información por activar el ajuste.

¿Rive respeta Reducir movimiento automáticamente?

No. El runtime de Rive reproduce lo que haga la máquina de estados. Lee tú mismo accessibilityReduceMotion o UIAccessibility.isReduceMotionEnabled y pausa la vista con .paused(_:) o isPaused, o reemplázala por una imagen fija del estado de ánimo actual. Establece un estado de ánimo tranquilo antes de pausar para que el fotograma congelado se vea como una pose.

Respuestas rápidas de esta guía

Fuentes

Guías relacionadas