Animations & Motion Design

Rive Animations: Interactive State Machine Animations

16 min Lesson 13 of 13

Rive Animations: Interactive State Machine Animations

Rive is a real-time interactive design and animation tool that produces compact .riv files consumed directly by Flutter. Unlike traditional sprite sheets or Lottie JSON files, Rive animations embed a state machine — a finite-state automaton that defines states, transitions, and inputs. This lets you drive complex, branching animations entirely from Dart code without shipping hundreds of frame images.

Why Use Rive Over Other Animation Approaches?

  • State machines replace conditional animation logic in Dart with designer-controlled graph nodes.
  • File size — a rich character animation typically weighs 20–80 KB in .riv format versus several MB of sprite frames.
  • Real-time interpolation — Rive's renderer interpolates between states at runtime, producing silky-smooth transitions without pre-baking frames.
  • Two-way binding — Boolean, trigger, and number inputs flow from Dart into the state machine; the machine's active state can be read back.
Note: Rive state machines are authored in the Rive editor. You design the art and define the state graph there; Dart code only sends inputs and reacts to events.

Step 1 — Add the Dependency and Asset

Add rive to pubspec.yaml and declare your .riv file as a Flutter asset:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  rive: ^0.13.0          # use the latest stable version

flutter:
  assets:
    - assets/animations/button.riv

Run flutter pub get to fetch the package. Place button.riv inside assets/animations/.

Step 2 — Load the .riv File and Attach a State Machine Controller

The recommended approach is to load the asset bytes in initState, parse the RiveFile, locate the artboard you want, and attach a StateMachineController. The controller exposes named inputs that you store as fields so you can fire them later.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:rive/rive.dart';

class RiveButtonDemo extends StatefulWidget {
  const RiveButtonDemo({super.key});

  @override
  State<RiveButtonDemo> createState() => _RiveButtonDemoState();
}

class _RiveButtonDemoState extends State<RiveButtonDemo> {
  // Artboard reference used by RiveAnimation.direct
  Artboard? _artboard;

  // Inputs retrieved from the state machine
  SMITrigger? _pressTrigger;
  SMIBool? _isHovered;

  @override
  void initState() {
    super.initState();
    _loadRive();
  }

  Future<void> _loadRive() async {
    // 1. Read the asset bytes
    final data = await rootBundle.load('assets/animations/button.riv');

    // 2. Parse the Rive file
    final file = RiveFile.import(data);

    // 3. Grab the default (or named) artboard
    final artboard = file.mainArtboard;

    // 4. Find the state machine by name and attach it
    final controller = StateMachineController.fromArtboard(
      artboard,
      'ButtonMachine',       // must match the name in the Rive editor
      onStateChange: _onStateChange,
    );

    if (controller != null) {
      artboard.addController(controller);

      // 5. Cache the inputs so we can fire them later
      _pressTrigger = controller.findInput<SMITrigger>('Press');
      _isHovered    = controller.findInput<SMIBool>('Hovered');
    }

    // 6. Trigger a rebuild with the loaded artboard
    setState(() => _artboard = artboard);
  }

  void _onStateChange(String machineName, String stateName) {
    debugPrint('State machine "$machineName" entered state "$stateName"');
  }

  @override
  Widget build(BuildContext context) {
    if (_artboard == null) return const CircularProgressIndicator();

    return GestureDetector(
      onTapDown: (_) => _pressTrigger?.fire(),
      onTapUp:   (_) => _isHovered?.value = false,
      child: SizedBox(
        width: 200,
        height: 80,
        child: RiveAnimation.direct(_artboard!),
      ),
    );
  }
}
Tip: Always check controller != null before using it. StateMachineController.fromArtboard returns null when the machine name does not match — a silent failure that is easy to miss during development.

State Machine Input Types

Rive exposes three input primitives that map to Dart types:

  • SMITrigger — one-shot pulse; call .fire() to send a transient event (e.g., button press, jump).
  • SMIBool — persistent Boolean; set .value = true/false (e.g., hover state, active flag).
  • SMINumber — persistent floating-point; set .value = 0.75 (e.g., health percentage, speed blend).

Step 3 — Triggering Transitions from User Input

Wiring Flutter gestures to Rive inputs is straightforward. The example below shows a toggle switch whose idle, hover, and toggle states are all handled by the state machine — your Dart code only manages the SMIBool value:

class RiveToggle extends StatefulWidget {
  const RiveToggle({super.key});

  @override
  State<RiveToggle> createState() => _RiveToggleState();
}

class _RiveToggleState extends State<RiveToggle> {
  Artboard? _artboard;
  SMIBool? _isOn;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    final bytes = await rootBundle.load('assets/animations/toggle.riv');
    final file  = RiveFile.import(bytes);
    final board = file.mainArtboard;

    final ctrl = StateMachineController.fromArtboard(board, 'ToggleMachine');
    if (ctrl != null) {
      board.addController(ctrl);
      _isOn = ctrl.findInput<SMIBool>('IsOn');
    }
    setState(() => _artboard = board);
  }

  void _toggle() {
    if (_isOn != null) {
      _isOn!.value = !_isOn!.value;   // flip the Boolean input
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_artboard == null) {
      return const SizedBox(width: 80, height: 40);
    }
    return GestureDetector(
      onTap: _toggle,
      child: SizedBox(
        width: 80,
        height: 40,
        child: RiveAnimation.direct(_artboard!),
      ),
    );
  }
}
Warning: Never call setState() inside the onStateChange callback to trigger a heavy UI rebuild — the callback fires on the render thread. Use it for lightweight side-effects (analytics, audio cues). If you must update Flutter state, dispatch the call via WidgetsBinding.instance.addPostFrameCallback.

Using RiveAnimation.network vs RiveAnimation.asset vs RiveAnimation.direct

  • RiveAnimation.asset('assets/anim.riv', stateMachines: ['Machine']) — simplest; no controller needed for basic playback, but you cannot drive inputs.
  • RiveAnimation.network(url) — streams a remote .riv; same limitation.
  • RiveAnimation.direct(_artboard!) — required when you need a StateMachineController for input-driven animations.

Summary

Integrating Rive state machine animations in Flutter requires four coordinated steps: adding the rive package and asset, loading and parsing the .riv file at runtime, attaching a StateMachineController by machine name, and caching typed inputs (SMITrigger, SMIBool, SMINumber) to fire or set in response to user gestures. This architecture cleanly separates animation design (Rive editor) from interaction logic (Dart), yielding expressive, designer-driven motion without inflating your codebase.