【Flutter】Riveと実現する滑らかなインタラクティブアニメーション

  • 2024年4月6日
  • 2024年4月6日
  • Widget

対象者

  • FlutterにRiveを組み込みたい人
  • インタラクティブなUIとアニメーションに関心があり、ユーザー体験を向上させたい方

Riveの学習したい方は、対象ではありません。

はじめに

この記事は、デザインツールRiveの基本からFlutterでの応用まで取り組んでいきます。
あなたがアプリ開発者で、プロジェクトに新しい息吹を吹き込みたいと考えているなら、まさにこの記事が最適なスタートラインになります。RiveとFlutterの連携によって実現可能な、目を見張るようなアニメーションとインタラクションをアプリ内で実現できるでしょう。

Riveの基本

Riveとは

Riveは、アニメーションやインタラクティブなデザインを簡単に作成、管理できるツールです。特にUI/UXデザイナーや開発者が協力して、アプリやウェブのための生き生きとしたアニメーションを作ることを目指しています。Riveの魅力は、コーディングスキルがなくても直感的にアニメーションをデザインできる点にあります。

Riveの特徴

Riveの最大の特徴は、その使いやすさと柔軟性です。ドラッグアンドドロップのインターフェースを通じて、複雑なアニメーションも簡単に作成可能です。また、Riveはフルスタックのアニメーションソリューションを提供し、Flutterなどのプラットフォームにも簡単に組み込むことができます。これにより、開発者とデザイナーの間の協業がスムーズになります。

Riveでできること

Riveを使用すると、ボタンクリックの反応やローディングアニメーションなど、ユーザーの操作に対する直接的なフィードバックを提供するインタラクティブな要素を簡単に作成できます。また、複雑な動きのキャラクターやロゴアニメーションなど、視覚的に魅力的なコンテンツの制作も可能です。実際に、多くの商用プロジェクトやアプリケーションで、Riveによるアニメーションが利用されており、その実用性と効果が証明されています。

無料プランの制限

無料プランだと、ファイルが3つしか作成できないみたい、、

Riveの始め方

Riveのアカウント作成方法

Riveを始める第一歩は、Riveの公式ウェブサイトでアカウントを作成することです。これにより、Rive Editorへのアクセスが可能になり、自分だけのアニメーションを作成できるようになります。アカウント作成は無料で、メールアドレスやソーシャルメディアアカウントを使用して簡単に登録できます。

Rive Editorの基本操作

Rive Editorは、直感的なインターフェイスを備えており、アニメーションの作成を簡単にします。基本的な操作には、オブジェクトの追加、アニメーションのタイムライン管理、キーフレームの設定などが含まれます。

一応以下を参考に作ってみました。「画像の左上の円で調整するとキーフレームが追加される」ことが分からなかった(アニメーション作成ツールとか使ったことないです、、)
WebアニメーションはRiveが便利!
SVGを二つ配置して、それぞれ移動させるだけのアニメーションを作りました。それをエキスポートしました

Flutterでの実装

インストール

以下でパッケージが入ります。

 flutter pub add rive
flutter pub get

またAndroid実施時にエラーが出た起動できなかったので、「android\app\build.gradle」を修正しました。

