Mascote animado de onboarding em SwiftUI
Por equipe Charmotion · Atualizado em

Para animar um mascote de onboarding em SwiftUI, coloque um TabView com .tabViewStyle(.page) abaixo de uma única view do mascote, que fica fora das páginas. Quando a página selecionada mudar, defina a propriedade de humor do mascote e dispare um trigger de reação única nos momentos-chave. Com uma só view, a animação continua sem interrupção, em vez de recarregar a cada deslize.
Principais pontos
- Mantenha uma única view do mascote fora do
TabView. Um mascote dentro de cada página recarrega e reinicia a cada deslize. - Associe cada página a um humor em loop e use triggers só para momentos específicos (boas-vindas, permissão concedida, concluído).
- O
PageTabViewStyleestá disponível a partir do iOS 14, a mesma versão mínima do runtime do Rive. - Com Reduzir Movimento ativado, mostre uma pose estática por página e pule as reações únicas.
Como criar um onboarding paginado com TabView(.page)?
Um TabView vinculado a uma seleção e com o estilo .page oferece páginas deslizáveis com indicador de página, disponível desde o iOS 14. Modele as páginas como um enum para que cada uma tenha seu próprio texto e estado do mascote:
enum OnboardingPage: Int, CaseIterable, Identifiable {
case welcome, feature, permission, paywall
var id: Int { rawValue }
var title: String {
switch self {
case .welcome: return "Meet Kiko"
case .feature: return "Snap a receipt, Kiko sorts it"
case .permission: return "Allow notifications?"
case .paywall: return "Go Pro"
}
}
}
struct OnboardingPages: View {
@Binding var page: OnboardingPage
var body: some View {
TabView(selection: $page) {
ForEach(OnboardingPage.allCases) { item in
Text(item.title)
.font(.title2.bold())
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.tag(item)
}
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
}
O .tag(item) precisa ter o mesmo tipo do binding de seleção; caso contrário, deslizar não atualiza page.
Como manter uma única view do mascote ativa entre as páginas?
Coloque o mascote acima do TabView em um VStack, e não dentro do ForEach. As páginas são criadas e destruídas conforme você desliza; um mascote dentro delas recarregaria o arquivo e reiniciaria do estado de entrada todas as vezes. Fora delas, ele continua tocando e apenas muda de estado.
O controller abaixo usa o novo runtime do Rive para Apple e mantém a instância do view model vinculada, o que é necessário para alterar valores depois:
import SwiftUI
import RiveRuntime
@MainActor
final class MascotController: ObservableObject {
@Published private(set) var rive: Rive?
private var instance: ViewModelInstance?
func load(initialMood: Int) async {
guard rive == nil else { return }
do {
let worker = try await Worker()
let file = try await File(source: .local("mascot", Bundle.main), worker: worker)
let artboard = try await file.createArtboard()
let instance = try await file.createViewModelInstance(
.viewModelDefault(from: .artboardDefault(artboard))
)
instance.setValue(of: NumberProperty(path: "mood"), to: Float(initialMood))
let machine = try await artboard.createStateMachine(
"CharacterViewModelMachine",
binding: instance
)
self.instance = instance
rive = try await Rive(file: file, artboard: artboard, stateMachine: machine)
} catch {
print("Mascot failed to load: \(error)")
}
}
func setMood(_ mood: Int) {
instance?.setValue(of: NumberProperty(path: "mood"), to: Float(mood))
}
func fire(_ trigger: String) {
instance?.fire(trigger: TriggerProperty(path: trigger))
}
}
Definir o primeiro humor antes de a view aparecer evita um frame com o estado padrão do arquivo, ponto que o guia de migração do Rive menciona explicitamente.
Como associar cada página do onboarding a um humor do mascote?
Defina primeiro o mapeamento em uma tabela e depois codifique-o no enum. Os loops descrevem o que o usuário está fazendo na página; os triggers marcam momentos.
| Página | Loop (mood) | Trigger |
|---|---|---|
| Boas-vindas | wave (2) | enter na primeira exibição |
| Recurso | thinking (3) | nenhum |
| Permissão | listening (6) | yes ou no após o alerta do sistema |
| Paywall | happy (1) | nenhum (mantenha o clima calmo) |
| Concluído | idle (0) | success |
extension OnboardingPage {
var mood: Int {
switch self {
case .welcome: return 2
case .feature: return 3
case .permission: return 6
case .paywall: return 1
}
}
}
struct OnboardingView: View {
@StateObject private var mascot = MascotController()
@State private var page: OnboardingPage = .welcome
var body: some View {
VStack(spacing: 0) {
RiveUIViewRepresentable(rive: mascot.rive)
.frame(height: 260)
.accessibilityHidden(true)
OnboardingPages(page: $page)
}
.task { await mascot.load(initialMood: page.mood) }
.onChange(of: page) { _, newPage in
mascot.setMood(newPage.mood)
}
}
}
A closure de onChange(of:) com dois parâmetros é a forma do iOS 17; no iOS 14 a 16, use a versão com um parâmetro. Como a state machine cuida das transições, deslizar rapidamente para a frente e para trás é seguro: vale o último valor.

Como disparar uma reação única quando uma página aparece?
Dispare triggers a partir de eventos, não do layout. Bons pontos de gancho são o fim do carregamento (enter), o resultado de um alerta de permissão e o botão final:
Button("Allow notifications") {
Task {
let center = UNUserNotificationCenter.current()
let granted = (try? await center.requestAuthorization(options: [.alert, .badge])) ?? false
mascot.fire(granted ? "yes" : "no")
withAnimation { page = .paywall }
}
}
(Isso exige import UserNotifications.) Evite disparar um trigger no onAppear de uma página: o TabView pode criar páginas vizinhas antecipadamente, então o onAppear pode rodar antes de o usuário realmente ver a página. Reagir à seleção de page, como no onChange acima, é confiável.
Mantenha as reações únicas curtas e raras. Um mascote que comemora a cada deslize perde o sentido já na terceira página; guarde o success para a conclusão de verdade.

Como respeitar o Reduzir Movimento no onboarding?
Leia @Environment(\.accessibilityReduceMotion). Quando for verdadeiro, substitua a view ao vivo por uma imagem estática por página e pule os triggers. Uma transição curta com fade entre as imagens estáticas é um substituto comum para o movimento no modo de movimento reduzido.
struct OnboardingMascot: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var mascot: MascotController
let page: OnboardingPage
var body: some View {
if reduceMotion {
Image("mascot-mood-\(page.mood)")
.resizable()
.scaledToFit()
.id(page)
.transition(.opacity)
} else {
RiveUIViewRepresentable(rive: mascot.rive)
}
}
}
As imagens estáticas podem ser frames exportados dos vídeos do mesmo personagem, para que a versão reduzida combine com a animada. Qualquer informação que o mascote transmita, como "permissão concedida", também deve aparecer em texto. Há mais padrões em Reduzir Movimento em SwiftUI e UIKit.
O onboarding deve começar com uma splash ou ir direto ao conteúdo?
As Human Interface Guidelines da Apple são diretas: a launch screen (tela de inicialização) deve ser "quase idêntica à primeira tela do seu app" e "não é uma oportunidade de branding". Se você quiser um momento de marca, as HIG sugerem mostrá-lo "no início do seu fluxo de onboarding".
Isso faz da página de boas-vindas o lugar natural para a entrada do mascote: a launch screen do sistema mostra um fundo simples, a primeira página do onboarding aparece com o mesmo fundo, e o mascote toca enter e se acomoda no loop de aceno enquanto o usuário lê. Não é preciso uma splash screen separada. O padrão de código para uma intro independente está em splash screen animada em SwiftUI.
Se você ainda não tem um personagem animado, o Charmotion gera um a partir de um mascote ou logo enviado: 17 animações, normalmente em poucos minutos. O .riv com o contrato de mood e triggers usado acima, além de arquivos SwiftUI para os dois runtimes do Rive, vem com todos os planos pagos (a partir de US$ 20/mês); o plano gratuito não tem conjuntos de personagem. Veja os preços.
Adicione um mascote animado a um onboarding em SwiftUI
- Modele as páginas. Crie um enum de páginas com um título e um número de humor para cada uma.
- Monte o paginador. Use
TabView(selection:)com.tabViewStyle(.page)e marque cada página com o case correspondente do enum. - Adicione um mascote. Coloque
RiveUIViewRepresentable(rive:)acima do TabView em um VStack, apoiado por um controller que mantém a instância do view model. - Sincronize o humor. Em
.onChange(of: page), definaNumberProperty(path: "mood")com o humor da página. - Dispare os momentos. Dispare os triggers
enter,yes/noesuccessa partir de eventos reais, como o resultado de uma permissão. - Trate o Reduzir Movimento. Troque a view ao vivo por uma imagem estática por página quando
accessibilityReduceMotionfor verdadeiro.
Perguntas frequentes
Por que meu mascote reinicia em cada página do onboarding?
Ele está dentro das páginas do TabView, então o SwiftUI cria uma nova view, e um novo arquivo Rive, para cada página. Mova o mascote para fora do TabView e mantenha o controller dele em um @StateObject na view pai. Assim, só o valor de mood muda quando a página muda.
Posso usar Lottie em vez de Rive para um mascote de onboarding?
Sim, se cada página apenas tocar o próprio clipe. O Lottie não tem state machine com data binding no LottieView, então você mesmo alterna entre arquivos ou segmentos de marcadores, e as transições entre eles são cortes secos, a menos que você as projete. O Rive cuida da mesclagem entre humores dentro de um único arquivo.
O mascote deve ficar visível para o VoiceOver?
Normalmente não. No onboarding, o texto transmite a mensagem, então marque o mascote com accessibilityHidden(true). Se o personagem comunicar algo que não está no texto, dê a ele um rótulo de acessibilidade que descreva o estado, como "Configuração concluída", e não a animação em si.