Flutter で自作マウスカーソルにバネのシミュレーションを加えてみる

公開:2020/10/29
更新:2020/10/29
8 min読了の目安(約7500字TECH技術記事

iPad のマウスカーソルを Flutter で再現する試みをしていて、その一部です。

目的

こんなのを作ります。

今回作るのは、上記のナビゲーションバーが紫色の時の動きです。マウスポインタに対して、少し遅れて自作マウスカーソルを動かします。

次の動画(下のリプライの方の動画です🙏 )のように、バネがマウスポインタに向かってマウスカーソルを引っ張ってるようなイメージです。

サンプルコード

サンプルコードはこちらに置いています。
https://github.com/HeavenOSK/flutter_ipad_mouse_cursor/tree/master/spring_moving_cursor


※この記事では、マウスカーソルの表示のさせ方そのものには触れていませんが、Flutter で自作マウスカーソルを表示する で書いていますので、ぜひご覧ください。


用語

この記事では、次の用語を以下の意味で使っています。

  • マウスポインタ
    • 操作しているマウスの実際の位置
  • マウスカーソル
    • 自作マウスカーソルの位置

実装のイメージ

一つ前の記事では、マウスカーソルの位置がマウスポインタと同期していましたが、今回は以下の点で違いがあります。

  • マウスポインタに対して、マウスカーソルが遅れて動く
  • マウス操作が中断した後も、マウスカーソルがマウスポインタの位置まで動き続ける必要がある

そのため、今回は画面フレームの更新のタイミングで、マウスカーソルの位置の更新を行うことにします。

次のイメージです。

マウスポインタの現在位置を常に追っておき、現在のマウスカーソル位置とズレが出た時に、マウスカーソルの位置を動かします。画面フレームの更新のたびに、何度もマウスカーソル位置を再計算してマウスポインタの位置と重なるまで動かします。

やり方(概要)

  1. 画面フレームの更新イベントを受け取る
  2. マウスポインタとマウスカーソル位置を保存する状態クラスをつくる
  3. 画面フレームの更新時に、マウスカーソルの位置を再計算するロジックを実装する

1. 画面フレームの更新イベントを受け取る

今回は、アプリが起動中ずっと画面フレームの更新イベントを受け取るようにします。

LoopAnimationController

まず、画面フレームの更新イベントを受け取るために停止しない AnimationController を実装します。AnimationController が完了したタイミング(AnimationStatus.completed)で巻き戻し、最初に戻ったタイミング(AnimationStatus.dismissed)で再びアニメーションを発火させます。

class LoopAnimationController extends AnimationController {
  LoopAnimationController(TickerProvider ticker)
      : super.unbounded(
          duration: const Duration(seconds: 1),
          vsync: ticker,
        ) {
    addStatusListener(
      (status) {
        if (status == AnimationStatus.completed) {
          reverse();
        } else if (status == AnimationStatus.dismissed) {
          forward();
        }
      },
    );
    forward();
  }
}

サンプルコード (Github)

画面更新を伝える Widget

停止しない AnimationController が出来たら、あとは StatefulWidget 内で状態管理クラスに更新イベント伝えるコールバックを受け渡します。

class _FrameUpdateProviderState extends State<FrameUpdateProvider>
    with SingleTickerProviderStateMixin {
  LoopAnimationController _loopAnimationController;
  
  void initState() {
    _loopAnimationController = LoopAnimationController(this)
      ..addListener(() {
        controller.updateVirtualPosition();
      });
    super.initState();
  }

  /// 省略
}

サンプルコード (Github)

あとは、上記の StatefulWidget をアプリ起動中 dispose されないような場所( MaterialApp より上層など)に配置すれば、アプリ起動中ずっとフレームの更新を受け取ることができます。


普段、モバイルアプリ開発で使う大体のアニメーションは、Introduction to animations
で紹介されている方法でできるので、今回の方法はまず使わないです。

バグやパフォーマンスを落とす原因になりやすいので、基本は使わないようにして、使う場合は注意が必要だと思います。今回は実験なので何も考えずにやっています。


2. マウスポインタの現在位置を受け取る

Flutter で自作マウスカーソルを表示する でも紹介しましたが、MouseRegion を使います。
onHover コールバックで、現在位置を状態管理クラスに伝えます。

  
  Widget build(BuildContext context) {
    final controller = useProvider(customMouseCursorController);
    return MouseRegion(
      cursor: SystemMouseCursors.none,
      onHover: (event) {
        controller.updatePosition(event.position);
      },
      child: Stack(
        children: [
          child,
          const PositionedCursor(),
        ],
      ),
    );
  }

詳しくは Flutter で自作マウスカーソルを表示する をご覧ください。

3. 画面フレームの更新時に、マウスカーソルの位置を計算するロジックを実装する

画面フレームの更新イベントの受け取りと、マウスポインタの現在位置の受け取りができるようになったので、ここではマウスカーソルを描画する位置の実装をします。

マウスカーソル位置の計算で使用する状態クラス

冒頭でかるく触れたとおり、今回はマウスポインタ位置、マウスカーソル位置の2つの値を扱います。

今回は freezed を使って二つの値を保持できるクラスを用意しました。

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

part 'custom_mouse_cusor_state.freezed.dart';


abstract class CustomMouseCursorState with _$CustomMouseCursorState {
  const factory CustomMouseCursorState({
     Offset actualPosition, // マウスポインタの現在位置
     Offset virtualPosition, // マウスカーソルの現在位置
  }) = _CustomMouseCursorState;
}

サンプルコード (Github)

マウスカーソルの位置を計算

上記で用意したマウスポインタ位置、マウスカーソル位置の2つの値をもとにして、次のマウスカーソル位置を計算します。

以下では、マウスポインタとマウスカーソルの間の距離をもとにして、フックの法則(たぶん)を使って、次に表示するマウスカーソル位置を計算しています。

  double _speedX = 0;
  double _speedY = 0;
  
   void updateVirtualPosition() {
      state = state.copyWith(
        virtualPosition: computeNextVirtualPosition(
          actualPosition: state.actualPosition,
          virtualPosition: state.virtualPosition,
        )
      );
    }
  }
  
  Offset computeNextVirtualPosition({
     Offset actualPosition,
    Offset virtualPosition,
  }) {
    if (virtualPosition == null) {
      return actualPosition;
    }

    const spring = 0.3; // バネ 係数
    const easing = 0.5; // カーソルを停止させるための係数

    final diff = actualPosition - virtualPosition;
    _speedX += diff.dx * spring;
    _speedX *= easing;
    _speedY += diff.dy * spring;
    _speedY *= easing;

    return Offset(
      virtualPosition.dx + _speedX,
      virtualPosition.dy + _speedY,
    );
  }

サンプルコード (Github)

上記のupdateVirtualPositionが画面フレームが更新される度に呼び出されて、マウスカーソル位置が移動していきます。

springの値は大きくなるほどマウスカーソルが速く動き、easing0 < easing < 1 の範囲で指定することでマウスカーソル停止時のブレ方を調整することができます。


この記事では、計算式について詳しく述べていませんが、今回の実装では以下の記事を参考に実装しました。

HTML5のCanvasでつくるダイナミックな表現―CreateJSを使う〜第24回 マウスポインタの動きに弾みがついた曲線を滑らかに描く ( gihyo.jp )

Flutter の記事ではないですが、非常にわかりやすいので実装したい方は参考にしてください。


完成!

これで完成です!

iPad のマウスカーソルを Flutter で再現するチャレンジをやっているので、良かったら Twitter をフォローしてください。

参考