Flutter x Rive2 でドラッグ位置を追跡してアニメーションさせてみる

17 min読了の目安(約16100字TECH技術記事

■ はじめに

はじめまして、ずみこう(@zmkfrog)と申します🐢

この記事はFlutter #1 Advent Calendar 2020の17日目の記事です🎄

■ 概要

Rive2公式Twitterがツイートしていた、カーソル位置を追跡するアニメーションを再現してみます👀


筆者が再現した最終成果物はこちら✍️
サンプルコード - Github

■ Rive2とは

Rive社(旧2Dimensions)が開発しているブラウザーベースのベクター2Dアニメーション制作ツールです。
2020年8月ごろにRive2が公開され、現在βテスト中です。

Rive(旧Flare)のUX改善等の理由で作り直したようです。
エディタのUIや、Flutterで使用する際のパッケージも新しくなっています。


Rive2のはじめ方や、エディタの使用法に関しては記載しません(使いこなせてない)🙏

Flutterで動かしてみたい場合は、公式チュートリアルが2つほど公開されているので、そちらを試してみるのが良いかと思います🚀
チュートリアル用のアニメーションファイルは用意されているため、サクッと入門できます🐢



■ 注意事項

今回再現するカーソル(ドラッグ)位置のトラッキングですが、ネット上にRive2以降の解説記事や資料が見当たらなかったため、既存の公式チュートリアルを参考にした筆者のオレオレ実装となっています。

あくまで「数ある方法の中の一つ」程度に見ていただければと思います🐢

そのうち公式からチュートリアルが公開されるんじゃないかと思います。楽しみ!

■ 環境

Rive2 エディタ

v0.4.32

flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 1.22.4, on Mac OS X 10.15.7 19H114 darwin-x64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.2)
[✓] Xcode - develop for iOS and macOS (Xcode 12.2)
[✓] Android Studio (version 4.1)
[✓] VS Code (version 1.51.1)
[✓] Connected device (1 available)

■ アニメーション作成

作成したアニメーションは以下の4つです。

Idle Vertical Horizontal Scaling

それぞれ以下のような役割です。

  • Idle
    待機状態。上下に揺れたり まばたき したり。
  • Vertical
    縦方向の視線の動き。
  • Horizontal
    横方向の視線の動き。
  • Scaling
    瞳孔の拡大/縮小。

これらのアニメーションを、ドラッグ位置を元にミックスして再生していきます。

作成の流れについては、記事が長くなるので↓のスクラップにメモ書きしました🧻

■ Flutterへ導入

流れ

  1. ベースとなるWidgetを用意
  2. ドラッグ位置の取得
  3. ドラッグ位置の割合(0~1)を求める計算処理を追加する
  4. riveパッケージを導入
  5. アニメーションをインポート
  6. Idleアニメーションを再生してみる
  7. カスタムコントローラーを作成
  8. カスタムコントローラーの使用

1. ベースとなるWidgetを用意

アニメーションの再生とドラッグ位置の取得を行うための以下のようなWidgetの用意しておきます。
(ここでは、tracking_sample.dartとしました)

Stackアニメーション表示用Widgetドラッグ位置取得用Widgetを重ねています。

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

class TrackingSample extends StatefulWidget {
  const TrackingSample({Key key}) : super(key: key);

  
  _TrackingSampleState createState() => _TrackingSampleState();
}

class _TrackingSampleState extends State<TrackingSample> {
  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Stack(
      children: [
	// アニメーション表示用Widget
	Center(
	  child: SizedBox(
	    height: 160,
	    width: 160,
	    // 後ほどアニメーションに置き換える
	    child: Placeholder(),
	  ),
	),
	// ドラッグ位置取得用Widget
	GestureDetector(
	  child: Container(
	    color: Colors.transparent,
	  ),
	),
      ],
    );
  }
}

2. ドラッグ位置の取得

今回は、縦/横の両方向を取得したいので、GestureDetectoronPan〇〇系を使用します。

縦方向のみ必要な場合は、onVerticalDrag○○系
横方向のみ必要な場合は、onHorizontalDrag○○系
を適宜使い分けると良さそうです。

