Androidでフリック

前置き

Androidアプリで、フリックによるページ切り替えや自動スクロールをサポートするための情報をまとめます。「フリック」は、APIの中ではflingと呼ばれています。「投げ飛ばす」みたいな意味でしょうか。ずっとflyの現在進行形だと勘違いしてました(それはflyingでした)。

本文

サンプルアプリ

HOMEアプリなんかに良く見られる、左右のフリックでページ切り替えを行うサンプルを作りました。ページ境界でスクロールが止まります。



こちらは、ページ境界に関係なく、勢いが無くなったら自然に止まるパターンです。



本記事に掲載したソースは要点のみを抜粋した不完全なものです。サンプルアプリの完全なソースコードはGPソフトの書庫からダウンロードできます。

準備

レイアウトは、ImageViewを横に3枚並べてLinearLayoutに入れたものを使っています。

<?xml version="1.0" encoding="utf-8"?>
<jp.dip.gpsoft.example.fling.FlingView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/wall"
    android:orientation="horizontal"
    android:layout_width="960px"
    android:layout_height="match_parent"
    >
  <ImageView
    android:src="@drawable/red"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_weight="1"
    />
  <ImageView
    android:src="@drawable/blue"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_weight="1"
    />
  <ImageView
    android:src="@drawable/green"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_weight="1"
    />
</jp.dip.gpsoft.example.fling.FlingView>

jp.dip.gpsoft.example.fling.FlingViewは、Linearlayoutの派生クラスです。幅を適当に設定してありますが、各ImageViewの幅が画面幅と同じになるように、動的に調整します。

public class FlingView extends LinearLayout {
    private static int PAGE_WIDTH = 320;
    private static int MIN_FLING_MOVE = 40;
    private static final int PAGES_NUM = 3;

    public void resetPosition() {
        // メトリクスを調整。
        WindowManager wm = (WindowManager) getContext().getSystemService(
                Context.WINDOW_SERVICE);
        Display disp = wm.getDefaultDisplay();
        PAGE_WIDTH = disp.getWidth();
        MIN_FLING_MOVE = PAGE_WIDTH / 8;
        setLayoutParams(new FrameLayout.LayoutParams(PAGE_WIDTH * PAGES_NUM,
                getLayoutParams().height));
        // 真ん中のページにスクロール。
        scrollTo(PAGE_WIDTH, 0);
    }

つまり、LinearLayoutの幅は画面の3倍ということになります。このLinearLayoutを横方向にスクロールさせることによりページ切り替えを実現します。

アプリ起動時に、メインのActivityからresetPosition()を呼ぶようにしました。MIN_FLING_MOVEは、フリックとみなす指の移動量の最小値です。

流れ

  1. android.view.GestureDetectorでフリックを検出
  2. android.widget.Scrollerで自動スクロールをシミュレート
  3. android.widget.Scrollerが算出したスクロール位置へviewをスクロール(止まるまで繰り返す)

フリックの検出

素直に考えると、フリックを検出するには、View#onTouchEvent()をオーバーライドして、タッチダウンや移動やタッチアップを監視し、その移動距離や移動速度を計算する必要がありそうですが、そういったことは、android.view.GestureDetectorクラスに任せることができます。

View#onTouchEvent()が受け取るタッチイベントをGestureDetectorへバケツリレーしておけばOKです。

public class FlingView extends LinearLayout {
    private GestureDetector mDetector;

