ViewGroup のタッチイベントの管理

ViewGroup では、ViewGroup 自体とは異なるタッチイベントのターゲットである子を持つことが一般的であるため、ViewGroup でのタッチイベントの処理には特別な注意が必要です。各ビューが目的のタッチイベントを正しく受け取るようにするには、onInterceptTouchEvent() メソッドをオーバーライドします。

以下の関連リソースもご覧ください。

ViewGroup でタッチイベントをインターセプトする

onInterceptTouchEvent() メソッドは、子のサーフェスを含め、ViewGroup のサーフェスでタッチイベントが検出されるたびに呼び出されます。onInterceptTouchEvent() から true が返されると、MotionEvent がインターセプトされます。つまり、子に渡されるのではなく、親の onTouchEvent() メソッドに渡されます。

onInterceptTouchEvent() メソッドを使用すると、子より先に親でタッチイベントを確認できます。onInterceptTouchEvent() から true を返すと、それまでタッチイベントを処理していた子ビューが ACTION_CANCEL を受け取り、以降のイベントは通常の処理のために親の onTouchEvent() メソッドに送られます。 onInterceptTouchEvent()false を返し、ビュー階層を下って通常のターゲットに移動するときにイベントを単に監視することもできます。これにより、独自の onTouchEvent() でイベントが処理されます。

次のスニペットのクラス MyViewGroupViewGroup を拡張しています。 MyViewGroup には複数の子ビューが含まれます。子ビューで横方向に指をドラッグすると、子ビューはタッチイベントを取得できなくなり、MyViewGroup はコンテンツをスクロールしてタッチイベントを処理する必要があります。ただし、子ビューでボタンを押すか、子ビューを縦方向にスクロールする場合は、子が意図したターゲットであるため、親でこのようなタッチイベントをインターセプトしないようにします。このような場合、onInterceptTouchEvent()false を返す必要があり、MyViewGrouponTouchEvent() は呼び出されません。

Kotlin

    class MyViewGroup @JvmOverloads constructor(
            context: Context,
            private val mTouchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
    ) : ViewGroup(context) {

        ...

        override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
            /*
             * This method JUST determines whether we want to intercept the motion.
             * If we return true, onTouchEvent will be called and we do the actual
             * scrolling there.
             */
            return when (ev.actionMasked) {
                // Always handle the case of the touch gesture being complete.
                MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
                    // Release the scroll.
                    mIsScrolling = false
                    false // Do not intercept touch event, let the child handle it
                }
                MotionEvent.ACTION_MOVE -> {
                    if (mIsScrolling) {
                        // We're currently scrolling, so yes, intercept the
                        // touch event!
                        true
                    } else {

                        // If the user has dragged her finger horizontally more than
                        // the touch slop, start the scroll

                        // left as an exercise for the reader
                        val xDiff: Int = calculateDistanceX(ev)

                        // Touch slop should be calculated using ViewConfiguration
                        // constants.
                        if (xDiff > mTouchSlop) {
                            // Start scrolling!
                            mIsScrolling = true
                            true
                        } else {
                            false
                        }
                    }
                }
                ...
                else -> {
                    // In general, we don't want to intercept touch events. They should be
                    // handled by the child view.
                    false
                }
            }
        }

        override fun onTouchEvent(event: MotionEvent): Boolean {
            // Here we actually handle the touch event (e.g. if the action is ACTION_MOVE,
            // scroll this container).
            // This method will only be called if the touch event was intercepted in
            // onInterceptTouchEvent
            ...
        }
    }
    