先ほどのGestureDetectorに以下の三つのリスナーを追加。

tracking_sample.dart
GestureDetector(
  onPanDown: (detail) {
    // 初回画面タップ時に一度だけ呼び出される
    // トラッキングに使用
    print('onPanDown: ${detail.localPosition}');
  },
  onPanUpdate: (detail) {
    // ドラッグ位置が変化するたびに呼び出される
    // トラッキングに使用
    print('onPanUpdate: ${detail.localPosition}');
  },
  onPanEnd: (detail) {
    // ドラッグ終了時に呼び出される
    // トラッキングを終了し、アニメーションの初期化に使用
    print('onPanEnd');
  },
  child: Container(
    color: Colors.transparent,
  ),
),

ここで一度実行してみます。
左上が原点(横方向は左、縦方向は上)で、右下に向かうにつれて数値が大きくなるのが分かります。

デバッグ用にドラッグ位置を画面上に表示するコードが追加されていますが、気にしないでください。

3. ドラッグ位置の割合(0~1)を求める計算処理を追加する

アニメーション制御用に、全体を1とした時のドラッグ位置の割合を求める必要があります。
縦横それぞれ、ドラッグ位置Widget全体のサイズで割ることで求められます。

まずは、Widget全体のサイズを取得する処理を追加します。

tracking_sample.dart
// GlobalKeyを用意
final _globalKey = GlobalKey();

// ドラッグ位置取得用Widgetのサイズ
Size _widgetSize;

// Widgetサイズのセット
void _setWidgetSize() {
  // WidgetsBinding.instance.addPostFrameCallback に指定した処理は、
  // build完了後に実行されます。
  WidgetsBinding.instance.addPostFrameCallback((_) {
    // サイズ取得対象WidgetのGlobalKeyのContextからSizeを取得
    _widgetSize = _globalKey.currentContext.size;
  });
}


void initState() {
  super.initState();

  // Widgetサイズをセット
  _setWidgetSize();
}


Widget build(BuildContext context) {
  return Stack(
    children: [
      // アニメーション表示用Widget
      Center(),
      // ドラッグ位置取得用Widget
      GestureDetector(
        onPanDown: (detail) {},
        onPanUpdate: (detail) {},
        onPanEnd: (detail) {},
        child: Container(
          // ↓↓ GlobalKeyを指定する ↓↓
          key: _globalKey,
          color: Colors.transparent,
        ),
      ),
    ],
  );
}

次に、割合計算を行うメソッドを用意します。

tracking_sample.dart
// ドラッグ位置の割合を計算する
Offset _calcPercentage(Offset dragPosition, Size widgetSize) {
  // x軸の割合
  final x = dragPosition.dx / widgetSize.width;
  // y軸の割合
  final y = dragPosition.dy / widgetSize.height;	

  return Offset(x, y);
}

上記のメソッドは、GestureDetectorのonPanDownonPanUpdate内で使用します。

tracking_sample.dart
GestureDetector(
  onPanDown: (detail) {
    // 初回画面タップ時に一度だけ呼び出される
    // トラッキングに使用
    final percentage = _calcPercentage(
      detail.localPosition,
      _widgetSize,
    );
    
    print('onPanDown: $percentage');
  },
  onPanUpdate: (detail) {
    // ドラッグ位置が変化するたびに呼び出される
    // トラッキングに使用
    final percentage = _calcPercentage(
      detail.localPosition,
      _widgetSize,
    );
    
    print('onPanDown: $percentage');
  },
  onPanEnd: (detail) {...},
  child: Container(
    key: _globalKey,
    color: Colors.transparent,
  ),
),

もう一度、実行してみます。
左上(0,0)~右下(1,1)までの割合を取得できるようになりました🎉

デバッグ用にドラッグ位置を画面上に表示するコードが追加されていますが、気にしないでください。

4. riveパッケージを導入

