ボタンを押している間カウントアップを続ける

Android でユーザーに数値を入力させる時に、直接入力させるんじゃなくて、アップ/ダウンボタンを横に付けて、数値を上下させるインターフェイスはきっとよくあるはず。

現に標準で備わっている DatePicker は(直接入力もできるけど)まあ普通はちょっと前の日や少し先の日を指定することが圧倒的に多いので、上下の矢印をピコピコして日付を指定するわけだ。

DatePicker に代表されるように、こいつらは矢印をずっと押しているとその間どんどん数字が上ったり、下ったりする。何度もタップしなくて済むようになってる。さて、これを自前で実装するにはどうするべ。

サンプルなので非常に単純なアプリケーションにするよ。

1.数値を表示するためのカスタムビューを作成

数値のフォーマットなどのコードが散在するとコードが見辛いので、こられは全部 NumericView なる TextView を継承したクラスに押し込みます

public class NumericView extends TextView {
    private int count;

    public NumericView(Context context) {
        this(context, null, 0);
    }

    public NumericView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public NumericView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        count = 0;
    }

    public void up() {
        count++;
        invalidate();
    }

    public void down() {
        count--;
        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        setText(String.format("%05d", count));
    }
}


特にたいしたことはない。view.up() / view.down() を呼ぶたびに表示されている数値が上下するだけ。

2.NumericView と上下ボタンを配置して、クリックした時の動作を実装

クリックした時に数値が上下するのは小学生でも実装できる

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res/YOUR_PACKAGE_NAME"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="horizontal" >

    <YOUR_PACKAGE_NAME.NumericView
        android:id="@+id/displayView"
        android:layout_width="30dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:textSize="24sp" />

    <Button
        android:id="@+id/upButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="@string/up" />

    <Button
        android:id="@+id/downButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="@string/down" />

</LinearLayout>
public class MainActivity extends Activity {
    private NumericView displayView;
    private Button upButton;
    private Button downButton;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        displayView = (NumericView)findViewById(R.id.displayView);
        upButton    = (Button)findViewById(R.id.upButton);
        downButton  = (Button)findViewById(R.id.downButton);

        upButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                displayView.up();
            }
        });

        downButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                displayView.down();
            }
        });
    }
}

3.押しっぱなしにした時の処理を実装する

押している間中にずっとカウンターをアップさせるには、別のスレッドを起動してそいつに View を更新させる必要がある。
Android で非同期処理を作成するには、とりあえず馬鹿の何とやらで AsyncTask を使っておけばいいでしょう。

AsyncTask は Generics タイプで型引数が 3つもあるんだけど、今回は最も単純なケースでデータのやりとりは不要。
一定時間ごとに指定したビューが更新できればいいので

public class ContinuouslyUp extends AsyncTask<Void, Void, Void> {
    private static final int INTERVAL = 500;   // 0.5秒
    private NumericView view;

    public ContinuouslyUp(NumericView view) {
        this.view = view;
    }

    @Override
    protected Void doInBackground(Void... params) {
        while (true) {
            if ( isCancelled() ) {
                return null;
            }
            try {
                Thread.sleep(INTERVAL);
            } catch (InterruptedException e) {
                return null;
            }
            publishProgress(null);
        }
    }

    @Override
    protected void onProgressUpdate(Void... values) {
        view.up();
    }
}

とは言え、AsyncTask かなり癖がある。注意する点は

  1. AsyncTask のインスタンスは一度しか execute() できない。一旦 cancel して止めたスレッドを再開とかできない。
  2. 今回作成したサンプルのようにバックグラウンドのスレッドを無限ループでずっと動かしている場合は AsyncTask#cancel() で停止する必要があるが、実際には doInBackground() の中で isCancelled() を確認して自力で終了しなければならない。

かな?他にも色々ありそうだけど、よくわからん。

同じように ContinuouslyDown も作る。あまりに同じなので、polymorphic な感じでどうぞ。

4. 作った AsyncTask を利用する

とりあえずアップする方だけ。ダウンも同じさ。

public class MainActivity extends Activity {
    private NumericView displayView;
    private Button upButton;
    private Button downButton;
    private ContinuouslyUp upper;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        displayView = (NumericView)findViewById(R.id.displayView);
        upButton    = (Button)findViewById(R.id.upButton);
        downButton  = (Button)findViewById(R.id.downButton);

        upButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                displayView.up();
            }
        });
        upButton.setOnLongClickListener(new OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                upper = new ContinuouslyUp(displayView); 
                upper.execute();
                return true;
            }
        });
        upButton.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                if ( upper != null ) upper.cancel(true);
                return false;
            }
        });

        downButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                displayView.down();
            }
        });
    }
}

さっきの小学生のコードから比べると、OnLongClickListener と OnTouchListener が追加になっている。
注意点としては

  1. AsyncTask は一度しか execute() できないので、長押しする度に新たなインスタンスを作成する必要がある
  2. 今回実装したタスクは起動するとずっと動いているので、cancel する時の引数に true を指定する
  3. 長押しするまで upper インスタンスは null 、OnTouchListener は単純にクリックした時も発生するので、nullをチェックしておかないとクリックした途端に落ちる
  4. OnTouchListenerで false を返さないと、クリックした時のイベントが発生せず、まったく動作しないアプリになる

できあがり。

サンプルソース
GitHub - koko-u/UpDownSample: Android Application Sample which up and down counter continuously