Flutter x Rive2 でドラッグ位置を追跡してアニメーションさせてみる
■ はじめに
はじめまして、ずみこう(@zmkfrog)と申します🐢
この記事はFlutter #1 Advent Calendar 2020の17日目の記事です🎄
■ 概要
Rive2公式Twitterがツイートしていた、カーソル位置を追跡するアニメーションを再現してみます👀
筆者が再現した最終成果物はこちら✍️
サンプルコード - Github
■ Rive2とは
Rive社(旧2Dimensions)が開発しているブラウザーベースのベクター2Dアニメーション制作ツールです。
2020年8月ごろにRive2
が公開され、現在βテスト中です。
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へ導入
流れ
- ベースとなるWidgetを用意
- ドラッグ位置の取得
- ドラッグ位置の割合(0~1)を求める計算処理を追加する
- riveパッケージを導入
- アニメーションをインポート
- Idleアニメーションを再生してみる
- カスタムコントローラーを作成
- カスタムコントローラーの使用
1. ベースとなるWidgetを用意
アニメーションの再生とドラッグ位置の取得を行うための以下のようなWidgetの用意しておきます。
(ここでは、tracking_sample.dartとしました)
Stack
でアニメーション表示用Widget
とドラッグ位置取得用Widget
を重ねています。
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. ドラッグ位置の取得
今回は、縦/横の両方向を取得したいので、GestureDetector
のonPan〇〇
系を使用します。
先ほどのGestureDetector
に以下の三つのリスナーを追加。
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全体のサイズを取得する処理を追加します。
// 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,
),
),
],
);
}
次に、割合計算を行うメソッドを用意します。
// ドラッグ位置の割合を計算する
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のonPanDown
とonPanUpdate
内で使用します。
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.yamldependencies: 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。
import 'package:rive/rive.dart';
次に、Artboardをセットする変数を用意します。
// 読み込んだRiveファイル内のArtboard
Artboard _artBoard;
さらに、Riveファイルを読み込むメソッドを追加。
// 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
で呼び出すようにします。
void initState() {
super.initState();
// Widgetサイズをセット
_setWidgetSize();
// Riveファイルを読み込む
_loadRiveFile();
}
最後に、Stack
内のアニメーション表示用Widgetの内容を以下のように書き換えれば、
Idle
アニメーションの再生が確認できます👀
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
クラスを用意します。
いくつかオーバーライド可能なメソッドが存在しますが、今回はinit
とapply
に絞ります。
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クラスのインスタンス生成時に一度だけ実行されます。
いくつかメンバ変数を用意
// アートボード
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;
メンバ変数への値のセット、アニメーションの開始処理を追加
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;
}
bool
-
apply
メソッド
アニメーション再生中は、繰り返し呼び出されます🔁
Idleアニメーションの再生処理
void apply(RuntimeArtboard core, double elapsedSeconds) {
// Idleアニメーション
_idle.animation.apply(_idle.time, coreContext: core);
_idle.advance(elapsedSeconds);
}
Vertical/Horizontalアニメーションの再生処理
ドラッグ中(percentageに何らかの値がセットされている状態)のみ、その他のアニメーションを再生する処理を追加
Vertical
とHorizontal
アニメーションに関しては、アニメーションの再生終了時間を
ドラッグ位置の割合で割ることで、描画すべき秒数を計算して反映しています。
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
アニメーションに関しては、先に画面中心
とドラッグ位置
の二点間の距離を計算します。
その後、再生終了時間を二点間の距離で割ることで、描画すべき秒数を計算して反映しています。
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以外のアニメーションをリセットします。
// 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
を使用するように修正すれば完成です🐢
カスタムコントローラーをセットする変数を追加
// ドラッグ位置トラッキング用のカスタムコントローラー
EyeController _controller;
Riveファイル読み込みメソッドを修正
// 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(),
);
},
);
}
}
コントローラー内部で保持しているドラッグ位置の割合を更新するメソッドを追加
// アニメーション位置の更新
void _updateLineOfSight(Offset percentage) {
if (_controller == null) {
return;
}
// コントローラー内部で保持しているドラッグ位置の割合を更新する
_controller.percentage = percentage;
}
GestureDetector
の各リスナーに処理を追加
// ドラッグ位置取得用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,
),
),
これで完成です🐢
■ おわりに
最後までお読みいただきありがとうございました!
(筆者の理解不足により、解説がうまくできず申し訳ありません...)
ところどころ怪しいですが、なんとか再現できました🙌
Riveはリッチなアニメーションを簡単に導入できて良い感じですね🐢
それでは、みなさん良いお年を🎍
Twitter: @zmkfrog
Discussion