Creación de un Control Deslizante Personalizado en Android (ViewGroup)

Este artículo detalla la creación de un control de panel deslizante completamente personalizado en Android, heredando de ViewGroup. Este tipo de control es útil para interfaces que contienen múltiples secciones navegibles, como listas de noticias o suscripciones.

Estructura del Layout

La estructura principal se basa en un FrameLayout que organiza dos paneles:

  • Panel Izquierdo: Contiene la navegación o el menú.
  • Panel Principal: Muestra el contenido actual.

Se introduce el uso de la etiqueta <include/> para reutilizar layouts XML.

Layout del Panel Izquierdo (ScrollView)

El panel izquierdo se implementa como un ScrollView para permitir el desplazamiento si el contenido excede la altura de la pantalla. Se define un estilo reutilizable para los elementos de texto del menú, facilitando la consistencia visual y la gestión del código.

Estilo para Botones de Menú (styles.xml)

Se define un estilo llamado style_menu_button para los elementos de texto del panel izquierdo. Este estilo configura:

  • Un fondo selector para estados presionados y normales (@drawable/selector_menu_bg).
  • clickable establecido a true.
  • Espaciado de dibujo (drawablePadding).
  • Alineación vertical del texto (gravity).
  • Padding interno.
  • Color del texto.
  • Tamaño del texto.
  • Dimensiones del layout.
  • Un manejador de eventos de clic (android:onClick).

<resources>
   <style name="AppBaseTheme" parent="android:Theme.Light"></style>
   <style name="AppTheme" parent="AppBaseTheme"></style>

   <style name="style_menu_button" parent="android:Widget.Holo.Light.TextView">
       <item name="android:background">@drawable/selector_menu_bg</item>
       <item name="android:clickable">true</item>
       <item name="android:drawablePadding">10dp</item>
       <item name="android:gravity">center_vertical</item>
       <item name="android:padding">8dp</item>
       <item name="android:textColor">#ADCFD6</item>
       <item name="android:textSize">18sp</item>
       <item name="android:layout_width">match_parent</item>
       <item name="android:layout_height">wrap_content</item>
       <item name="android:onClick">onMenuItemClick</item>
   </style>
</resources>
 

Layout del Panel Izquierdo (layout_left_menu.xml)

Este archivo define la estructura visual del menú lateral. Utiliza un ScrollView que contiene un LinearLayout vertical. Cada elemento del menú es un TextView que aplica el estilo style_menu_button y se le asigna un icono (android:drawableLeft) y texto.


<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="240dp"
   android:layout_height="match_parent" >

   <LinearLayout
       android:layout_width="240dp"
       android:layout_height="match_parent"
       android:background="@drawable/menu_background"
       android:orientation="vertical" >

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_news"
           android:text="Noticias" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_subscriptions"
           android:text="Suscripciones" />

       <!-- Otros TextViews para el menú -->
       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_local"
           android:text="Local" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_comments"
           android:text="Comentarios" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_images"
           android:text="Imágenes" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_topics"
           android:text="Temas" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_votes"
           android:text="Votos" />

       <TextView
           style="@style/style_menu_button"
           android:drawableLeft="@drawable/icon_reading"
           android:text="Lectura Agregada" />

   </LinearLayout>

</ScrollView>
 

Definición de Colores (colors.xml)

Se puede definir un color específico para el estado presionado del fondo del botón del menú.


<?xml version="1.0" encoding="utf-8"?>
<resources>
   <color name="menu_button_pressed_bg">#33ADCFD6</color>
</resources>
 

Layout del Panel Principal (layout_main_content.xml)

Este layout representa el contenido principal que se muestra. Incluye una barra superior con un botón de retroceso y un título.


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="#FFFFFF"
   android:orientation="vertical" >

   <LinearLayout
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:background="@drawable/top_bar_background"
       android:orientation="horizontal" >

       <ImageButton
           android:id="@+id/ib_back_button"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:background="@null"
           android:src="@drawable/ic_back" />

       <View
           android:layout_width="1dp"
           android:layout_height="match_parent"
           android:background="@drawable/top_bar_divider" />

       <TextView
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_gravity="center_vertical"
           android:layout_marginLeft="10dp"
           android:text="Mi Aplicación"
           android:textColor="#FFFFFF"
           android:textSize="24sp" />
   </LinearLayout>

   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:gravity="center"
       android:text="Contenido Principal Aquí"
       android:textSize="26sp" />

