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). clickableestablecido atrue.- 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.