やっとRive2関連の実装に入っていきます...🚀

  • pubspec.yamlに以下を追記
    pubspec.yaml
    dependencies:
      rive: ^0.6.4 # 2020/12/17最新
    
  • pub getする
    console
    $ flutter pub get
    

5. アニメーションをインポート

  • プロジェクト直下にassetsフォルダを作成し、eye.rivをインポートする
  • pubspec.yamlに以下を追記する
    pubspec.yaml
    flutter:
      assets:
        - 'assets/eye.riv'
    
  • pub getする
    console
    $ flutter pub get
    

6. Idleアニメーションを再生してみる

一旦、基本的なアニメーション再生処理を追加してIdleアニメーションのみ再生してみます。

まずは、riveパッケージをimport。

tracking_sample.dart
import 'package:rive/rive.dart';

次に、Artboardをセットする変数を用意します。

tracking_sample.dart
// 読み込んだRiveファイル内のArtboard
Artboard _artBoard;

さらに、Riveファイルを読み込むメソッドを追加。

tracking_sample.dart
// Riveファイルを読み込む
void _loadRiveFile() async {
  final bytes = await rootBundle.load('assets/eye.riv');
  final file = RiveFile();

  if (file.import(bytes)) {
    // ファイルの読み込みに成功
    setState(
      () {
        // Riveファイル内のアートボードを取得
        //
        // アートボードがひとつのみの場合は "mainArtboard" から、
        // 複数存在する場合は "artboardByName(String name)" を使用して名前指定で取得します。
        _artBoard = file.mainArtboard;

        // アニメーションコントローラーを追加する
        //
        // "SimpleAnimation" はriveパッケージの基本のコントローラーで、
        // パラメータに作成したアニメーション名を指定します。
        // addした時点でアニメーションの再生が開始されます。
        _artBoard.addController(
          SimpleAnimation('Idle'),
        );
      },
    );
  }
}

Riveファイル読み込みメソッドをinitStateで呼び出すようにします。

tracking_sample.dart

void initState() {
  super.initState();
  
  // Widgetサイズをセット
  _setWidgetSize();
  
  // Riveファイルを読み込む
  _loadRiveFile();
}

最後に、Stack内のアニメーション表示用Widgetの内容を以下のように書き換えれば、
Idleアニメーションの再生が確認できます👀

tracking_sample.dart

Widget build(BuildContext context) {
  return Stack(
    children: [
      // アニメーション表示用Widget
      Center(
        child: SizedBox(
          height: 160,
          width: 160,
          // ↓↓ ここ ↓↓
	  child: _artBoard != null
              ? Rive(
                  artboard: _artBoard,
                  fit: BoxFit.fill,
                  alignment: Alignment.center,
              )
            : Container(),
        ),
      ),
      // ドラッグ位置取得用Widget
      GestureDetector(),
    ],
  );
}


Idleアニメーションの再生

7. カスタムコントローラーを作成

ドラッグ位置をトラッキングしてアニメーションを再生する為に、カスタムしたコントローラーを作成していきます🎮

新規ファイルでRiveAnimationController<RuntimeArtboard>を継承したEyeControllerクラスを用意します。
いくつかオーバーライド可能なメソッドが存在しますが、今回はinitapplyに絞ります。

eye_controller.dart
import 'dart:math';
import 'dart:ui';

import 'package:rive/rive.dart';

class EyeController extends RiveAnimationController<RuntimeArtboard> {
  
  bool init(RuntimeArtboard core) {
    // TODO: implement init
    return super.init(core);
  }

  
  void apply(RuntimeArtboard core, double elapsedSeconds) {
    // TODO: implement apply
  }
}
  • initメソッド

EyeControllerクラスのインスタンス生成時に一度だけ実行されます。

いくつかメンバ変数を用意

eye_controller.dart
// アートボード
RuntimeArtboard _artBoard;

// アニメーション
LinearAnimationInstance _idle;
LinearAnimationInstance _vertical;
LinearAnimationInstance _horizontal;
LinearAnimationInstance _scaling;

// Idleアニメーションを除いた各アニメーションの再生終了時間(s)
double _verticalEndTime;
double _horizontalEndTime;
double _scalingEndTime;