</LinearLayout>
 

Integración en la Actividad Principal

La actividad principal (MainActivity) utiliza un RelativeLayout que contiene la instancia personalizada de SlideMenu. Dentro de SlideMenu, se incluyen los layouts del panel izquierdo y principal usando la etiqueta <include/>.


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

   <com.example.customviews.SlideMenu
       android:id="@+id/slide_menu_view"
       android:layout_width="match_parent"
       android:layout_height="match_parent" >

       <include layout="@layout/layout_left_menu"/>

       <include layout="@layout/layout_main_content"/>

   </com.example.customviews.SlideMenu>

</RelativeLayout>
 

Jerarquía de Layout y Posicionamiento

Al usar FrameLayout como base, el panel principle podría cubrir el panel izquierdo. La solución es posicionar explícitamente los hijos dentro del ViewGroup personalizado. Esto se logra sobrescribiendo los métodos onMeasure y onLayout.

Método onMeasure

Este método es responsable de medir el tamaño de los hijos del ViewGroup. Debe medir tanto el panel izquierdo como el panel principal según sus especificaciones de layout.

Método onLayout

Aquí se determina la posición final de cada hijo en la pantalla. El panel izquierdo se posiciona fuera de la vista (a la izquierda) y el panel principal se coloca en la posición por defecto (cubriendo el espacio restante).


public class SlideMenu extends ViewGroup {

   // ... constructores y otras inicializaciones ...

   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       // Medir el panel izquierdo (hijo 0)
       View leftMenu = getChildAt(0);
       leftMenu.measure(leftMenu.getLayoutParams().width, heightMeasureSpec);

       // Medir el panel principal (hijo 1)
       View mainContent = getChildAt(1);
       // El contenido principal ocupa todo el espacio disponible
       mainContent.measure(widthMeasureSpec, heightMeasureSpec);

       // El tamaño del SlideMenu es el del contenido principal
       setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
   }

   @Override
   protected void onLayout(boolean changed, int l, int t, int r, int b) {
       // Posicionar el panel izquierdo fuera de la pantalla inicialmente
       View leftMenu = getChildAt(0);
       leftMenu.layout(-leftMenu.getMeasuredWidth(), 0, 0, b); // Posición inicial oculta

       // Posicionar el panel principal
       View mainContent = getChildAt(1);
       mainContent.layout(l, t, r, b); // Ocupa todo el ViewGroup
   }

   // ... resto de la implementación ...
}
 

Manejo de Eventos Táctiles

Se implementa el manejo de eventos táctiles para permitir el deslizamiento y la apertura/cierre del menú.

Interceptación de Táctiles (onInterceptTouchEvent)

Este método decide si el ViewGroup actual debe interceptar un evento táctil, evitando que llegue a sus hijos. Se utiliza para detectar movimientos horizontales significativos y así iniciar el deslizamiento del menú.


private float initialTouchX;
private float downY; // Guardar la coordenada Y inicial del toque
private boolean menuOpened = false; // Estado del menú

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
   switch (ev.getAction()) {
       case MotionEvent.ACTION_DOWN:
           initialTouchX = ev.getX();
           downY = ev.getY(); // Guardar Y para poder comparar en MOVE
           break;
       case MotionEvent.ACTION_MOVE:
           float deltaX = ev.getX() - initialTouchX;
           float deltaY = ev.getY() - downY;

           // Si el movimiento es principalmente horizontal y supera un umbral
           if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
               // Si el menú está cerrado y se desliza hacia la derecha, o
               // si el menú está abierto y se desliza hacia la izquierda
               if ((!menuOpened && deltaX > 0) || (menuOpened && deltaX < 0)) {
                    return true; // Interceptar el evento para el deslizamiento
               }
           }
           break;
       // ACTION_UP y otros casos no suelen necesitar interceptación aquí
   }
   // Dejar que los hijos manejen el evento si no se intercepta
   return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
   // Implementar la lógica de deslizamiento aquí usando scrollTo/scrollBy o Scroller
   // y actualizar el estado menuOpened
   switch (event.getAction()) {
       case MotionEvent.ACTION_DOWN:
            // Inicializar las variables de toque si no se hace en onIntercept
           return true; // Indicar que hemos consumido el evento

       case MotionEvent.ACTION_MOVE:
           float deltaX = event.getX() - initialTouchX;
           // Lógica para desplazar el contenido basado en deltaX
           // Se debe limitar el desplazamiento a los bordes del menú
           scrollContent(deltaX);
           break;

       case MotionEvent.ACTION_UP:
           // Determinar si se debe abrir o cerrar el menú completamente
           // basándose en la posición actual y la velocidad del deslizamiento
           final int menuWidth = getChildAt(0).getMeasuredWidth();
           if (getScrollX() < -menuWidth / 2) { // Más de la mitad del menú visible
               openMenu();
           } else {
               closeMenu();
           }
           break;
   }
   return super.onTouchEvent(event);
}

