ビューをインタラクティブにする

UI の描画は、カスタムビューを作成するプロセスの一部にすぎません。描画だけなく、模倣対象である現実世界の動作と同じように、ビューがユーザー入力に応答していく必要があります。オブジェクトは、常に現実のオブジェクトと同じように動作する必要があります。たとえば、画像が突然消えて別の場所から現れるような動作は避けるべきです。現実世界のオブジェクトはそのような動作をしません。突然消えるのではなく、現実世界と同じように、画像は、ある場所から別の場所へと移動する必要があります。

また、ユーザーは、インターフェースの微妙な動作や感覚に敏感で、現実世界とよく似た繊細な動きを好みます。たとえば、ユーザーが UI オブジェクトをフリング(フリック)する際は、最初は動作を妨げるような摩擦を感じつつ、次第に動きに勢いが付いてフリングの範囲を超えていくような躍動感を感じる必要があります。

このレッスンでは、Android フレームワークの機能を使用して、このようなリアルな動作をカスタムビューに追加する方法について説明します。

関連情報については、入力イベントプロパティ アニメーションをご覧ください。

入力ジェスチャーを処理する

他の多くの UI フレームワークと同様に、Android は入力イベントモデルをサポートしています。ユーザー アクションは、コールバックをトリガーするイベントに変換されます。コールバックをオーバーライドすると、アプリがユーザーにどのように応答するかカスタマイズできます。Android システムで最も一般的な入力イベントは「タッチ」で、これは onTouchEvent(android.view.MotionEvent) をトリガーします。このメソッドをオーバーライドすることで、イベントを処理できます。

Kotlin

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return super.onTouchEvent(event)
    }
    

Java

    @Override
       public boolean onTouchEvent(MotionEvent event) {
        return super.onTouchEvent(event);
       }
    

タッチイベントだけでは、あまり有用ではありません。最新のタッチ UI は、タップ、プル、プッシュ、フリング、ズームなどのジェスチャーに関するインタラクションを定義しています。未加工のタッチイベントをジェスチャーに変換するため、Android には GestureDetector が用意されています。

GestureDetector を構築するには、GestureDetector.OnGestureListener を実装するクラスのインスタンスを渡します。ごく少数のジェスチャーだけを処理する場合は、GestureDetector.OnGestureListener インターフェースを実装するのではなく、GestureDetector.SimpleOnGestureListener を拡張します。たとえば、GestureDetector.SimpleOnGestureListener を拡張するクラスを作成して、onDown(MotionEvent) をオーバーライドするコードを以下に示します。

Kotlin

    private val myListener =  object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true
        }
    }

    private val detector: GestureDetector = GestureDetector(context, myListener)
    

Java

    class MyListener extends GestureDetector.SimpleOnGestureListener {
       @Override
       public boolean onDown(MotionEvent e) {
           return true;
       }
    }
    detector = new GestureDetector(PieChart.this.getContext(), new MyListener());
    

GestureDetector.SimpleOnGestureListener を使用するかどうかにかかわらず、true を返す onDown() メソッドを常に実装する必要があります。すべてのジェスチャーは onDown() メッセージで始まるため、この手順は必須です。GestureDetector.SimpleOnGestureListener と同様に、onDown() から false を返した場合、システムは、アプリがジェスチャーの残りの部分を無視するものと想定します。GestureDetector.OnGestureListener の他のメソッドが呼び出されることはありません。onDown() から false を返す必要があるのは、ジェスチャー全体を本当に無視したい場合に限られます。GestureDetector.OnGestureListener を実装して GestureDetector のインスタンスを作成したら、GestureDetector を使用することで、onTouchEvent() で受け取るタッチイベントを解釈できるようになります。

Kotlin

    override fun onTouchEvent(event: MotionEvent): Boolean {
        return detector.onTouchEvent(event).let { result ->
            if (!result) {
                if (event.action == MotionEvent.ACTION_UP) {
                    stopScrolling()
                    true
                } else false
            } else true
        }
    }
    

Java

    @Override
    public boolean onTouchEvent(MotionEvent event) {
       boolean result = detector.onTouchEvent(event);
       if (!result) {
           if (event.getAction() == MotionEvent.ACTION_UP) {
               stopScrolling();
               result = true;
           }
       }
       return result;
    }
    

ジェスチャーの一部として認識されないタッチイベントを onTouchEvent() に渡すと、false が返されます。その場合、独自のカスタム ジェスチャー検出コードを実行できます。

現実味のある動きを作成する

ジェスチャーは、タッチスクリーン デバイスを制御する強力な方法ですが、現実味のある効果が出せないと、直感に反し、覚えづらくなります。その良い例は、ユーザーが画面上で 1 本の指をすばやくスライドさせてから上にあげる「フリング」ジェスチャーです。このジェスチャーは、ユーザーがあたかもフライホイールを押して回転させたかのように、UI がフリングの方向にすばやく動いてスローダウンする反応を見せる場合にのみ、効果があります。

とはいえ、フライホイールの感覚をシミュレートするのは簡単ではありません。フライホイール モデルを的確に動作させるには、多くの物理学と数学の知識が必要です。幸いなことに、Android には、このような動作をシミュレーションするためのヘルパークラスが用意されています。Scroller クラスは、フライホイール スタイルのフリング ジェスチャーを処理する際のベースになります。

