Pular para o conteúdo
charmotion

Movimento reduzido (Reduce Motion) no SwiftUI e UIKit

Por equipe Charmotion · Atualizado em

Ilustração de capa do guia de iOS e Swift: Reduzir Movimento no SwiftUI e no UIKit, com personagens de demonstração do Charmotion

No SwiftUI, leia @Environment(\.accessibilityReduceMotion) var reduceMotion; o valor é atualizado na hora quando o usuário muda o ajuste. No UIKit, verifique UIAccessibility.isReduceMotionEnabled e observe UIAccessibility.reduceMotionStatusDidChangeNotification. Quando estiver ativado, substitua loops ou movimentos grandes por uma pose parada ou um crossfade, e pause as views de Rive e Lottie.

Principais pontos

  • accessibilityReduceMotion está disponível a partir do iOS 13, watchOS 6 e visionOS 1; isReduceMotionEnabled a partir do iOS 8.
  • O SwiftUI renderiza de novo automaticamente quando o ajuste muda; o UIKit precisa da notificação de mudança.
  • O novo runtime do Rive tem .paused(_:) no RiveUIViewRepresentable e isPaused no RiveUIView.
  • Mantenha as mudanças de estado e tire o movimento: o mascote ainda deve mostrar "success" como uma pose, não desaparecer.
  • Pule totalmente intros decorativas e comemorações que tocam uma vez.

Como ler o Reduzir Movimento no SwiftUI?

Declare o valor de ambiente em qualquer view. Ele é true quando Ajustes → Acessibilidade → Movimento → Reduzir Movimento está ativado, e o SwiftUI reavalia o body quando ele muda.

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 animações explícitas, escolha uma animação mais suave ou nenhuma, em vez de ramificar a view inteira:

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

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

Os princípios mais amplos (o que conta como movimento, WCAG 2.3.3) estão em animação acessível e movimento reduzido; este guia foca em código para plataformas Apple.

Como ler o Reduzir Movimento no UIKit e acompanhar as mudanças?

UIAccessibility.isReduceMotionEnabled retorna o valor atual. Ele não avisa você sobre mudanças, então observe UIAccessibility.reduceMotionStatusDidChangeNotification para atualizar uma tela que já está visível:

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
    }
}

Em chamadas de UIView.animate, verifique a flag no momento da chamada e use um cross-dissolve mais curto, ou defina o estado final sem animação.

O que um mascote deve fazer quando o Reduzir Movimento está ativado?

Remova o movimento, mas mantenha o significado. Um mascote que informa estado ("carregando", "pronto", "algo deu errado") deve continuar informando.

Movimento completoAlternativa reduzida
Loop idle de respiração ou piscadaPose idle estática
Loop de carregamentoPose "thinking" estática com um texto ou indicador de progresso
Reação única de success / errorTroca para uma imagem parada happy ou sad com um fade curto, mais um rótulo de texto
Animação de entrada (entra voando, quica)Entrada com crossfade
Intro de aberturaPule, ou mostre um frame parado durante o carregamento
Loop de attention para um item novoPose attention estática e um badge

O jeito mais fácil de implementar isso é um dicionário de mood para imagem parada, usando frames do mesmo personagem para que a versão reduzida pareça 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: um trigger que toca uma vez e depois volta ao humor atual.

Como pausar Rive ou Lottie com o Reduzir Movimento?

Pausar congela o frame atual, o que é aceitável para um loop decorativo se esse frame funcionar como pose. Cada runtime tem uma chave direta:

// 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)))

Dois cuidados:

  • Um frame pausado pode estar no meio do movimento. Se você pausar uma view Rive depois que ela já estava tocando, ela pode parar em uma posição intermediária estranha. Definir o mood para um estado calmo (idle) antes de pausar, ou mostrar uma imagem parada no lugar, evita isso.
  • Não dispare triggers com a animação pausada. Uma state machine pausada não avança, então as reações únicas não vão tocar. Mostre o resultado na imagem parada ou em texto.