// Idleアニメーションを除いた各アニメーションの再生時間の中央値(s)
// ドラッグ終了時に使用する
double _verticalMedianTime;
double _horizontalMedianTime;
double _scalingMedianTime;

メンバ変数への値のセット、アニメーションの開始処理を追加

eye_controller.dart

bool init(RuntimeArtboard core) {
  // アートボードをセット
  _artBoard = core;

  // 各アニメーションをセット
  // "animationByName(String name)" でアニメーションの名前を指定する
  _idle = core.animationByName('Idle');
  _vertical = core.animationByName('Vertical');
  _horizontal = core.animationByName('Horizontal');
  _scaling = core.animationByName('Scaling');

  // Verticalアニメーションの初期処理
  //
  // 再生終了時間を計算
  _verticalEndTime = _vertical.animation.duration / _vertical.animation.fps;
  // 再生時間の中央値を計算
  _verticalMedianTime = _verticalEndTime / 2;

  // Horizontalアニメーションの初期処理
  //
  // 再生終了時間を計算
  _horizontalEndTime =
      _horizontal.animation.duration / _horizontal.animation.fps;
  // 再生時間の中央値を計算
  _horizontalMedianTime = _horizontalEndTime / 2;

  // Scalingアニメーションの初期処理
  //
  // 再生終了時間を計算
  _scalingEndTime = _scaling.animation.duration / _scaling.animation.fps;
  // 再生時間の中央値を計算
  _scalingMedianTime = _scalingEndTime / 2;

  // isActive にtrueをセットして、アニメーションの再生を開始する
  isActive = true;
  return _idle != null;
}
  • applyメソッド

アニメーション再生中は、繰り返し呼び出されます🔁

Idleアニメーションの再生処理

eye_controller.dart

void apply(RuntimeArtboard core, double elapsedSeconds) {
  // Idleアニメーション
  _idle.animation.apply(_idle.time, coreContext: core);
  _idle.advance(elapsedSeconds);
}

Vertical/Horizontalアニメーションの再生処理

ドラッグ中(percentageに何らかの値がセットされている状態)のみ、その他のアニメーションを再生する処理を追加

VerticalHorizontalアニメーションに関しては、アニメーションの再生終了時間を
ドラッグ位置の割合で割ることで、描画すべき秒数を計算して反映しています。

eye_controller.dart

void apply(RuntimeArtboard core, double elapsedSeconds) {
  // Idleアニメーション
  _idle.animation.apply(_idle.time, coreContext: core);
  _idle.advance(elapsedSeconds);

  // ↓↓ ここ ↓↓
  if (percentage != null) {
    // ドラッグ操作中

    // Verticalアニメーション
    _vertical.animation.apply(
      _verticalEndTime * percentage.dy,
      coreContext: core,
    );

    // Horizontalアニメーション
    _horizontal.animation.apply(
      _horizontalEndTime * percentage.dx,
      coreContext: core,
    );
  }
}

Scalingアニメーションの再生処理

Scalingアニメーションに関しては、先に画面中心ドラッグ位置の二点間の距離を計算します。

(a, b)と(c, d)二点間の距離は (ca)2+(db)2\sqrt{(c-a)^2+(d-b)^2} で計算

その後、再生終了時間を二点間の距離で割ることで、描画すべき秒数を計算して反映しています。

eye_controller.dart

void apply(RuntimeArtboard core, double elapsedSeconds) {
  // Idleアニメーション
  _idle.animation.apply(_idle.time, coreContext: core);
  _idle.advance(elapsedSeconds);

  if (percentage != null) {
    // ドラッグ操作中

    // Verticalアニメーション
    _vertical.animation.apply(
      _verticalEndTime * percentage.dy,
      coreContext: core,
    );

    // Horizontalアニメーション
    _horizontal.animation.apply(
      _horizontalEndTime * percentage.dx,
      coreContext: core,
    );
    
    // ↓↓ ここ ↓↓
    // Scalingアニメーション
    //
    // 以下の二点間の距離を求める
    // - 画面中心(0.5, 0.5)
    // - 現在のドラッグ位置(percentage.dx, percentage.dy)
    final distanceFromCenter = sqrt(
      pow(percentage.dx - centerPoint, 2) +
          pow(percentage.dy - centerPoint, 2),
    );
    _scaling.animation.apply(
      _scalingEndTime * distanceFromCenter,
      coreContext: core,
    );
  }
}

