Cómo administrar eventos táctiles en un ViewGroup

Es necesario tener un cuidado especial al controlar eventos táctiles en un ViewGroup, ya que es común que un ViewGroup tenga elementos secundarios que son objetivo para diferentes eventos táctiles que los del ViewGroup 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 ViewGroup, incluida la superficie de sus elementos secundarios, se llama al método onInterceptTouchEvent(). 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 principal.

El método onInterceptTouchEvent() le da a un elemento principal 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 recibe un objeto ACTION_CANCEL y los eventos que se produzcan a partir de ese momento, se envían al método onTouchEvent() para su control habitual. onInterceptTouchEvent() también puede mostrar false y simplemente espiar a los eventos mientras pasan por la jerarquía de la vista 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 arrastras 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 principal 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 debería llamar a 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(). ViewGroup llama a este método cuando un elemento secundario no quiere que el elemento principal y sus antecesores 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 adecuado o contrólalo en el objeto Window.Callback pertinente (por ejemplo, Activity).

Cómo usar las constantes de ViewConfiguration

En el fragmento anterior, se usa la ViewConfiguration actual para inicializar una variable llamada mTouchSlop. Puedes usar la clase ViewConfiguration para acceder a las distancias, las velocidades y los tiempos comunes 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.

Otros dos métodos de ViewConfiguration que suelen usarse son getScaledMinimumFlingVelocity() y getScaledMaximumFlingVelocity(). Estos métodos muestran la velocidad mínima y máxima (respectivamente) para iniciar un lanzamiento, medido en píxeles por segundo. 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 principal 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. También puedes usar este enfoque para reducir la región táctil del elemento secundario si es necesario.

En el siguiente ejemplo, un objeto ImageButton es la "vista delegada" (es decir, el elemento secundario cuya área táctil extenderá el elemento principal). 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 principal y publica un Runnable en el subproceso de la IU. Esto garantiza que el elemento principal disponga sus elementos secundarios 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 principal.
  • Encuentra la vista secundaria 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 la instancia de un TouchDelegate, pasando el rectángulo de visita expandido y la vista secundaria ImageButton como parámetros.
  • Establece TouchDelegate en la vista principal, de modo que los toques dentro de los límites delegados táctiles se enrutan al elemento secundario.

En su calidad de delegado táctil para la vista secundaria ImageButton, la vista principal recibirá todos los eventos táctiles. Si el evento táctil ocurrió dentro del rectángulo de visita del elemento secundario, el elemento principal 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);
                    }
                }
            });
        }
    }