    public FlingView(Context context, AttributeSet attrs) {
        super(context, attrs);

        mDetector = new GestureDetector(getContext(), new GestureListener());//★GestureDetectorオブジェクト生成。
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean b = mDetector.onTouchEvent(event);//★タッチイベントをバケツリレー。
        return b;
    }

GestureDetectorのコンストラクタにGestureListenerオブジェクトを渡していますが、これは、android.view.GestureDetector.SimpleOnGestureListenerの派生クラスです。GestureDetectorがフリックを検出すると、このリスナに通知されます。

private class GestureListener extends SimpleOnGestureListener {

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
        float velocityX, float velocityY) {
        // 横方向のflingのみ対応。
        int dx = (int) (e2.getX() - e1.getX()); // 横方向の移動距離。

        // flingを行うのは、以下の全てが満たされるとき。
        // ・横方向に一定値以上移動した
        // ・縦方向の移動成分より横方向の移動成分の方が強い(速度で比較)
        if ( Math.abs(dx) < MIN_FLING_MOVE
                || Math.abs(velocityX) < Math.abs(velocityY) ) {
            super.onFling(e1, e2, velocityX, velocityY);
            return false;//★興味ないフリックイベントはスルー。
        }

        // スクロール処理。
    }

今回は、横方向のフリックのみに興味があるので、横方向への移動距離が一定量より短い場合や、横よりも縦方向に強くフリックした場合はスルーします。

自動スクロールのシミュレート

フリックが検出できたら、前ページ(あるいは次ページ)まで自動スクロールさせます。このとき、時間経過とともに徐々にスクロールさせていきたいわけです。また、一定速度でスクロールするのではなく、徐々に減速しながら止まるとか、一旦加速してから減速するといったチューニングをしたいこともあるでしょう。android.widget.Scrollerクラスを使うと、自動スクロールをシミュレートし、経過時間に応じたスクロール位置を計算することができます。

public class FlingView extends LinearLayout {
    private Scroller mScroller;

    public FlingView(Context context, AttributeSet attrs) {
        super(context, attrs);

        mScroller = new Scroller(getContext(), new DecelerateInterpolator());
    }

Scrollerのコンストラクタに渡すInterpolatorオブジェクトが、自動スクロールのスピード変化を制御します。本サンプルでは、android.view.animation.DecelerateInterpolatorクラスを使いました。他にもOvershootInterpolatorやBounceInterpolatorなどが用意されています。

自動スクロールをシミュレートさせるには、スクロール開始位置とスクロール量をScrollerへ伝えます。

private class GestureListener extends SimpleOnGestureListener {
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
            float velocityX, float velocityY) {
        // スクロール先を決める。
        int currentX = getScrollX();
        int targetX = 0;
        if ( velocityX > 0 ) { // 右へのfling?
            if ( currentX <= 0 ) return false;
            targetX = currentX / PAGE_WIDTH * PAGE_WIDTH;
        } else { // 左へのfling
            if ( currentX >= PAGE_WIDTH * (PAGES_NUM - 1) ) return false;
            targetX = PAGE_WIDTH * (currentX / PAGE_WIDTH + 1);
        }

        mScroller.startScroll(currentX, 0, targetX - currentX, 0);//★シミュレート開始。

        return true;
    }

viewをスクロール

Scrollerは、あくまでもシミュレートするだけなので、Scroller#startScroll()を呼んだだけでは画面は変化しません。そこで、startScroll()のあとでviewをinvalidate()します。

private class GestureListener extends SimpleOnGestureListener {
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
            float velocityX, float velocityY) {

        ...

        mScroller.startScroll(currentX, 0, targetX - currentX, 0);

        invalidate();//★再描画を促す。
        return true;
    }

これによりviewが再描画されますが、その過程でView#computeScroll()が呼ばれます。ここで、Scrollerのシミュレート結果に基づいてスクロール位置を変えます。

public class FlingView extends LinearLayout {
    @Override
    public void computeScroll() {
        if ( mScroller.computeScrollOffset() ) {//★現時点のスクロール位置を計算。
            scrollTo(mScroller.getCurrX(), 0);//★計算結果に基づいてスクロール。
        }
    }

Scroller#computeScrollOffset()が、現時点でのスクロール位置を計算するメソッドです。このreturn値によって、スクロールが継続中かもう終わったかが分かりますので、継続中なら、新しいスクロール位置へviewをスクロールさせます。

View#scrollTo()を呼ぶと、その内部でinvalidate()が呼ばれ、再び再描画が起きます。その過程でまたView#computeScroll()が呼ばれるので、結局、computeScrollOffset()がfalseを返すまで(つまり、自動スクロールが止まるまで)、再描画の連鎖が続くことになり、スクロールのアニメーションが表示されるわけです。

ページ境界で止めない場合

前述の2個目の動画のように、フリックが弱い場合はページ境界へ到達する前に止まるようにしたいなら、Scroller#startScroll()の代わりにScroller#fling()を使います。

private class GestureListener extends SimpleOnGestureListener {
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
            float velocityX, float velocityY) {

        ...

        mScroller.fling(currentX, 0, -(int) velocityX, 0,
            Math.min(currentX, targetX), Math.max(currentX, targetX), 0, 0);//★

        return true;
    }

このコードでは、最遠でもtargetXでスクロールが止まるようにしていますが、勢いが続く限り遠くまでスクロールさせることも可能です。その場合はtargetXの代わりに、右フリックなら0を、左フリックならPAGE_WIDTH*(PAGES_NUM-1)を指定すれば良いでしょう。

その他のポイント

フリックではなくドラッグした場合は、指の動きに追従してスクロールさせたいところですが、これもGestureDetectorが検出してリスナに通知してくれます。

private class GestureListener extends SimpleOnGestureListener {
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2,
            float distanceX, float distanceY) {
        scrollBy((int) distanceX, 0);//★指の移動距離分スクロール。
        return true;
    }

また、ドラッグによりページの中間までスクロールしてから指を離した場合、強制的に近い方のページ境界へ自動スクロールさせておきましょう。これはGestureDetectorでは判断できないので、View#onTouchEvent()内でタッチアップイベントを処理する必要があります。

public class FlingView extends LinearLayout {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // まずGestureDetectorに食わせる。
        boolean b = mDetector.onTouchEvent(event);

