「みんなのコミック」は2018年10月31日を持ちまして更新を終了いたしました。

2020/11/09 追記: みんコミAdvent Calendarその他の知見を元に、漫画表示用カスタムビュー「MangaView」を公開しました。


はじめに

この記事は「みんコミ Advent Calendar」の18日目の記事です。

すみません。根雪れい(@neyuki_rei)さんの「おかあさん(10)と僕。」を読んでいたらアドベントカレンダーの更新が遅れてしまいました。

いやはや。まさかのお風呂回に驚きました。布団の柄とかが微妙に凝っていて細部を見ていくと楽しいです。

さて、アドベントカレンダー本編です。

みんコミ」のAndroidアプリ(バージョン1.0.3)をベースに執筆しています。スクリーンショットは極力控える方針ですので、本記事を読む際には、「Google Play Store」からアプリをインストールしておくことをお勧めします。

en_generic_rgb_wo_60(公開終了しています)


「みんコミ」アプリのコミックビューアー。

僕はたいていタブレット(Nexus 9、Xperia Z3 Tablet Compact)で読んでいます(Nexus 5Xは画面が小さいので……)。

しかし、今日(正確には昨日)Nexus 5Xで作品を読んでいて、拡大した状態で勢いよくフリックしてもスクロールが加速しないことに気づきました。

個人的にはぴゅんと表示が跳んで欲しいと思います。

この処理はScrollerを使うと比較的簡単に実装できます。

サンプルプロジェクトをGitHubに置いておきます。

https://github.com/keiji/adventcalendar_2015_mincomi

サンプル「スクロールサンプル(ScrollerActivity)」では、赤い■をフリックすると移動するようにプログラムしています。

device-2015-12-18-004150

まず、GestureDetectorでflingジェスチャーを取得します。

使い方は簡単です。GestureDetectorのインスタンスにonTouchが受け取るMotionEventを渡すだけで、onTouchEventの状況から判定して所定のジェスチャーのコールバック(今回の場合はonFling)が呼びます

<br />public class ScrollerView extends View {
    private static final String TAG = ScrollerView.class.getSimpleName();

    private static final int SIZE = 100;
    private static final Paint PAINT = new Paint();

    static {
        PAINT.setColor(Color.RED);
    }

    private Rect mRect;

    private final GestureDetectorCompat mGestureDetector;

    public ScrollerView(Context context) {
        super(context);

        mGestureDetector = new GestureDetectorCompat(context, mOnGestureListener);

        setFocusable(true);
    }

    GestureDetector.OnGestureListener mOnGestureListener = new GestureDetector.OnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        @Override
        public void onShowPress(MotionEvent e) {

        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return true;
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            return true;
        }

        @Override
        public void onLongPress(MotionEvent e) {

        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            return true;
        }
    };

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.d(TAG, "onTouchEvent " + event.getAction());

        boolean handled = mGestureDetector.onTouchEvent(event);
        if (handled) {
            return true;
        }

        return super.onTouchEvent(event);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (mRect == null) {
            mRect = new Rect(0, 0, SIZE, SIZE);
            int left = (canvas.getWidth() - SIZE) / 2;
            int top = (canvas.getHeight() - SIZE) / 2;
            mRect.offset(left, top);
        }

        canvas.drawRect(mRect, PAINT);
    }
}

サンプルでは、Compatibility Libraryに含まれているGestureDetectorCompatを使用しています。

簡単と言っても、OnGestureListenerの各メソッドの戻り値には気をつけて下さい。Android Studioで自動生成するとデフォルトで各メソッドはfalseを返しますが、そのままではACTION_DOWN以外のイベントを受け取れません(当然、ジェスチャも受け取れません)。

適宜trueを返すようにしましょう。

つぎはScrollerです。

インスタンス化し、onFlingで受け取った各種の値をそのままflingメソッドに渡します。これで、flingが始まってからの時間に応じて自動的にスクロール量を計算してくれます。

なおGoogleは、OverScrollerを使用するように推奨しています。

<br />public class ScrollerView extends View {
    private static final String TAG = ScrollerView.class.getSimpleName();

    private static final int SIZE = 100;
    private static final Paint PAINT = new Paint();

    static {
        PAINT.setColor(Color.RED);
    }

    private Rect mRect;

    private final OverScroller mScroller;
    private final GestureDetectorCompat mGestureDetector;

    public ScrollerView(Context context) {
        super(context);

        mScroller = new OverScroller(context);
        mGestureDetector = new GestureDetectorCompat(context, mOnGestureListener);

        setFocusable(true);
    }

    GestureDetector.OnGestureListener mOnGestureListener = new GestureDetector.OnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        @Override
        public void onShowPress(MotionEvent e) {

        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return true;
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            return true;
        }

        @Override
        public void onLongPress(MotionEvent e) {

        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {

            mScroller.fling(mRect.centerX(), mRect.centerY(),
                    Math.round(velocityX), Math.round(velocityY),
                    0, getWidth() - SIZE, 0, getHeight() - SIZE,
                    SIZE / 2, SIZE / 2);

            ViewCompat.postInvalidateOnAnimation(ScrollerView.this);

            return true;
        }
    };

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.d(TAG, "onTouchEvent " + event.getAction());

        boolean handled = mGestureDetector.onTouchEvent(event);
        if (handled) {
            return true;
        }

        return super.onTouchEvent(event);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (mRect == null) {
            mRect = new Rect(0, 0, SIZE, SIZE);
            int left = (canvas.getWidth() - SIZE) / 2;
            int top = (canvas.getHeight() - SIZE) / 2;
            mRect.offset(left, top);
        }

        canvas.drawRect(mRect, PAINT);
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) {
            mRect.offsetTo(mScroller.getCurrX(), mScroller.getCurrY());

            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
}

flingからViewCompat.postInvalidateOnAnimation(this);を実行するとcomputeScroll()が呼び出されます。

mScroller.computeScrollOffset()でスクロールの状態を計算し、(flingが続いていれば)その結果をRectの位置として設定しています(ここで得られる値は、Scrollerのflingメソッドに与えた値を基にした絶対座標です)。

mScroller.computeScrollOffset()がtrueである限りpostInvalidateOnAnimationcomputeScrollの連鎖が続き、やがてScrollerが完了すると移動も止まります。

なお、OverScrollerはその名の通り、最後の値を設定すればオーバースクロールを有効にして、バウンスさせることができます。例では赤い■のサイズの半分の量だけオーバースクロールさせるように設定しています。

あらためて、実装例をGitHubに置いておきます。

サンプルプロジェクトをGitHubに置いておきます。

https://github.com/keiji/adventcalendar_2015_mincomi

実装参照


「有山圭二」は「みんなのコミック」及び運営の「株式会社イーブックイニシアティブジャパン」とは一切関係がありません。
また、本アドベントカレンダーの内容はあくまで参加者個人の見解です。
「みんなのコミック」の評価を目的とするものではありませんので、ご了承下さい。

それでは明日19日の担当は、この記事を書くに当たってGestureDetectorCompatの存在を知ったけど、Compat系のクラスには嫌な思い出しかないという「有山圭二」さんです。

よろしくお願いします。