Java

    public class MyViewGroup extends ViewGroup {

        private int mTouchSlop;

        ...

        ViewConfiguration vc = ViewConfiguration.get(view.getContext());
        mTouchSlop = vc.getScaledTouchSlop();

        ...

        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            /*
             * This method JUST determines whether we want to intercept the motion.
             * If we return true, onTouchEvent will be called and we do the actual
             * scrolling there.
             */

            final int action = MotionEventCompat.getActionMasked(ev);

            // Always handle the case of the touch gesture being complete.
            if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
                // Release the scroll.
                mIsScrolling = false;
                return false; // Do not intercept touch event, let the child handle it
            }

            switch (action) {
                case MotionEvent.ACTION_MOVE: {
                    if (mIsScrolling) {
                        // We're currently scrolling, so yes, intercept the
                        // touch event!
                        return true;
                    }

                    // If the user has dragged her finger horizontally more than
                    // the touch slop, start the scroll

                    // left as an exercise for the reader
                    final int xDiff = calculateDistanceX(ev);

                    // Touch slop should be calculated using ViewConfiguration
                    // constants.
                    if (xDiff > mTouchSlop) {
                        // Start scrolling!
                        mIsScrolling = true;
                        return true;
                    }
                    break;
                }
                ...
            }

            // In general, we don't want to intercept touch events. They should be
            // handled by the child view.
            return false;
        }

        @Override
        public boolean onTouchEvent(MotionEvent ev) {
            // Here we actually handle the touch event (e.g. if the action is ACTION_MOVE,
            // scroll this container).
            // This method will only be called if the touch event was intercepted in
            // onInterceptTouchEvent
            ...
        }
    }
    

なお、ViewGroup には requestDisallowInterceptTouchEvent() メソッドも用意されています。 子が親とその祖先に onInterceptTouchEvent() でタッチイベントをインターセプトさせない場合、ViewGroup はこのメソッドを呼び出します。

ACTION_OUTSIDE イベントを処理する

ViewGroupACTION_OUTSIDEMotionEvent を受け取った場合、デフォルトではイベントが子にディスパッチされません。ACTION_OUTSIDEMotionEvent を処理するには、dispatchTouchEvent(MotionEvent event) をオーバーライドして適切な View にディスパッチするか、関連する Window.Callback(例: Activity)で処理します。

ViewConfiguration 定数を使用する

上記のスニペットでは、現在の ViewConfiguration を使用して mTouchSlop という変数を初期化しています。ViewConfiguration クラスを使用すると、Android システムで使用される一般的な距離、速度、時間にアクセスできます。

「タッチスロップ」とは、ユーザーのタップがスクロールとして解釈されるまでにタップが動ける距離を、ピクセル単位で表したものです。タッチスロップは通常、ユーザーが画面上の要素をタップしているときなど、他のタップ操作を行っているときに誤ってスクロールしないようにするために使用します。

他によく使用される ViewConfiguration メソッドには、getScaledMinimumFlingVelocity()getScaledMaximumFlingVelocity() の 2 つがあります。 これらのメソッドは、ピクセル / 秒で測定した、フリングを開始する最小速度と最大速度を(それぞれ)返します。次に例を示します。

Kotlin

    private val vc: ViewConfiguration = ViewConfiguration.get(context)
    private val mSlop: Int = vc.scaledTouchSlop
    private val mMinFlingVelocity: Int = vc.scaledMinimumFlingVelocity
    private val mMaxFlingVelocity: Int = vc.scaledMaximumFlingVelocity

    ...

    MotionEvent.ACTION_MOVE -> {
        ...
        val deltaX: Float = motionEvent.rawX - mDownX
        if (Math.abs(deltaX) > mSlop) {
            // A swipe occurred, do something
        }
        return false
    }

    ...

    MotionEvent.ACTION_UP -> {
        ...
        if (velocityX in mMinFlingVelocity..mMaxFlingVelocity && velocityY < velocityX) {
            // The criteria have been satisfied, do something
        }
    }
    

Java

    ViewConfiguration vc = ViewConfiguration.get(view.getContext());
    private int mSlop = vc.getScaledTouchSlop();
    private int mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
    private int mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();

    ...

    case MotionEvent.ACTION_MOVE: {
        ...
        float deltaX = motionEvent.getRawX() - mDownX;
        if (Math.abs(deltaX) > mSlop) {
            // A swipe occurred, do something
        }

    ...

    case MotionEvent.ACTION_UP: {
        ...
        } if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity
                && velocityY < velocityX) {
            // The criteria have been satisfied, do something
        }
    }
    

子ビューのタップ可能エリアを拡張する

Android には TouchDelegate クラスが用意されており、親は子の境界を超えて子ビューのタップ可能エリアを拡張できます。これは、子を小さくしなければならないもののタップ領域を大きくする必要がある場合に便利です。必要に応じて、このアプローチを使用して子のタップ領域を縮小することもできます。

