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

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


[12/20追記: ズームサンプルが正常に動作しない不具合を修正しました]

はじめに

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

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

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


「みんコミ」アプリのコミックビューアーで、拡大縮小したときにピンチイン・アウトの「フォーカス」のハンドリングを調整すると、もっと使いやすくなると思います。

コードを見ていないのではっきりとしたことは言えませんが、現状のコミックビューアーは、ピンチアウトで拡大操作をしたときに、1番目の指を起点に拡大縮小をしていく挙動になっているように感じました。

ScaleGestureDetectorを使って取得した値に基づいて画像を拡大縮小するときに、Focusの値も使って画像の位置を調整すると、自然な拡大ができます。

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

https://github.com/keiji/adventcalendar_2015_mincomi

サンプル「ズームサンプル(ZoomActivity)」では、起動すると標準のアイコンを画面に表示します。ピンチイン・アウトの操作で拡大縮小するようにプログラムしています。また、拡大縮小の際、二本の指の中間点(Focus)を基に位置を変えています。

device-2015-12-18-200216

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

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

public class ZoomView extends View {
    private static final String TAG = ZoomView.class.getSimpleName();

    private static final float CIRCLE_RADIUS = 10f;

    private static final Paint PAINT = new Paint();
    private static final Paint CIRCLE_PAINT = new Paint();

    static {
        PAINT.setAntiAlias(true);

        CIRCLE_PAINT.setColor(Color.RED);
    }

    private final Rect mSrcRect;
    private final Bitmap mBitmap;

    private final ScaleGestureDetector mScaleGestureDetector;

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

        mBitmap = BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher);
        mSrcRect = new Rect(0, 0, mBitmap.getWidth(), mBitmap.getHeight());

        mScaleGestureDetector = new ScaleGestureDetector(context, mScaleGestureListener);

        setFocusable(true);
    }

    private float mLastScaleFactor;

    private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
            = new ScaleGestureDetector.OnScaleGestureListener() {

        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            float scale = 1 + detector.getScaleFactor() - mLastScaleFactor;

            Log.d(TAG, "scale = " + scale);
            setScale(scale, detector.getFocusX(), detector.getFocusY());

            return true;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            mLastScaleFactor = detector.getScaleFactor();
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
        }
    };

    private float mFocusX;
    private float mFocusY;

    public void setScale(float scale, float focusX, float focusY) {
        mFocusX = focusX;
        mFocusY = focusY;

        float newWidth = mRect.width() * scale;
        float newHeight = mRect.height() * scale;

        float scrollX = mRect.width() - newWidth;
        float scrollY = mRect.height() - newHeight;

        mRect.right = mRect.left + newWidth;
        mRect.bottom = mRect.top + newHeight;

        scrollX *= (focusX / getWidth());
        scrollY *= (focusY / getHeight());

        mRect.offset(scrollX, scrollY);

        invalidate();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

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

        return super.onTouchEvent(event);
    }

    private RectF mRect;

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

        if (mRect == null) {
            mRect = calculateRectFromBitmap(mBitmap, canvas.getWidth(), canvas.getHeight());
        }

        canvas.drawBitmap(mBitmap, mSrcRect, mRect, PAINT);

        canvas.drawCircle(mFocusX, mFocusY, CIRCLE_RADIUS, CIRCLE_PAINT);

    }

    private static RectF calculateRectFromBitmap(Bitmap bitmap, int viewWidth, int viewHeight) {
        float width = bitmap.getWidth();
        float height = bitmap.getHeight();

        float ratio = Math.min(viewWidth / width, viewHeight / height);
        width *= ratio;
        height *= ratio;

        return new RectF(0, 0, width, height);
    }
}

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

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

解説

通常、onScaleから得た拡大率を普通にcanvas.setScaleやRectのwidthやheightに反映すると、左上の原点(0, 0)を基準に拡大します。

通常は左上が原点

通常は左上が原点

二本指でピンチ操作をするときは、二本の指の中間点を拡大していくという挙動が自然です。この二本の指の中間点の座標を「フォーカス」と呼びます。コードではScaleGestureDetectorのgetFocusX()とgetFocusY()で取得できます。

(GoogleのサンプルサイトではgetCurrentSpanX()やgetCurrentSpanY()から逐次フォーカスを計算していますが、今回はFocusを使う方法を採用しています)

19th.002

このフォーカス(図の黒丸)を中心にすると、ユーザーにとって自然な拡大・縮小に見えます。

19th.004

しかし、フォーカスを基準にただ拡大しても、画像はやはりずれてしまいます。

19th.003

拡大・縮小後の大きさと、以前の大きさの差分を取り、フォーカスの量に応じて位置を調整する必要があります。

例えば、中心をフォーカスした状態で拡大すると、サイズが大きくなった量の半分左側に移動して描画することで、拡大してもフォーカスの位置は(見かけ上)変わりません。

もしフォーカスの位置がずれていた場合、基準となる大きさ(サンプルコードではViewのwidthやheight)に締めるフォーカスの比率を基に、位置を調整する量を計算します。

    public void setScale(float scale, float focusX, float focusY) {
        mFocusX = focusX;
        mFocusY = focusY;

        float newWidth = mRect.width() * scale;
        float newHeight = mRect.height() * scale;

        float scrollX = mRect.width() - newWidth;
        float scrollY = mRect.height() - newHeight;

        mRect.right = mRect.left + newWidth;
        mRect.bottom = mRect.top + newHeight;

        scrollX *= (focusX / getWidth());
        scrollY *= (focusY / getHeight());

        mRect.offset(scrollX, scrollY);

        invalidate();
    }

19th.007

ただし、今回の実装は不完全です。サンプルでは無限に拡大縮小できますし、拡大縮小を繰り返して描画の位置がずれてもまったく補正していません。

現実に実装しようとすれば、タッチによるスクロールやFling操作への対応、画像の割り当てや採用する座標系によって調整が必要になります。

あらためて、実装例をGitHubに置いておきます。参考になれば幸いです。

https://github.com/keiji/adventcalendar_2015_mincomi

実装参照


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

みんコミといえば、僕が普段からお世話になっている根雪れい(@neyuki_rei)さんも連載していますね!

先ほどのエントリのとおり、根雪さんに表紙をお願いした「Android Studio 完全移行ガイド」をコミックマーケット89で頒布します。

c89_farewell_adt

根雪さん。いつも最高の眼鏡っ娘をありがとうございます!


それでは明日20日の担当は、一日に二度、ブログを更新するだなんてこれまであっただろうかと感慨にふけっている「有山圭二」さんです。

よろしくお願いします。