フリングを開始するには、フリングの初速度と、x および y の最小値と最大値を指定して、fling() を呼び出します。速度には、GestureDetector によって計算される値を使用できます。

Kotlin

    fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
        scroller.fling(
                currentX,
                currentY,
                (velocityX / SCALE).toInt(),
                (velocityY / SCALE).toInt(),
                minX,
                minY,
                maxX,
                maxY
        )
        postInvalidate()
        return true
    }
    

Java

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
       scroller.fling(currentX, currentY, velocityX / SCALE, velocityY / SCALE, minX, minY, maxX, maxY);
       postInvalidate();
        return true;
    }
    

注: GestureDetector によって計算される速度は物理学的には正確ですが、デベロッパーの多くは、この値を使用したフリング アニメーションは速すぎると感じています。x と y の速度を 4~8 の係数で除算するのが一般的です。

fling() を呼び出すと、フリング ジェスチャーの物理モデルがセットアップされます。次に、一定の間隔で Scroller.computeScrollOffset() を呼び出して、Scroller を更新する必要があります。computeScrollOffset() は、現在の時間を読み取り、物理モデルを使用して、その時点の x と y の位置を計算することにより、Scroller オブジェクトの内部状態を更新します。そのような x と y の値を取得するには、getCurrX()getCurrY() を呼び出します。

ほとんどのビューは、Scroller オブジェクトの x と y の位置を scrollTo() に直接渡します。PieChart の例はそれとは少し異なり、現在のスクロールの y 位置を使用して、グラフの回転角度を設定しています。

Kotlin

    scroller.apply {
        if (!isFinished) {
            computeScrollOffset()
            setPieRotation(currY)
        }
    }
    

Java

    if (!scroller.isFinished()) {
        scroller.computeScrollOffset();
        setPieRotation(scroller.getCurrY());
    }
    

Scroller クラスは、スクロール位置を計算しますが、その位置を自動的にビューに適用することはありません。スクロール アニメーションが滑らかに見えるように、十分な頻度で新しい座標を取得して適用するのは、アプリの役目です。これには、次の 2 つの方法があります。

  • fling() を呼び出した後で postInvalidate() を呼び出すことで、強制的に再描画を行います。この手法の場合、スクロール オフセットが変更するたびに、onDraw() でスクロール オフセットを計算し、postInvalidate() を呼び出す必要があります。
  • ValueAnimator をセットアップして、フリングが継続している間、アニメーション化し、addUpdateListener() を呼び出すことで、アニメーションの更新を処理するリスナーを追加します。

PieChart の例は、2 番目のアプローチを採用しています。この手法はセットアップ方法がやや複雑ですが、アニメーション システムと連携しやすく、不必要なビューの無効化を避けることができます。デメリットは、API レベル 11 より前のバージョンの場合、ValueAnimator を使用できない点です。つまり、バージョン 3.0 より前の Android を搭載しているデバイスでは、この手法は利用できません。

注: 対象 API レベルが低いアプリの場合は、ValueAnimator を使用できます。実行時に現在の API レベルをチェックして、現在のレベルが 11 よりも前の場合は、ビュー アニメーション システムの呼び出しを省略してください。

Kotlin

    private val scroller = Scroller(context, null, true)
    private val scrollAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
        addUpdateListener {
            if (scroller.isFinished) {
                scroller.computeScrollOffset()
                setPieRotation(scroller.currY)
            } else {
                cancel()
                onScrollFinished()
            }
        }
    }
    

Java

    scroller = new Scroller(getContext(), null, true);
    scrollAnimator = ValueAnimator.ofFloat(0,1);
    scrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            if (!scroller.isFinished()) {
                scroller.computeScrollOffset();
                setPieRotation(scroller.getCurrY());
            } else {
                scrollAnimator.cancel();
                onScrollFinished();
            }
        }
    });
    

遷移を滑らかにする

ユーザーは、最新の UI に対して、状態が滑らかに遷移することを期待しています。最新の UI 要素は、現れたり消えたりするのではなく、フェードインしてフェードアウトします。モーションは、突然始まったり終わったりせず、滑らかに始まって終わります。Android 3.0 で導入された Android プロパティ アニメーション フレームワークを使用すると、滑らかな遷移を容易に実現できます。

アニメーション システムを使用する場合、ビューの外観に影響するプロパティ変更があったときに、プロパティを直接変更しないようにします。代わりに、ValueAnimator を使用して変更を加えます。下記の PieChart の例の場合、円グラフ内で現在選択されているスライスを編集すると、グラフ全体が回転して、選択ポインタが、選択スライスの中央に来ます。ValueAnimator は、新しい回転値をすぐに設定するのではなく、数百ミリ秒の時間をかけて回転を変更します。

Kotlin

    autoCenterAnimator = ObjectAnimator.ofInt(this, "PieRotation", 0).apply {
        setIntValues(targetAngle)
        duration = AUTOCENTER_ANIM_DURATION
        start()
    }
    

Java

    autoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
    autoCenterAnimator.setIntValues(targetAngle);
    autoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION);
    autoCenterAnimator.start();
    

変更対象の値が、ベース View プロパティの 1 つである場合、さらに簡単にアニメーションを実行できます。ビューには、複数のプロパティを同時にアニメーション化するように最適化されたビルトイン ViewPropertyAnimator が用意されています。たとえば、次のようになります。

Kotlin

    animate()
        .rotation(targetAngle)
        .duration = ANIM_DURATION
        .start()
    

Java

    animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();