O Lottie 4.3.0 e versões posteriores também podem tocar automaticamente um marcador chamado reduced motion quando o Reduzir Movimento está ativado, se o designer incluir um no arquivo.

Ainda devo mostrar a intro de abertura com o Reduzir Movimento?

Não. Uma intro é decorativa por definição, e pulá-la é o ganho mais claro para quem ativou o ajuste. Condicione a sobreposição ao valor de ambiente:

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)
            }
        }
    }
}

O padrão completo, incluindo como combinar com a launch screen estática, está em splash screen animada em SwiftUI.

Se o personagem tem significado no seu app, as poses estáticas podem vir direto do conjunto de animações: em todos os planos pagos, cada conjunto inclui um pôster estático de cada animação ao lado do .riv, e exporta as 17 como MP4, GIF e WebM/MOV transparentes sob demanda, assim você tem uma imagem estática combinando com cada humor.

Como aplicar o Reduzir Movimento a transições e efeitos de rolagem?

A animação do personagem é só uma parte do movimento em um app. As transições fornecidas pelo sistema ficam por conta da plataforma; cada efeito personalizado que você adiciona, cabe a você adaptar.

  • Transições de inserção e remoção: troque .move, .slide e .scale por .opacity.
  • Efeitos guiados pela rolagem (scrollTransition, cabeçalhos com parallax): retorne o efeito identidade quando o movimento estiver reduzido.
  • Animações hero com matchedGeometryEffect: mantenha a mudança de estado, mas remova a animação passando nil para withAnimation.
  • Efeitos de símbolos: prefira efeitos sem deslocamento, como uma mudança de cor, em vez de quiques.
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)
            }
    }
}

Um pequeno helper mantém a escolha consistente em todo o app:

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

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

Como testar o Reduzir Movimento rapidamente?

Três formas, da mais rápida para a mais lenta:

  • Previews do SwiftUI: accessibilityReduceMotion é somente leitura, então .environment(\.accessibilityReduceMotion, true) não compila. Leia o valor em uma view pai e passe um parâmetro reduceMotion: Bool para a view que você quer visualizar; depois, mostre os dois valores lado a lado no preview.
  • Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector, selecione o simulador e ative ou desative o Reduce Motion no painel de configurações enquanto o app roda.
  • Ajustes do simulador ou do dispositivo: Ajustes → Acessibilidade → Movimento → Reduzir Movimento.

Confira cada tela nos dois estados: se os loops param, se nada importante desaparece e se alterar o ajuste com a tela aberta a atualiza (o SwiftUI faz isso automaticamente; o UIKit só se você observar a notificação).

Perguntas frequentes

O accessibilityReduceMotion está disponível no watchOS e no visionOS?

Sim. A Apple indica o valor de ambiente accessibilityReduceMotion do SwiftUI para iOS 13 ou posterior, watchOS 6 ou posterior e visionOS 1. No UIKit, UIAccessibility.isReduceMotionEnabled está disponível desde o iOS 8, e a reduceMotionStatusDidChangeNotification avisa quando o usuário altera o ajuste com o app em execução.

O Reduzir Movimento deve parar toda a animação?

Não. Ele pede que você reduza o movimento, principalmente deslocamentos grandes, em loop ou com parallax. Fades curtos, mudanças de cor e trocas de estado geralmente não são problema. Mantenha o feedback que mostra ao usuário que algo aconteceu, só que sem deslocamento, mudança de escala ou quiques, para que ninguém perca informação por ter ativado o ajuste.

O Rive respeita o Reduzir Movimento automaticamente?

Não. O runtime do Rive reproduz o que a state machine mandar. Leia você mesmo accessibilityReduceMotion ou UIAccessibility.isReduceMotionEnabled e pause a view com .paused(_:) ou isPaused, ou substitua-a por uma imagem estática do humor atual. Defina um humor calmo antes de pausar, para que o frame congelado pareça uma pose.

Respostas rápidas deste guia

Fontes

Guias relacionados