次の例で、ImageButton は「デリゲート ビュー」(つまり、親がタップ領域を拡張する子)です。レイアウト ファイルは次のとおりです。

    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
         android:id="@+id/parent_layout"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         tools:context=".MainActivity" >

         <ImageButton android:id="@+id/button"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:background="@null"
              android:src="@drawable/icon" />
    </RelativeLayout>
    

下記のスニペットは次のことを行います。

  • 親ビューを取得し、UI スレッドに Runnable を送信します。これにより、親は getHitRect() メソッドを呼び出す前に子をレイアウトします。getHitRect() メソッドは、親の座標で子のヒット矩形(タップ可能エリア)を取得します。
  • ImageButton の子ビューを検索し、getHitRect() を呼び出して、子のタップ可能エリアの境界を取得します。
  • ImageButton のヒット矩形の境界を拡張します。
  • 拡張されたヒット矩形と ImageButton の子ビューをパラメータとして渡し、TouchDelegate をインスタンス化します。
  • タッチ デリゲートの境界内でのタップが子にルーティングされるように、親ビューに TouchDelegate を設定します。

ImageButton の子ビューのタッチ デリゲートとして機能する親ビューは、タッチイベントをすべて受け取ります。子のヒット矩形でタッチイベントが発生した場合、親は処理のためにタッチイベントを子に渡します。

Kotlin

    public class MainActivity : Activity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)

            // Post in the parent's message queue to make sure the parent
            // lays out its children before you call getHitRect()
            findViewById<View>(R.id.parent_layout).post {
                // The bounds for the delegate view (an ImageButton
                // in this example)
                val delegateArea = Rect()
                val myButton = findViewById<ImageButton>(R.id.button).apply {
                    isEnabled = true
                    setOnClickListener {
                        Toast.makeText(
                                this@MainActivity,
                                "Touch occurred within ImageButton touch region.",
                                Toast.LENGTH_SHORT
                        ).show()
                    }

                    // The hit rectangle for the ImageButton
                    getHitRect(delegateArea)
                }

                // Extend the touch area of the ImageButton beyond its bounds
                // on the right and bottom.
                delegateArea.right += 100
                delegateArea.bottom += 100

                // Sets the TouchDelegate on the parent view, such that touches
                // within the touch delegate bounds are routed to the child.
                (myButton.parent as? View)?.apply {
                    // Instantiate a TouchDelegate.
                    // "delegateArea" is the bounds in local coordinates of
                    // the containing view to be mapped to the delegate view.
                    // "myButton" is the child view that should receive motion
                    // events.
                    touchDelegate = TouchDelegate(delegateArea, myButton)
                }
            }
        }
    }
    

Java

    public class MainActivity extends Activity {

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            // Get the parent view
            View parentView = findViewById(R.id.parent_layout);

            parentView.post(new Runnable() {
                // Post in the parent's message queue to make sure the parent
                // lays out its children before you call getHitRect()
                @Override
                public void run() {
                    // The bounds for the delegate view (an ImageButton
                    // in this example)
                    Rect delegateArea = new Rect();
                    ImageButton myButton = (ImageButton) findViewById(R.id.button);
                    myButton.setEnabled(true);
                    myButton.setOnClickListener(new View.OnClickListener() {
                        @Override
                        public void onClick(View view) {
                            Toast.makeText(MainActivity.this,
                                    "Touch occurred within ImageButton touch region.",
                                    Toast.LENGTH_SHORT).show();
                        }
                    });

                    // The hit rectangle for the ImageButton
                    myButton.getHitRect(delegateArea);

                    // Extend the touch area of the ImageButton beyond its bounds
                    // on the right and bottom.
                    delegateArea.right += 100;
                    delegateArea.bottom += 100;

                    // Instantiate a TouchDelegate.
                    // "delegateArea" is the bounds in local coordinates of
                    // the containing view to be mapped to the delegate view.
                    // "myButton" is the child view that should receive motion
                    // events.
                    TouchDelegate touchDelegate = new TouchDelegate(delegateArea,
                            myButton);

                    // Sets the TouchDelegate on the parent view, such that touches
                    // within the touch delegate bounds are routed to the child.
                    if (View.class.isInstance(myButton.getParent())) {
                        ((View) myButton.getParent()).setTouchDelegate(touchDelegate);
                    }
                }
            });
        }
    }