resetメソッドを追加

ドラッグ終了時に呼び出して、Idle以外のアニメーションをリセットします。

eye_controller.dart
// Idleアニメーションを除いた各アニメーションのリセット
// ドラッグ終了時に呼び出す
void reset() {
  // ドラッグ位置の割合を初期化
  percentage = null;

  // Idleアニメーションを除いた各アニメーションを再生時間の中央値を反映
  _vertical.animation.apply(_verticalMedianTime, coreContext: _artBoard);
  _horizontal.animation.apply(_horizontalMedianTime, coreContext: _artBoard);
  _scaling.animation.apply(_scalingMedianTime, coreContext: _artBoard);
}

8. カスタムコントローラーの使用

TrackingSampleクラスを開いてEyeControllerを使用するように修正すれば完成です🐢

カスタムコントローラーをセットする変数を追加

tracking_sample.dart
// ドラッグ位置トラッキング用のカスタムコントローラー
EyeController _controller;

Riveファイル読み込みメソッドを修正

tracking_sample.dart
// Riveファイルを読み込む
void _loadRiveFile() async {
  final bytes = await rootBundle.load('assets/eye.riv');
  final file = RiveFile();

  if (file.import(bytes)) {
    // ファイルの読み込みに成功
    setState(
      () {
        // Riveファイル内のアートボードを取得
        //
        // アートボードがひとつのみの場合は "mainArtboard" から、
        // 複数存在する場合は "artboardByName(String name)" を使用して名前指定で取得します
        _artBoard = file.mainArtboard;

        // ↓↓ ここを変更します ↓↓
        // アニメーションコントローラーを追加
        // 同時にメンバ変数 _controller へセット
        _artBoard.addController(
          _controller = EyeController(),
        );
      },
    );
  }
}

コントローラー内部で保持しているドラッグ位置の割合を更新するメソッドを追加

tracking_sample.dart
// アニメーション位置の更新
void _updateLineOfSight(Offset percentage) {
  if (_controller == null) {
    return;
  }

  // コントローラー内部で保持しているドラッグ位置の割合を更新する
  _controller.percentage = percentage;
}

GestureDetectorの各リスナーに処理を追加

tracking_sample.dart
// ドラッグ位置取得用Widget
GestureDetector(
  onPanDown: (detail) {
    // ドラッグ位置の割合計算
    final percentage = _calcPercentage(
      detail.localPosition,
      _widgetSize,
    );

    // ↓↓ ここ ↓↓
    // ドラッグ位置の割合を元にアニメーションを更新する
    _updateLineOfSight(percentage);

    print('onPanDown: $percentage');
  },
  onPanUpdate: (detail) {
    // ドラッグ位置の割合計算
    final percentage = _calcPercentage(
      detail.localPosition,
      _widgetSize,
    );

    // ↓↓ ここ ↓↓
    // ドラッグ位置の割合を元にアニメーションを更新する
    _updateLineOfSight(percentage);

    print('onPanUpdate: $percentage');
  },
  onPanEnd: (detail) {
    // ↓↓ ここ ↓↓
    // Idle以外のアニメーションを初期化
    _controller.reset();

    print('onPanEnd');
  },
  child: Container(
    key: _globalKey,
    color: Colors.transparent,
  ),
),

これで完成です🐢

サンプルコード - Github

■ おわりに

最後までお読みいただきありがとうございました!
(筆者の理解不足により、解説がうまくできず申し訳ありません...)

ところどころ怪しいですが、なんとか再現できました🙌
Riveはリッチなアニメーションを簡単に導入できて良い感じですね🐢

それでは、みなさん良いお年を🎍

Twitter: @zmkfrog