private void scrollContent(float deltaX) {
   int currentScrollX = getScrollX();
   int menuWidth = getChildAt(0).getMeasuredWidth();

   // Calcular la nueva posición de scroll
   int nextScrollX = currentScrollX + (int)deltaX;

   // Limitar el desplazamiento:
   // El menú izquierdo no debe salirse completamente por la izquierda
   // El contenido principal no debe salirse por la derecha si el menú está abierto
   if (nextScrollX > 0) { // Movimiento hacia la derecha (cerrando menú)
       nextScrollX = 0;
   } else if (nextScrollX < -menuWidth) { // Movimiento hacia la izquierda (abriendo menú)
       nextScrollX = -menuWidth;
   }

   // Aplicar el scroll
   scrollTo(nextScrollX, 0);
}

private void openMenu() {
   final int menuWidth = getChildAt(0).getMeasuredWidth();
   // Usar Scroller para animación suave
   smoothScrollTo(-menuWidth, 0);
   menuOpened = true;
}

private void closeMenu() {
   // Usar Scroller para animación suave
   smoothScrollTo(0, 0);
   menuOpened = false;
}

// Método para iniciar la animación suave
private Scroller scroller;

public SlideMenu(Context context, AttributeSet attrs) {
   super(context, attrs);
   scroller = new Scroller(context);
}

// ... en onTouchEvent(ACTION_UP) se llama a smoothScrollTo ...

private void smoothScrollTo(int targetX, int targetY) {
   int currentScrollX = getScrollX();
   int dx = targetX - currentScrollX;
   // Duración basada en la distancia
   int duration = Math.abs(dx * 3); // Ajustar multiplicador para velocidad
   scroller.startScroll(currentScrollX, 0, dx, 0, duration);
   invalidate(); // Solicitar redibujo para activar computeScroll
}

@Override
public void computeScroll() {
   if (scroller.computeScrollOffset()) {
       int currX = scroller.getCurrX();
       scrollTo(currX, 0);
       invalidate(); // Continuar la animación
   }
}
 

scrollTo y scrollBy

Estos métodos permiten mover el contenido del ViewGroup. scrollTo(x, y) mueve a una posición absoluta, mientras que scrollBy(dx, dy) mueve una cantidad relativa a la posición actual. Se utilizan para implementar el deslizamiento.

Animación Suave con Scroller

Para lograr una transición fluida al abrir o cerrar el menú, se utiliza la clase Scroller. Se inicializa en el constructor del ViewGroup y se usa en onTouchEvent (al levantar el dedo) y en computeScroll para continuar la animación hasta que finalice.

Funcionalidad de Conmutación (Toggle)

Se añade una lógica para alternar entre el estado abierto y cerrado del menú, típicamente activada por un gesto o un botón.

Consideraciones Adicionales

  • Gestión de Gestos Complejos: Para gestos más avanzados, se pueden integrar librerías como GestureDetector.
  • Rendimiento: Optimizar el dibujo y el manejo de eventos es crucial, especialmente en dispositivos de gama baja.
  • Accesibilidad: Asegurar que el control sea usable con lectores de pantalla y otras tecnologías de asistencia.

Etiquetas: Android ViewGroup Custom View layout animation

Publicado el 7-8 08:04