Cómo administrar eventos táctiles en un ViewGroup

Es necesario tener un cuidado especial cuando se controlan eventos táctiles en un ViewGroup, ya que es común que un ViewGroup tenga elementos secundarios que sean objetivos para eventos táctiles diferentes a los del ViewGroup en sí mismo. A fin de asegurarte de que cada vista reciba correctamente los eventos táctiles destinados a ella, anula el método onInterceptTouchEvent().

Consulta los siguientes recursos relacionados:

Como interceptar eventos táctiles en un ViewGroup

Cada vez que se detecta un evento táctil en la superficie de un onInterceptTouchEvent(), incluida la superficie de sus elementos secundarios, se llama al método ViewGroup. Si onInterceptTouchEvent() muestra true, se intercepta el MotionEvent, lo que significa que no se pasa al elemento secundario, sino al método onTouchEvent() del elemento superior.

El método onInterceptTouchEvent() le da a un elemento superior la oportunidad de ver cualquier evento táctil antes de que lo hagan sus elementos secundarios. Si muestras true desde onInterceptTouchEvent(), la vista secundaria que antes controlaba eventos táctiles recibirá un objeto ACTION_CANCEL y los eventos que se produzcan a partir de ese momento, se enviarán al método onTouchEvent() del elemento superior para su control habitual. El método onInterceptTouchEvent() también puede mostrar false y simplemente espiar a los eventos mientras pasan por la jerarquía de vistas hacia sus objetivos habituales, que controlarán los eventos con su propio objeto onTouchEvent().

En el siguiente fragmento, la clase MyViewGroup extiende ViewGroup. MyViewGroup contiene varias vistas secundarias. Si arrastraras el dedo horizontalmente sobre una vista secundaria, esta ya no debería recibir eventos táctiles, y MyViewGroup debería controlar los eventos táctiles desplazando su contenido. Sin embargo, si presionas botones en la vista secundaria o desplazas la vista secundaria verticalmente, el elemento superior no debe interceptar esos eventos táctiles, porque el elemento secundario es el objetivo pretendido. En esos casos, onInterceptTouchEvent() debería mostrar false y no se llamará al método onTouchEvent() de MyViewGroup.

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
            ...
        }
    }
    

Ten en cuenta que ViewGroup también proporciona un método requestDisallowInterceptTouchEvent(). La clase ViewGroup llama a este método cuando un elemento secundario no quiere que el elemento superior y sus elementos superiores intercepten eventos táctiles con onInterceptTouchEvent().

Cómo procesar eventos ACTION_OUTSIDE

Si un ViewGroup recibe un MotionEvent con un ACTION_OUTSIDE, no se enviará el evento a sus elementos secundarios de forma predeterminada. Para procesar un MotionEvent con ACTION_OUTSIDE, anula dispatchTouchEvent(MotionEvent event) para enviarlo al objeto View correspondiente o contrólalo en el objeto Window.Callback relevante (por ejemplo, Activity).

Cómo usar las constantes de ViewConfiguration

En el fragmento anterior, se usa la clase ViewConfiguration actual para inicializar una variable llamada mTouchSlop. Puedes usar la clase ViewConfiguration para acceder a distancias, velocidades y tiempos normales que usa el sistema Android.

"Margen táctil" se refiere a la distancia en píxeles en que el toque de un usuario puede oscilar antes de que se interprete el gesto como desplazamiento. Por lo general, se usa el margen táctil para evitar realizar un desplazamiento accidentalmente cuando el usuario está realizando alguna operación táctil, por ejemplo, tocando elementos en pantalla.

Los otros dos métodos ViewConfiguration que se usan con frecuencia son getScaledMinimumFlingVelocity() y getScaledMaximumFlingVelocity(). Estos métodos muestran las velocidades (medidas en píxeles por segundo) mínima y máxima, respectivamente, para iniciar un lanzamiento. Por ejemplo:

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
        }
    }
    

Cómo extender el área táctil de una vista secundaria

Android proporciona la clase TouchDelegate para permitir que un elemento superior extienda el área táctil de una vista secundaria más allá de los límites del elemento secundario. Esto resulta útil cuando el elemento secundario tiene que ser pequeño, pero debe tener una región táctil más grande. Si es necesario, también puedes usar este enfoque para reducir la región táctil del elemento secundario.

En el siguiente ejemplo, un objeto ImageButton es la "vista delegada" (es decir, el elemento secundario cuya área táctil extenderá el elemento superior). Aquí puedes ver el archivo de diseño:

    <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>
    

El siguiente fragmento hace lo siguiente:

  • Obtiene la vista superior y publica una interfaz Runnable en el subproceso de IU. Esto garantiza que el elemento superior disponga sus elementos secundarios antes de llamar al método getHitRect(). El método getHitRect() obtiene el rectángulo de visita del elemento secundario (área táctil) en las coordenadas del elemento superior.
  • Busca la vista secundaria de ImageButton y llama a getHitRect() para obtener los límites del área táctil del elemento secundario.
  • Extiende los límites del rectángulo de visita de ImageButton.
  • Crea una instancia de la clase TouchDelegate, que pasa el rectángulo de visita expandido y la vista secundaria de ImageButton como parámetros.
  • Establece la clase TouchDelegate en la vista superior, de modo que los toques dentro de los límites de delegados táctiles se enrutan al elemento secundario.

En su calidad de delegado táctil para la vista secundaria ImageButton, la vista superior recibirá todos los eventos táctiles. Si el evento táctil ocurrió dentro del rectángulo de visita del elemento secundario, el elemento superior pasará el evento táctil al elemento secundario para que lo controle.

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);
                    }
                }
            });
        }
    }