【Android】大量コマ数のパラパラ漫画アニメーションをSpriteを使って1枚にまとめる

# はじめに
**「画像をアニメーションさせたいです！パラパラ漫画なので簡単にできると思います！」**
って言う事はよくあると思います。…が、

**「これが画像です！100枚あります！」**
ってなると話はちょっと別な訳で

```xml:anim.xml
<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="true"
    android:visible="true">

    <item
        android:drawable="@drawable/yoshino000"
        android:duration="42" />
    <item
        android:drawable="@drawable/yoshino001"
        android:duration="42" />
    <item
        android:drawable="@drawable/yoshino002"
        android:duration="42" />
    <item
        android:drawable="@drawable/yoshino003"
        android:duration="42" />
    <item
        android:drawable="@drawable/yoshino004"
        android:duration="42" />
    
　　　　　　　　<!-- はてしない中略 -->

    <item
        android:drawable="@drawable/yoshino108"
        android:duration="42" />

</animation-list>
```
いや長くて見通し悪いし画像ファイル増えすぎてごちゃごちゃするし何より美しくない。
と言う事で今回はSpriteを使って1枚の画像からアニメーションを作っていきたいと思います。

# Sprite?
ゲームやWebなどで、画像を何回も何回も都度ロードするともたつくので、ロード回数はできれば少なくしたい。
そこで、1画面で用いる画像を全て1枚の画像にまとめて、画像の中から一部のみを切り取って使おう、と言う考え方があります。
![action1.png](https://qiita-image-store.s3.amazonaws.com/0/82960/65c794f6-b637-3e38-0a29-19ab44d826c6.png)
*WidgetWorx様よりお借りしました https://www.widgetworx.com/spritelib/

RPGツクールやスーパー正男をやったことある方なら馴染み深いかもしれません。
今回はこの方式を利用して、コマ数が多いアニメーションを1枚のスプライトで表現して行きたいと思います。

# カスタムビューを作る
カスタムビューについては詳しく書いてある記事が色々あるので細かくは割愛しますが、基本的にはコンストラクタをちゃんと書いて、onDrawで描画したいものを指定すればだいたい動きます。
本来はパラメータの指定などはattrを使ってxmlから指定、動的に変更させたいものに関してはDataBindingを使ってViewModelから指定するのが綺麗なのかもしれませんが、今回は簡潔にするためにinitメソッドを作って値を入れて行きます。

```java:SpriteAnimation.java
    /**
     * 初期化
     *
     * @param resId アニメーションさせるsprite画像のリソースID
     * @param sizeX 1列のコマ数
     * @param size 全体のコマ数
     * @param interval 1フレームの間隔
     * @param repeatTimes 繰り返し回数。無限ループの場合は0を指定
     * @param delay 開始まで何ミリ秒遅らせるか
     */
    public void init(
            final int resId,
            final int sizeX,
            final int size,
            final int interval,
            final int repeatTimes,
            final int delay) {
        mSprite = BitmapFactory.decodeResource(getResources(), resId, null);
        mSizeX = sizeX;
        mSize = size - 1;
        mPixWidth = mSprite.getWidth() / sizeX;
        mPixHeight = mSprite.getHeight() / ((size + sizeX - 1) / sizeX);
        mWillRepeat = repeatTimes;

        mCurrentFrame = 0;
        mRepeated = 0;

        mCurrentRect = new Rect();
        mDestRect = new Rect();

        mIsStarted = true;
        final Handler handler = new Handler();
        mRunnable = () -> {
            if (repeatTimes != 0 && mRepeated >= repeatTimes) {
                handler.removeCallbacks(mRunnable);
                mRunnable = null;
            } else {
                invalidate();
                handler.postDelayed(mRunnable, interval);
            }
        };

        handler.postDelayed(mRunnable, delay);
    }
```
BitmapやRectのインスタンス化はonDraw()で毎回やると無駄なので最初に一気にやって行きます。

invalidate()を呼ぶとonDrawが呼ばれるので、描画ごとの処理はここに書いて行きます。

```java:SpriteAnimation.java
    /**
     * {@inheritDoc}
     */
    @Override
    protected void onDraw(final Canvas canvas) {
        super.onDraw(canvas);

        if (!mIsStarted) {
            return;
        }

        if (mWillRepeat != 0 && mRepeated >= mWillRepeat) {
            mCurrentFrame = mSize;
        }

        // 描画する範囲を決める
        final int col = mCurrentFrame % mSizeX;
        final int row = mCurrentFrame / mSizeX;

        mCurrentRect.set(
                col * mPixWidth,
                row * mPixHeight,
                (col + 1) * mPixWidth,
                (row + 1) * mPixHeight);

        mDestRect.set(0, 0, getWidth(), getHeight());

        canvas.drawBitmap(mSprite, mCurrentRect, mDestRect, null);

        mCurrentFrame++;
        if (mCurrentFrame >= mSize) {
            mRepeated++;
            if (mWillRepeat == 0 || mWillRepeat > mRepeated) {
                mCurrentFrame = 0;
            }
        }
    }
```
最初に読み込んだスプライトシートから、そのコマで描画する範囲を切り取って行きます。
![図1.png](https://qiita-image-store.s3.amazonaws.com/0/82960/005fcc40-e695-7d36-8e8d-2ee9f5dc4c61.png)
今のフレームと横幅から切り取る範囲の左上の座標を計算し、そこから画像の幅分取っている感じですね。
onDrawはinvalidate()から呼ばなくても、画面スクロールとかで画像が表示されるたびに呼ばれてしまうので注意が必要です。アニメーション止めたつもりがスクロールしたら変な画像に！？なんてことになりかねないので。
# おわりに
今回はスプライトを使ってアニメーションを作ってみましたが、正直Androidでサポートされているいくつもあるアニメーションクラスを使った方が簡単だし動作も保証されます。
ただ、あまりにも多い画像ファイルを追加しなければいけないのはいやだよって方は、こういうやり方もあるので参考にしてみてはいかがでしょうか。

# 追記
**この実装だと端末によってはアニメーションが奇怪な動きをすることがあります**
正確には画像の一コマのサイズが16pxの倍数でないとコマごとに描画範囲が微妙にずれてしまう問題が起きてしまいます。
これはAndroidのdpがmdpi(160dpi)を起点に倍率で決まっているためみたいです。
xdpiの場合1px=2.0dpなど。ldpi(1px=0.75dp)とかは知らん
アニメーションがガクガク震えてしまう方はそこらへんを考慮して見てください

# 参考にした
[[Android] Canvas クリアーして再描画](https://akira-watson.com/android/canvas-clear.html)
[Sprite Animation in Android](https://examples.javacodegeeks.com/android/games/canvas/sprite-animation-in-android/)
[Android Game Programming 4. Our First Sprite](http://www.edu4java.com/en/androidgame/androidgame4.html)