Reducir movimiento en SwiftUI y UIKit
Por el equipo de Charmotion · Actualizado el

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
accessibilityReduceMotionestá 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(_:)enRiveUIViewRepresentableeisPausedenRiveUIView. - 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 completo | Alternativa reducida |
|---|---|
| Loop en reposo (idle) de respiración o parpadeo | Pose fija en reposo |
| Loop de carga | Pose fija de "pensando" más un texto o indicador de progreso |
| Reacción puntual de éxito / error | Cambiar 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 inicio | Omitirla, o una imagen fija mientras carga |
| Loop de attention para un elemento nuevo | Pose 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) }
}
}
¿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,.slidey.scalepor.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 pasandonilawithAnimation. - 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:
accessibilityReduceMotiones de solo lectura, así que.environment(\.accessibilityReduceMotion, true)no compila. Lee el valor en una vista padre y pasa un parámetroreduceMotion: Boola 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
- accessibilityReduceMotion, Apple
- UIAccessibility.isReduceMotionEnabled, Apple
- UIAccessibility.reduceMotionStatusDidChangeNotification, Apple
- Apple runtime (new and legacy), Rive
- RiveUIViewRepresentable.swift, Rive (GitHub)
- lottie-ios 4.3.0 release notes, Airbnb (GitHub)