android {
    compileSdkVersion flutter.compileSdkVersion
-   ndkVersion flutter.ndkVersion
+   ndkVersion "25.1.8937393"

Riveを表示する

表示は以下でできました。超簡単。

RiveAnimation.asset(
        'assets/swords.riv',
        animations: ['Timeline 1'],
),

State Machineを使用したアニメーションの制御

RiveのState Machineを利用すると、複数のアニメーション状態を管理し、それらの間でスムーズに遷移させることができます。Flutterアプリケーション内でState Machineを使用するには、Riveファイル内で定義されたトリガーやステートをコントロールするためのロジックを実装します。これにより、ユーザーの入力やアプリケーションの状態に応じて、アニメーションを動的に変更することが可能になります。

Riveのユーザ登録をすると、サンプルにレーティングのアニメーションがあったので、エキスポートして、使ってみました。「State Machine1」の「rating」という変数の値を変更すると、星の数が変わります。

Riveのステートマシンの設定

Riveアニメーションファイル(.riv)には、複数のアニメーションステートとその遷移が定義されています。Flutterアプリにおいてこれらを制御するために、RiveAnimation.assetウィジェットのonInitコールバックを使用しています。

late final SMIInput<double> _rating;
var _currentAnimationState = '';

RiveAnimation.asset(
    'assets/rating_animation.riv',
    onInit: (Artboard artboard) {
      final controller = StateMachineController.fromArtboard(
            artboard,
            'State Machine 1', // Animations欄の StateMachine名
            onStateChange: (stateMachine, stateName) {
              setState(() {
                _currentAnimationState = '$stateMachine $stateName';
              });
            }, // callback関数
      );
      artboard.addController(controller!);
      _rating = controller.findInput<double>('rating') as SMINumber;
    },
)

ここでの重要な点は、StateMachineController.fromArtboardメソッドを使って、Riveアートボードからステートマシンを初期化していることです。ステートマシンの名前(この例では'State Machine 1')を指定し、状態変更時のコールバックを設定します。このコールバックは、ステートマシンの状態が変更されたときに呼び出され、UIの再描画をトリガーします。

ステートマシンの更新方法

ユーザーがアプリ内で特定のアクション(この例ではボタンタップ)を行った際にステートマシンを更新する方法を示します。

FilledButton(
      onPressed: () {
        _rating.change(index + 1);
      },
      child: Text('${index + 1}')),
),

ここでは、FilledButtonウィジェットのonPressedイベントに、ステートマシンのratingを変更するロジックを組み込んでいます。_rating.change(index + 1);行は、ボタンが押されるとTextに表示された野と同じ値にratingを変更し、それに応じてアニメーションが発火します。

まとめ

アニメーション作成ツールとしてのRiveに初めて挑戦し、その体験をFlutterアプリに取り入れる過程を共有しました。アニメーションに関しては完全な初心者からのスタートでしたが、Riveの直感的な操作性により、基本的な動きは実現できました。オブジェクトを移動させるといったシンプルなアニメーションは、Flutterと同様に、初心者でも手軽に作成可能です。

Riveのプレビュー機能は、作成中のアニメーションが実際にどのように動作するのかを確認できるため、作業プロセスを非常に楽しくします。一方で、より複雑なアニメーションにチャレンジする際には、その仕組みを理解するのが難しくなることもあり、ここではアニメーション制作の経験が豊富なデザイナーのサポートが不可欠になるかもしれません。

Riveを実際に触ってみてファイルをエキスポートして、Flutterアプリからアニメーション操作する方法を実現できました。完璧ではないかもしれませんが、このプロセスを通じて得た知見を共有し、同じくアニメーション制作に興味を持つ開発者の皆さんが一歩踏み出すきっかけになれば幸いです。

参考

ソース(main.dartにコピペして動作確認用)

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final SMIInput<double> _rating;
  var _currentAnimationState = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          children: [
            const SizedBox(
              width: 200,
              height: 200,
              child: RiveAnimation.asset(
                'assets/swords.riv',
                animations: ['Timeline 1'],
              ),
            ),
            const SizedBox(height: 50),
            SizedBox(
              height: 300,
              child: RiveAnimation.asset(
                'assets/rating_animation.riv',
                onInit: (Artboard artboard) {
                  final controller = StateMachineController.fromArtboard(
                    artboard,
                    'State Machine 1', // Animations欄の StateMachine名
                    onStateChange: (stateMachine, stateName) {
                      setState(() {
                        _currentAnimationState = '$stateMachine $stateName';
                      });
                    }, // callback関数
                  );
                  artboard.addController(controller!);
                  _rating = controller.findInput<double>('rating') as SMINumber;
                },
              ),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: List.generate(
                5,
                (index) => Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: FilledButton(
                      onPressed: () {
                        _rating.change(index + 1);
                      },
                      child: Text('${index + 1}')),
                ),
              ),
            ),
            Text('State: $_currentAnimationState'),
          ],
        ),
      ),
    );
  }
}