        if ( event.getAction() == MotionEvent.ACTION_UP && !b ) { // UPで、かつflingじゃなかった?
            // 近い方の境界へ自動スクロール。
            int currentX = getScrollX();
            int targetX = 0; // スクロール先。
            if ( currentX % PAGE_WIDTH < PAGE_WIDTH / 2 ) {
                targetX = currentX / PAGE_WIDTH * PAGE_WIDTH;
            } else {
                targetX = (currentX / PAGE_WIDTH + 1) * PAGE_WIDTH;
                targetX = Math.min(targetX, PAGE_WIDTH * (PAGES_NUM - 1));
            }
            mScroller.startScroll(currentX, 0, targetX - currentX, 0);//★強制自動スクロール。

            invalidate();//★再描画を促す。
            return true;
        }
        return b;
    }

mDetector.onTouchEvent()のreturn値をチェックしている点に注意して下さい。mDetector.onTouchEvent()がtrueを返した場合は、フリックによる自動スクロールが始まっているはずなので、邪魔してはいけません。

最後に、自動スクロール中に画面をタッチしたら自動スクロールを停止させましょう。これはGestureDetectorで検出できるので、リスナへ通知されます。

private class GestureListener extends SimpleOnGestureListener {
    @Override
    public boolean onDown(MotionEvent e) {
        if ( !mScroller.isFinished() ) { // 自動スクロール中?
            mScroller.forceFinished(true);//★停止。
            return true;
        }

        return true;
    }

Scroller#forceFinished()により自動スクロールが停止し、Scroller#computeScrollOffset()がfalseを返すようになるので、再描画の連鎖が止まります。このあとユーザが指を離せば、前述の強制自動スクロールが発動するのでページ境界へ吸着します。

課題

個人的には、自動スクロール中に画面をタッチしたら、ドラッグによるスクロール動作へシームレスに遷移して欲しいところですが、残念ながらGestureDetectorはそのように扱ってくれません。やりたければ、View#onTouchEvent()を自力で処理するしかなさそうです。

Last modified:2011/05/13 15:54:11
Keyword(s):
References:[Androidアプリ開発]
This page is frozen.