Archivo de la categoría: Swing/X

Desarrollo de aplicaciones de escritorio en Java, utilizando Swing/X

Creando un diálogo de Login con SwingX, Spring y JPA: la Vista

En la entrada anterior vimos la implementación de la capa de Servicio que será el back-end de nuestro diálogo de login. Ha llegado el momento de ver la implementación de la vista y el uso del componente JXLoginPane, para lo cual creo que es oportuno ver y mencionar las ventajas de utilizar este componente.

El componente JXLoginPane

Recordemos que al inicio de esta serie mencionábamos aspectos tales como caché de nombre de usuarios y la posibilidad de guardar contraseñas para mejorar la experiencia de usuario, la posibilidad de elegir entre una lista de servidores de autenticación en el caso que tengamos más de uno, mostrar un mensaje al usuario cuando tiene bloqueadas las mayúsculas, mostrar una animación mientras se lleva a cabo el proceso de autenticación, permitir cancelar la acción de autenticación o mostrar un mensaje de error en caso de fallo de autenticación. Pues bien, el componente JXLoginPane nos ofrece una solución out-of-the-box, configurable de acuerdo a nuestros requerimientos  y con poco trabajo de nuestro lado, siendo el único requisito mandatorio proveer una implementación de la clase abstracta LoginService. En nuestro ejemplo dicha implementación es la clase LoginServiceImp, la cual vimos en la entrada anterior.

Para comenzar podemos pensar en este componente como un panel común y corriente que podemos mostrar utilizando un contenedor de alto nivel (JFrame o JDialog):

LoginService loginService = new LoginServiceImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
...
JDialog dialog = new JDialog();
...
dialog.add(loginPane);
...

De hecho la misma clase JXLoginPane provee subclases de JFrame y JDialog a medida para que sea más sencillo y poder aprovechar algunos métodos, como veremos más adelante:

LoginService loginService = new LoginServiceImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
JXLoginPane.JXLoginDialog dialog = new JXLoginPane.JXLoginDialog((JFrame)null, loginPane);
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
dialog.setVisible(true);

El diálogo se verá como ilustra la siguiente imagen y por defecto muestra un mensaje al usuario indicando el bloqueo de mayúsculas, si fuera este el caso:

plain_login_pane

Para proveer caché de nombres de usuario debemos proveer una implementación de la clase abstracta UserNameStore, la cual implementamos en la capa de Servicio con el nombre UserNameStoreService:

LoginService loginService = new LoginServiceImp();
UserNameStore userNameStore = new UserNameStoreService();
JXLoginPane loginPane = new JXLoginPane(loginService);
loginPane.setUserNameStore(userNameStore);
loginPane.setSaveMode(JXLoginPane.SaveMode.USER_NAME);

Vemos que además de proveer el servicio de almacén de nombre de usuarios debemos setear la propiedad saveMode para que además de hacer uso del servicio guarde el nombre de usuario ingresado en caso que el login sea exitoso. Al proveer este mecanismo, el campo de texto para ingresar el nombre de usuario ahora será un combo box con la funcionalidad de autocompletado:

user_name_store_login_pane

Para agregar el guardado de contraseñas es necesario proveer una implementación de la clase abstracta PasswordStore y setear la propiedad saveMode con el valor PASSWORD o bien BOTH para habilitar el guardado tanto del nombre de usuario como de la contraseña:

LoginService loginService = new LoginServiceImp();
PasswordStore passwordStore = new PasswordStoreImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
...
loginPane.setPasswordStore(passwordStore);
loginPane.setSaveMode(JXLoginPane.SaveMode.PASSWORD); // O JXLoginPane.SaveMode.BOTH

Ahora nuestro panel contará con un check box para que el usuario indique si quiere que la aplicación recuerde su contraseña:

password_store_login_pane

Para permitir la selección de un servidor de autenticación sólo debemos proveer una lista con los nombres de los servidores. Aquel que fue seleccionado se enviará como parámetro en el método authenticate(…) del LoginService:

LoginService loginService = new LoginServiceImp();
List<String> servers = Arrays.asList(new String[]{"localhost", "ubuntu-server"});
JXLoginPane loginPane = new JXLoginPane(loginService);
...
loginPane.setServers(servers);

En nuestro panel veremos un combo box que nos permite seleccionar nuestro servidor de autenticación:

servers_list_login_pane

Como podemos ver, el componente es altamente configurable y sólo debemos proveer lo que necesitemos. Veamos entonces la animación que nos muestra el componente mientras se procesa la autenticación en background, en el cual también se puede apreciar un botón para cancelar el inicio de sesión:

authenticating_login_pane

Finalmente en caso de fallar el proceso de autenticación, el panel mostrará un mensaje de error como el que sigue:

login_failed_message

Implementando la Vista

Ahora veremos la implementación de esta capa en la cual pondremos todas las piezas juntas. Para ello necesitaremos dos clases, una que representará la vista o ventana principal de la aplicación y otra que se encargará de mostrar el diálogo de login. En caso de ser exitoso, se mostrará la ventana principal con los datos de inicio de sesión del usuario.

LoginView.java

package com.javadeepcafe.view;

import com.javadeepcafe.services.SesionService;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.SwingWorker;
import org.jdesktop.swingx.JXLoginPane;
import org.jdesktop.swingx.auth.LoginAdapter;
import org.jdesktop.swingx.auth.LoginEvent;
import org.jdesktop.swingx.auth.LoginService;
import org.jdesktop.swingx.auth.UserNameStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * @author Delcio Amarillo
 */
@Component
public class LoginView {
    
    private JXLoginPane loginPane;
    
    @Autowired
    private LoginService loginService;
    
    @Autowired
    private UserNameStore userNameStore;
    
    @Autowired
    private SesionService sesionService;
    
    @Autowired
    private MainView mainView;
    
    public void createAndShowLoginDialog() {

        loginService.addLoginListener(new LoginAdapter() {
            @Override
            public void loginFailed(LoginEvent evt) {
                if (evt.getCause() != null) {
                    loginPane.setErrorMessage(evt.getCause().getMessage());
                }
            }
        });
        
        loginPane = new JXLoginPane(loginService);
        loginPane.setUserNameStore(userNameStore);
        loginPane.setSaveMode(JXLoginPane.SaveMode.USER_NAME);
        
        final JXLoginPane.JXLoginDialog dialog = new JXLoginPane.JXLoginDialog((JFrame)null, loginPane);
        dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
        dialog.setVisible(true);
        
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                if (dialog.getStatus() == JXLoginPane.Status.SUCCEEDED) {
                    String userName = loginPane.getUserName();
                    sesionService.crearNuevaSesion(userName);
                }
                return null;
            }

            @Override
            protected void done() {
                boolean succeeded = dialog.getStatus() == JXLoginPane.Status.SUCCEEDED;
                dialog.dispose();
                mainView.notifyLoginProcessResult(succeeded);
            }
        };
        worker.execute();
    }
}

MainView.java

package com.javadeepcafe.view;

import com.javadeepcafe.services.SesionService;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.math.BigInteger;
import java.util.Date;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * @author Delcio Amarillo
 */
@Component
public class MainView {
    
    private JFrame frame;
    
    @Autowired
    private LoginView loginView;
    
    @Autowired
    private SesionService sesionService;
    
    public void createAndShowGUI() {
        frame = new JFrame("Bienvenido!");
        frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                MainView.this.disposeGUI();
            }
        });
        loginView.createAndShowLoginDialog();
    }
    
    public void notifyLoginProcessResult(boolean succeeded) {
        if (succeeded) {
            frame.add(getMainPanel());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        } else {
            frame.dispatchEvent(new WindowEvent(frame, WindowEvent.WINDOW_CLOSING));
        }
    }
    
    private JPanel getMainPanel() {
        
        BigInteger idSesion = sesionService.getSesionActiva().getId();
        Date inicioSesion = sesionService.getSesionActiva().getInicioSesion();
        BigInteger idUsuario = sesionService.getSesionActiva().getUsuario().getId();
        String nombreUsuario = sesionService.getSesionActiva().getUsuario().getNombreUsuario();
        Boolean usuarioBloqueado = sesionService.getSesionActiva().getUsuario().getBloqueado();
        
        JTextArea textArea = new JTextArea(15, 60);
        textArea.setEditable(false);
        
        textArea.append(
            String.format("Id sesión        : %s%n", idSesion)
        );
        textArea.append(
            String.format("Inicio sesión    : %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL%n", inicioSesion)
        );
        textArea.append(
            String.format("Id usuario       : %s%n", idUsuario)
        );
        textArea.append(
            String.format("Nombre de usuario: %s%n", nombreUsuario)
        );
        textArea.append(
            String.format("Usuario bloqueado: %s%n", usuarioBloqueado)
        );
        
        JButton cerrarSesionButton = new JButton(new AbstractAction("Finalizar sesión") {
            @Override
            public void actionPerformed(ActionEvent e) {
                MainView.this.disposeGUI();
            }
        });
        
        JLabel label = new JLabel();
        label.setText(
            String.format("Bienvenido de nuevo %s", nombreUsuario)
        );
        label.setFont(
            label.getFont().deriveFont(Font.ITALIC | Font.BOLD, 18)
        );
        
        JPanel controlPanel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
        controlPanel.add(cerrarSesionButton);
        
        JPanel panel = new JPanel(new BorderLayout(8,8));
        panel.setBorder(BorderFactory.createEmptyBorder(10,10,10,10));
        panel.add(label, BorderLayout.PAGE_START);
        panel.add(new JScrollPane(textArea), BorderLayout.CENTER);
        panel.add(controlPanel, BorderLayout.PAGE_END);
        return panel;
    }
    
    private void disposeGUI() {
        frame.setVisible(false);
        
        JLabel label = new JLabel("Cerrando sesión, por favor espere...");
        label.setIcon(UIManager.getIcon("OptionPane.informationIcon"));
        
        JPanel panel = new JPanel(new BorderLayout(8,8));
        panel.setBorder(BorderFactory.createEmptyBorder(10,10,10,10));
        panel.add(label);
        
        final JDialog dialog = new JDialog(frame, "Finalizando aplicación");
        dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
        dialog.setModal(false);
        dialog.add(panel);
        dialog.pack();
        dialog.setLocationRelativeTo(frame);
        dialog.setVisible(true);
        
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                sesionService.finalizarSesionActiva();
                return null;
            }
            
            @Override
            protected void done() {
                dialog.dispose();
                frame.dispose();
            }
        };
        worker.execute();
    }
}

Capturas de pantalla

login_pane

authenticating

main_view

closing_app

Configuración de Spring para soportar nuestra capa de Vista

BeansConfig.java

package com.javadeepcafe.config;

import com.javadeepcafe.view.LoginView;
import com.javadeepcafe.view.MainView;
...
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
...
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author Delcio Amarillo
 */
@Configuration
@EnableTransactionManagement
public class BeansConfig {
    
    ...
    
    @Bean
    public LoginView createLoginView() {
        return new LoginView();
    }
    
    @Bean
    public MainView createMainView() {
        return new MainView();
    }
}

Lanzando nuestra aplicación

Finalmente, veamos la clase main que inicializa nuestra aplicación.

DemoMainClass.java

package com.javadeepcafe.main;

import com.javadeepcafe.config.BeansConfig;
import com.javadeepcafe.view.MainView;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

/**
 * @author Delcio Amarillo
 */
public class DemoMainClass {

     public static void main(String[] args) {
         
         final ApplicationContext context = new AnnotationConfigApplicationContext(BeansConfig.class);
         
         SwingUtilities.invokeLater(new Runnable() {
             @Override
             public void run() {
                 try {
                     UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                 } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                     Logger.getLogger(DemoMainClass.class.getName()).log(Level.SEVERE, null, ex);
                 } finally {
                     MainView mainView = context.getBean(MainView.class);
                     mainView.createAndShowGUI();
                 }
             }
         });
    }
}

Conclusión

En esta serie vimos un ejemplo de implementación de login utilizando JPA, Spring y SwingX. Vimos la división en capas que permite que nuestra aplicación sea flexible y podamos adaptar los componentes según nuestros requerimientos. Si bien es cierto que el componente JXLoginPane pertenece al universo Swing, el diseño de las clases de back-end del mismo (LoginService, UserNameStore, PasswordStore, LoginListener) es un excelente ejemplo de división de responsabilidades orientada a la reutilización.

Código fuente

Pueden encontrar el código del ejemplo DemoLogin en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Creando un diálogo de Login con SwingX, Spring y JPA: Introducción

En esta serie veremos cómo implementar un mecanismo de autenticación de usuarios haciendo foco en la reutilización de componentes tanto de back-end como de front-end. Originalmente el alcance se reducía a presentar el componente JXLoginPane y el framework de autenticación de la extensión SwingX desarrollada por el equipo de Swinglabs, el cual está orientado orientado mayormente al front-end pero que nos deja parte de la implementación de back-end a los desarrolladores. Finalmente decidí ampliar el scope y abarcar aspectos de base de datos, uso de JPA y Spring para manejo de transacciones e inyección de dependencias. A continuación los requisitos para desarrollar el ejemplo, una breve introducción del motivo de esta serie de entradas, y la presentación del framework de autenticación de SwingX.

Requerimientos

Para desarrollar el ejemplo utilizaremos las siguientes herramientas y librerías. Para quienes utilicen Maven, más adelante compartiré el pom.xml con las dependencias correspondientes.

  • MySQL 5.6 Server Community Edition
  • MySQL JDBC Connector 5.1.6
  • Eclipselink JPA 2.5.2
  • SwingX 1.6.4
  • Spring Framework 4.1.6

Introducción

En la medida que nuestras aplicaciones van creciendo en complejidad y la información que manejan se torna sensible, va siendo hora de implementar algún mecanismo de inicio de sesión o login. Este mecanismo puede ser más o menos complejo, dependiendo de la aplicación en sí, pero en líneas generales sigue este patrón:

  • Un cuadro de diálogo que captura el nombre de usuario y contraseña.
  • Un proceso que valida la autenticidad de estas credenciales:
    • Si las credenciales son válidas, la aplicación continúa su ejecución.
    • Si no, se informa al usuario para que vuelva a intentar.

A esto podemos sumarle detalles de implementación más complejos como manejo de roles de usuario, múltiples servidores redundantes para autenticación, bloqueo preventivo de la cuenta de usuario cuando se intenta acceder con una contraseña inválida más de un número límite de veces, encripción o hashing de contraseñas, permitir “caché” o guardado local de usuarios y passwords para ayudar en el proceso de autenticación, etcétera.

Todos estos detalles hacen que un simple diálogo de login se torne complejo en cuestión de minutos. Generalmente hablando este hecho puede lograr que cada aplicación que desarrollemos tenga una implementación propia y que reutilicemos poco o nada de otras aplicaciones en lo que respecta al login. Sin embargo, los detalles de implementación no alteran la esencia y el proceso en sí sigue siendo simple: capturar datos, procesarlos, devolver un resultado.

Partiendo de esta premisa, el equipo de Swinglabs  ofrece un framework de autenticación e inicio de sesión en el proyecto SwingX, diseñado para aislar correctamente las responsabilidades en este proceso y permitirnos ajustarlo a nuestra medida, yreutilizando gran parte de nuestro código. Este framework gira en torno al componente JXLoginPane y consta de las siguientes partes:

  • JXLoginPane: es un componente visual que consiste en un panel con controles básicos para capturar los datos de inicio de sesión por parte del usuario. Este panel incluye un encabezado, un text field para el nombre de usuario, un password field para la contraseña, un combo box para seleccionar el servidor sobre el cual autenticar, y un check box para guardar el nombre de usuario y/o contraseña en caché.
  • LoginService: clase abstracta que debe implementar la lógica de autenticación de las credenciales ingresadas en el componente JXLoginPane. Internamente mantiene una lista con objetos que implementan la interfaz LoginListener, los cuales son notificados mediante LoginEvents durante todo el proceso: login iniciado, cancelado, exitoso o fallido.
  • LoginListener: esta interfaz ofrece un contrato para “escuchar” o atender eventos durante el proceso de login y actuar en consecuencia. Al igual que la mayoría de los listeners en Swing, también tiene su “adapter”: LoginAdapter.
  • PasswordStore: clase abstracta cuya finalidad es la de almacenar las contraseñas ingresadas por los usuarios en una especie de caché, con el objetivo de ayudar en el proceso de login. El verdadero mecanismo y la forma de persistencia de las contraseñas se deja a cargo de las implementaciones concretas. De este modo podemos guardar contraseñas en un archivo plano local, una base de datos auxiliar, un archivo properties o xml, etcétera.
  • UserNameStore: igual que PasswordStore pero para nombres de usuario.

Cada una de estas piezas cumple una función determinada y nos permite dividir responsabilidades en forma coherente y cohesiva. A los efectos de no perdernos en esta maraña de clases, interfaces y responsabilidades divididas, diremos que el 90% de nuestro trabajo consiste en implementar correctamente el siguiente método abstracto:

public abstract class LoginService {
    private String userName, password;
    private boolean isAuthenticated;
    ...
    public abstract boolean authenticate(String name, char[] password, String server) throws Exception;
    ...
}

El método authenticate(..) de la clase LoginService es el encargado de efectuar la lógica del proceso de autenticación, lo más complejo, dejando la interacción del usuario a cargo del componente JXLoginPane. A continuación crearemos nuestra implementación partiendo desde la definición de la base de datos contra la cual autenticar, nuestra implementación de la clase LoginService y la interacción con JXLoginPane.

Continuará…

En la siguiente entrada comenzaremos por la base de datos de nuestro ejemplo, incluyendo algunos aspectos de seguridad, veremos cómo hacer el mapeo objeto-relacional correspondiente y finalmente implementaremos la capa DAO de nuestra aplicación. Más adelante veremos la capas de Servicio y la Vista, el uso de Spring y el componente JXLoginPane.

 

Ejemplo de MVC y diseño por capas: la Vista

En la entrada anterior, vimos una implementación de nuestra capa Controlador utilizando un sencillo esquema de paso de mensajes hacia la Vista y delegación de tareas hacia el Modelo. En esta nueva entrada veremos un ejemplo de implementación de la Vista utilizando Swing.

Antes de comenzar, es bueno recordar que cuando modelamos esta capa vimos que debe ofrecer un contrato para ocuparse de varias tareas: interactuar y obtener información desde el usuario, procesar acciones y ser notificada acerca de cómo ha ido dicho procesamiento, a fin de poder actualizarse en consecuencia.

Quizás parezca un poco complejo al principio, pero de hecho nuestro diseño no es accidental. Así como vimos la utilidad de definir nuestro Modelo siguiendo el patrón DAO, en este caso diseñamos la Vista de acuerdo a una característica muy común en las interfaces de usuario: son basadas en Eventos.

La mayoría de los frameworks para interfaces de usuario (Swing o JavaFX en aplicaciones de escritorio; Java ServerFaces o Vaadin en aplicaciones Web) han sido diseñadas bajo una arquitectura Event-driven o arquitectura conducida por eventos. Por ejemplo, cuando el usuario interactúa con algún componente gráfico (un botón, una lista desplegable, etc.) se genera y dispara un evento con la información básica para determinar qué es lo que ha ocurrido. A partir de este evento, nosotros como desarrolladores debemos decidir cómo procesarlo y debemos mostrarle al usuario un feedback de qué tarea se está llevando a cabo o cómo ha ido dicho procesamiento. Veremos que en este esquema, nuestro diseño encaja perfectamente.

Nuestra vista en java: package example.mvc.view

Ha llegado el momento de escribir nuestra Vista en Java, para lo cual debemos recordar que en nuestro diseño sólo tenemos una interfaz IFabricantesView, la cual define todo el comportamiento necesario para implementar esta capa. Además de su implementación concreta, en este package incluiremos una clase reutilizable destinada a capturar los datos de la entidad Fabricante, ya sea para crear uno nuevo o para modificar los datos de uno existente, y también incluiremos nuestra clase GenericDomainTableModel la cual se desarrolló y ejemplificó en entradas anteriores, y que servirá de TableModel para presentar nuestros objetos Fabricante en un componente JTable.

Package example.mvc.view

GenericDomainTableModel

Lo dicho en la sección anterior, esta clase abstracta nos sirve de TableModel base para poder utilizar junto con el componente JTable. El desarrollo completo de esta clase se encuentra disponible en la serie Creando un TableModel reutilizable en Swing. Para quienes sólo quieran el código fuente, se encuentra al final de la parte II de dicha serie.

DatosFabricante

Esta clase de utilidad está pensada para delegar la responsabilidad de capturar los datos de una entidad Fabricante (sólo nombre y fecha de fundación, recordemos que el identificador único es auto-generado e inmodificable) de un modo transparente, y que nos evite la duplicación de código  de acuerdo con el principio DRY (Don’t Repeat Yourself). Tenemos entonces una clase que genera un panel con un text field para capturar el nombre del fabricante y un spinner para ingresar su fecha de fundación.

package example.mvc.view;

import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.Date;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerDateModel;
import javax.swing.text.DateFormatter;

/**
 * @author Delcio Amarillo
 */
public class DatosFabricante {
    
    private JTextField nombreTextField;
    private JSpinner fechaFundacionSpinner;
    private JPanel panel;
    
    public DatosFabricante(String nombre, Date fechaFundacion) {
        initComponents(nombre, fechaFundacion);
    }
    
    private void initComponents(String nombre, Date fechaFundacion) {
        nombreTextField = new JTextField(nombre, 30);
        
        Date fecha = fechaFundacion != null ? new Date(fechaFundacion.getTime())
                                            : new Date();
        
        fechaFundacionSpinner = new JSpinner(new SpinnerDateModel());
        JSpinner.DateEditor editor = new JSpinner.DateEditor(fechaFundacionSpinner, "dd/MM/yyyy");

        DateFormatter formatter = (DateFormatter)editor.getTextField().getFormatter();
        formatter.setAllowsInvalid(false);
        formatter.setOverwriteMode(true);
        
        fechaFundacionSpinner.setEditor(editor);
        fechaFundacionSpinner.setValue(fecha);

        panel = new JPanel(new GridBagLayout());
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(4, 4, 4, 4);
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.anchor = GridBagConstraints.CENTER;
        gbc.gridx = 0;
        gbc.gridy = 0;

        panel.add(new JLabel("Nombre:"), gbc);

        gbc.gridx = 1;
        gbc.weightx = 1;
        panel.add(nombreTextField, gbc);

        gbc.gridx = 0;
        gbc.weightx = 0;
        gbc.gridy = 1;
        panel.add(new JLabel("Fecha de fundación:"), gbc);

        gbc.gridx = 1;
        gbc.weightx = 1;
        panel.add(fechaFundacionSpinner, gbc);
    }
    
    public Date getFechaFundacion() {
        Date fechaFundacion = (Date)fechaFundacionSpinner.getValue();
        return fechaFundacion != null ? new Date(fechaFundacion.getTime()) 
                                      : fechaFundacion;
    }
    
    public String getNombreFabricante() {
        return nombreTextField.getText();
    }
    
    public JComponent getUIComponent() {
        return panel;
    }
}

IFabricantesView

He aquí el código de nuestra interfaz de la Vista. Nótese que por contrato es necesario mantener una referencia al Controlador al cual se le delegarán las tareas al momento de procesar las acciones. Veremos esto en la implementación de la interfaz.

package example.mvc.view;

import example.mvc.controller.IFabricantesController;
import example.mvc.model.Fabricante;
import java.util.List;

/**
 * @author Delcio Amarillo
 */
public interface IFabricantesView {
    
    public void doAgregarFabricante();
    
    public Fabricante getNuevoFabricante();
    
    public void notificarFabricanteAgregado(Boolean resultado);
    
    public void doModificarFabricante();
    
    public Fabricante getFabricanteAModificar();
    
    public void notificarFabricanteModificado(Boolean resultado);
    
    public void doEliminarFabricantes();
    
    public List<Fabricante> getFabricantesAEliminar();
    
    public void notificarFabricantesEliminados(Boolean resultado);
    
    public void doConsultarFabricantes();
    
    public void setListaFabricantes(List<Fabricante> fabricantes);
    
    public void setFabricantesController(IFabricantesController controller);
}

FabricantesViewSwing

Finalmente, llegó el momento de implementar nuestra interfaz. Como es de imaginar, el código de nuestra clase concreta es bastante extenso (aproximadamente 390 líneas de código), razón por la cual iremos explicando las distintas partes por separado, para finalmente poner todo el código junto en la siguiente sección.

Comencemos presentando los atributos de nuestra clase, y cómo se inicializan los componentes Swing de la misma. Luego iremos presentando la implementación de los métodos de nuestra interfaz agrupándolos por caso de uso.

public class FabricantesViewSwing implements IFabricantesView {
    
    private IFabricantesController controller;
    private Fabricante nuevoFabricante, fabricanteAModificar;
    private final List<Fabricante> fabricantesAEliminar;
    
    private JPanel content;
    private JTable table;
    private Action agregarAction, modificarAction, eliminarAction, consultarAction;
    private GenericDomainTableModel<Fabricante> tableModel;
    
    
    public FabricantesViewSwing(IFabricantesController controller) {
        this.controller = controller;
        this.fabricantesAEliminar = new ArrayList<>();
    }
    
    @Override
    public void setFabricantesController(IFabricantesController controller) {
        this.controller = controller;
    }
    
    private void initActions() {
        agregarAction = new AbstractAction("Agregar nuevo") {
            @Override
            public void actionPerformed(ActionEvent e) {
                DatosFabricante datosFabricante = new DatosFabricante(null, null);
                JComponent component = datosFabricante.getUIComponent();
                
                int opcion = JOptionPane.showConfirmDialog ( 
                                              null
                                            , component
                                            , "Nuevo fabricante"
                                            , JOptionPane.OK_CANCEL_OPTION
                                            , JOptionPane.PLAIN_MESSAGE
                );
                
                if (opcion == JOptionPane.OK_OPTION) {
                    String nombre = datosFabricante.getNombreFabricante();
                    Date fechaFundacion = datosFabricante.getFechaFundacion();
                    nuevoFabricante = new Fabricante(nombre, fechaFundacion);
                    doAgregarFabricante();
                }
            }
        };
        
        modificarAction = new AbstractAction("Modificar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int viewIndex = table.getSelectedRow();
                if (viewIndex > -1) {
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    
                    fabricanteAModificar = tableModel.getDomainObject(modelIndex);
                    String nombre = fabricanteAModificar.getNombre();
                    Date fechaFundacion = fabricanteAModificar.getFechaFundacion();
                    
                    DatosFabricante datosFabricante = new DatosFabricante(nombre, fechaFundacion);
                    JComponent component = datosFabricante.getUIComponent();
                    
                    int opcion = JOptionPane.showConfirmDialog (
                                                null
                                              , component
                                              , "Modificar fabricante"
                                              , JOptionPane.OK_CANCEL_OPTION
                                              , JOptionPane.PLAIN_MESSAGE
                    );

                    if (opcion == JOptionPane.OK_OPTION) {
                        nombre = datosFabricante.getNombreFabricante();
                        fechaFundacion = datosFabricante.getFechaFundacion();
                        fabricanteAModificar.setNombre(nombre);
                        fabricanteAModificar.setFechaFundacion(fechaFundacion);
                        doModificarFabricante();
                    }
                }
            }
        };
        
        eliminarAction = new AbstractAction("Eliminar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] viewIndexes = table.getSelectedRows();
                if (viewIndexes.length > 0) {
                    fabricantesAEliminar.clear();                    
                    for (int viewIndex : viewIndexes) {
                        int modelIndex = table.convertRowIndexToModel(viewIndex);
                        Fabricante fabricante = tableModel.getDomainObject(modelIndex);
                        fabricantesAEliminar.add(fabricante);
                    }                    
                    doEliminarFabricantes();
                }
            }
        };
        
        consultarAction = new AbstractAction("Actualizar tabla") {
            @Override
            public void actionPerformed(ActionEvent e) {
                doConsultarFabricantes();
            }
        };
    }
    
    private void initTableAndModel() {
        List header = Arrays.asList(new Object[] {
            "Id", "Nombre", "Fecha de fundación"
        });
        
        tableModel = new GenericDomainTableModel<Fabricante>(header) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch (columnIndex) {
                    case 0: return BigInteger.class;
                    case 1: return String.class;
                    case 2: return Date.class;
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Fabricante fabricante = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: return fabricante.getId();
                    case 1: return fabricante.getNombre();
                    case 2: return fabricante.getFechaFundacion();
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public boolean isCellEditable(int rowIndex, int columnIndex) {
                return false;
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {}
        };
        
        table = new JTable(tableModel);
        table.setAutoCreateRowSorter(true);
        doConsultarFabricantes();
    }
    
    private void initContent() {
        JScrollPane scrollPane = new JScrollPane(table);
        scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        
        JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
        buttonsPanel.add(new JButton(agregarAction));
        buttonsPanel.add(new JButton(modificarAction));
        buttonsPanel.add(new JButton(eliminarAction));
        buttonsPanel.add(new JButton(consultarAction));
        
        content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
        content.add(scrollPane, BorderLayout.CENTER);
        content.add(buttonsPanel, BorderLayout.PAGE_END);
    }
    
    private void initComponents() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                initActions();
                initTableAndModel();
                initContent();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    
    public JComponent getUIComponent() {
        if (content == null) {
            initComponents();
        }
        return content;
    }
    ...
}

Hasta aquí podemos ver que a nivel de Swing nuestra clase cuenta con un conjunto de acciones, una tabla y su respectivo modelo, y un panel llamado content. La inicialización de cada grupo de componentes se divide en los métodos initActions(), initTableAndModel() e initContent(), respectivamente. El método initComponents() se encarga de llamar a los tres métodos anteriores y se asegura que la creación de los componentes Swing se efectúe en el Event Dispatch Thread (EDT). Finalmente el método getUIComponent() expone el panel content hacia el exterior, de modo que podamos agregarlo a otro contenedor, ya sea otro panel o un contenedor de alto nivel (JFrame o JDialog). Por ejemplo:

IFabricantesView view = new FabricantesViewSwing(controller);
JComponent component = ((FabricantesViewSwing) view).getUIComponent();                
JFrame frame = new JFrame("ABMC Fabricantes");
frame.add(component);
...

Para que tengamos una idea, el panel debe verse más o menos así, dependiendo del Look and Feel (en mi caso Windows): un scroll pane con la tabla en el centro y un panel con los botones en la región inferior.

Panel content

Nótese también que en las acciones de agregar y modificar un Fabricante, hacemos uso de nuestra clase de utilidad DatosFabricante, mapeando los resultados en los atributos nuevoFabricante y fabricanteAModificar, respectivamente. En cambio, en la acción de eliminar los fabricantes seleccionados, tomamos los mismos del table model y los agregamos a la lista fabricantesAEliminar.

Pasemos entonces a la implementación de los métodos para cada caso de uso, enfocándonos en la interacción Usuario-Vista y Vista-Controlador.

Agregar fabricante

SD - Agregar fabricante

public class FabricantesViewSwing implements IFabricantesView {
    private IFabricantesController controller;
    private Fabricante nuevoFabricante;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doAgregarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.agregarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getNuevoFabricante() {
        return nuevoFabricante;
    }

    @Override
    public void notificarFabricanteAgregado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.addRow(nuevoFabricante);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al agregar un nuevo fabricante.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                nuevoFabricante = null;
            }
        };

        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

Aquí podemos ver que en esencia la secuencia de paso de mensajes es sencilla:

  1. El usuario clickea el botón Agregar nuevo.
  2. Mostramos un diálogo para cargar los datos del nuevo fabricante, haciendo uso de nuestra clase de utilidad DatosFabricante. Los datos se mapean en el atributo o place-holder nuevoFabricante.
  3. Llamamos al método doAgregarFabricante(), dentro del cual llamamos al método del Controlador agregarFabricante(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  4. El método getNuevoFabricante() que será llamado desde el Controlador retorna el place-holder nuevoFabricante
  5. Finalmente, si todo ha ido bien, en el método notificarFabricanteAgregado(…) agregamos el nuevoFabricante a nuestro table model, y reinicializamos el place-holder.

Lo único fuera de lo común es el uso de SwingWorker y SwingUtilities.invokeLater(…). Ambos son mecanismos para manejar la concurrencia en Swing. Por ejemplo, utilizamos SwingWorker para ejecutar la llamada al controlador en  un nuevo hilo. De este modo, nuestra interfaz gráfica no quedará “congelada” esperando una respuesta. Finalmente, al agregar el nuevo fabricante al table model, debemos asegurarnos que la llamada se efectúe en el Event Dispatch Thread, por lo cual utilizamos SwingUtilities.invokeLater(…).

Modificar fabricante

SD - Modificar fabricante

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private Fabricante fabricanteAModificar;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doModificarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.modificarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getFabricanteAModificar() {
        return fabricanteAModificar;
    }

    @Override
    public void notificarFabricanteModificado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.notifyDomainObjectUpdated(fabricanteAModificar);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al modificar el fabricante seleccionado.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                fabricanteAModificar = null;
            }
        };
            
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

En este caso de uso, el escenario es muy similar al anterior:

  1. El usuario clickea el botón Modificar.
  2. Tomamos el fabricante seleccionado del table model y se lo asignamos al atributo fabricanteAModificar.
  3. Mostramos un diálogo para modificar los datos del fabricante, haciendo uso de nuestra clase de utilidad DatosFabricante. Los datos se mapean en el atributo o place-holder fabricanteAModificar.
  4. Llamamos al método doModificarFabricante(), dentro del cual llamamos al método del Controlador modificarFabricante(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  5. El método getFabricanteAModificar() que será llamado desde el Controlador retorna el place-holder fabricanteAModificar.
  6. Finalmente, si todo ha ido bien, en el método notificarFabricanteModificado(…) notificamos al table model que el fabricante fabricanteAModificar ha cambiado su estado interno, y posteriormente reinicializamos el place-holder.

Eliminar fabricantes

SD - Eliminar fabricantes

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private final List<Fabricante> fabricantesAEliminar;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doEliminarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.eliminarFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public List<Fabricante> getFabricantesAEliminar() {
        return fabricantesAEliminar;
    }

    @Override
    public void notificarFabricantesEliminados(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    
                    for (Fabricante fabricante : fabricantesAEliminar) {
                        tableModel.deleteRow(fabricante);
                    }
                    
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al eliminar los fabricantes seleccionados.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }

                fabricantesAEliminar.clear();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

 En este caso de uso, el el paso de mensajes se da de la siguiente manera:

  1. El usuario clickea el botón Eliminar.
  2. Tomamos los fabricantes seleccionados del table model y los agregamos en la lista  fabricantesAEliminar.
  3. Llamamos al método doEliminarFabricantes(), dentro del cual llamamos al método del Controlador eliminarFabricantes(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  4. El método getFabricantesAEliminar() que será llamado desde el Controlador retorna la lista fabricantesAEliminar.
  5. Finalmente, si todo ha ido bien, en el método notificarFabricantesEliminados(…) notificamos al table model que cada uno de los elementos en la lista fabricantesAEliminar debe ser removido, para luego vaciar la lista.

Nótese que en este caso no solicitamos la confirmación del usuario antes de eliminar los fabricantes seleccionados. Si se requiere una confirmación adicional, podemos agregarla fácilmente entre el punto 1 y 2 para continuar con el flujo de ejecución si el usuario accede a la eliminación.

Consultar fabricantes

SD - Consultar fabricantes

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doConsultarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void,Void> () {
            @Override
            protected Void doInBackground() throws Exception {
                controller.getListaFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public void setListaFabricantes(final List<Fabricante> fabricantes) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                tableModel.clearTableModelData();
                tableModel.addRows(fabricantes);
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
}

Finalmente, la secuencia en el paso de mensajes en este caso de uso es así:

  1. El usuario presiona el botón Actualizar tabla.
  2. Llamamos al método doConsultarFabricantes(), dentro del cual llamamos al método del Controlador getListaFabricantes(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  3. Desde el controlador llamamos al método de la Vista setListaFabricantes(…).
  4. Si todo ha ido bien, reiniciamos nuestro table model y agregamos la lista de fabricantes obtenida desde el controlador.

En este punto puede resultar extraño el paso de mensajes en los puntos 2 y 3. Es decir, ¿por qué la Vista pide la una lista al Controlador, se suscribe a sí misma pasándose como parámetro, y luego el Controlador setea la lista en la Vista? ¿Por qué no simplemente obtenemos una lista desde el Controlador y actualizamos el table model directamente? Pues bien, esta decisión de diseño obedece a un punto clave que fue mencionado al principio de esta entrada:

Event driven UI: dado que el Controlador no sabe cómo se implementa la Vista, ni esta última conoce cuánto tiempo puede llevarle al Controlador obtener una lista con las entidades fabricante, optamos por un esquema en el cual la Vista solicita la lista al controlador y se pasa a sí misma como parámetro a la espera que el Controlador le responda. De este modo, cuando el Controlador tenga por fin la lista con los fabricantes, se lo comunica a la Vista para que se actualice en consecuencia.

FabricantesViewSwing (código completo)

A continuación, el código completo de nuestra clase.

package example.mvc.view;

import example.mvc.controller.IFabricantesController;
import example.mvc.model.Fabricante;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;

/**
 * @author Delcio Amarillo
 */
public class FabricantesViewSwing implements IFabricantesView {
    
    private IFabricantesController controller;
    private Fabricante nuevoFabricante, fabricanteAModificar;
    private final List<Fabricante> fabricantesAEliminar;
    
    private JPanel content;
    private JTable table;
    private Action agregarAction, modificarAction, eliminarAction, consultarAction;
    private GenericDomainTableModel<Fabricante> tableModel;
    
    public FabricantesViewSwing(IFabricantesController controller) {
        this.controller = controller;
        this.fabricantesAEliminar = new ArrayList<>();
    }
    
    @Override
    public void setFabricantesController(IFabricantesController controller) {
        this.controller = controller;
    }
    
    private void initActions() {
        agregarAction = new AbstractAction("Agregar nuevo") {
            @Override
            public void actionPerformed(ActionEvent e) {
                DatosFabricante datosFabricante = new DatosFabricante(null, null);
                JComponent component = datosFabricante.getUIComponent();
                
                int opcion = JOptionPane.showConfirmDialog ( 
                                              null
                                            , component
                                            , "Nuevo fabricante"
                                            , JOptionPane.OK_CANCEL_OPTION
                                            , JOptionPane.PLAIN_MESSAGE
                );
                
                if (opcion == JOptionPane.OK_OPTION) {
                    String nombre = datosFabricante.getNombreFabricante();
                    Date fechaFundacion = datosFabricante.getFechaFundacion();
                    nuevoFabricante = new Fabricante(nombre, fechaFundacion);
                    doAgregarFabricante();
                }
            }
        };
        
        modificarAction = new AbstractAction("Modificar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int viewIndex = table.getSelectedRow();
                if (viewIndex > -1) {
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    
                    fabricanteAModificar = tableModel.getDomainObject(modelIndex);
                    String nombre = fabricanteAModificar.getNombre();
                    Date fechaFundacion = fabricanteAModificar.getFechaFundacion();
                    
                    DatosFabricante datosFabricante = new DatosFabricante(nombre, fechaFundacion);
                    JComponent component = datosFabricante.getUIComponent();
                    
                    int opcion = JOptionPane.showConfirmDialog (
                                                null
                                              , component
                                              , "Modificar fabricante"
                                              , JOptionPane.OK_CANCEL_OPTION
                                              , JOptionPane.PLAIN_MESSAGE
                    );

                    if (opcion == JOptionPane.OK_OPTION) {
                        nombre = datosFabricante.getNombreFabricante();
                        fechaFundacion = datosFabricante.getFechaFundacion();
                        fabricanteAModificar.setNombre(nombre);
                        fabricanteAModificar.setFechaFundacion(fechaFundacion);
                        doModificarFabricante();
                    }
                }
            }
        };
        
        eliminarAction = new AbstractAction("Eliminar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] viewIndexes = table.getSelectedRows();
                if (viewIndexes.length > 0) {
                    fabricantesAEliminar.clear();
                    for (int viewIndex : viewIndexes) {
                        int modelIndex = table.convertRowIndexToModel(viewIndex);
                        Fabricante fabricante = tableModel.getDomainObject(modelIndex);
                        fabricantesAEliminar.add(fabricante);
                    }                    
                    doEliminarFabricantes();
                }
            }
        };
        
        consultarAction = new AbstractAction("Actualizar tabla") {
            @Override
            public void actionPerformed(ActionEvent e) {
                doConsultarFabricantes();
            }
        };
    }
    
    private void initTableAndModel() {
        List header = Arrays.asList(new Object[] {
            "Id", "Nombre", "Fecha de fundación"
        });
        
        tableModel = new GenericDomainTableModel<Fabricante>(header) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch (columnIndex) {
                    case 0: return BigInteger.class;
                    case 1: return String.class;
                    case 2: return Date.class;
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Fabricante fabricante = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: return fabricante.getId();
                    case 1: return fabricante.getNombre();
                    case 2: return fabricante.getFechaFundacion();
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public boolean isCellEditable(int rowIndex, int columnIndex) {
                return false;
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {}
        };
        
        table = new JTable(tableModel);
        table.setAutoCreateRowSorter(true);
        doConsultarFabricantes();
    }
    
    private void initContent() {
        JScrollPane scrollPane = new JScrollPane(table);
        scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        
        JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
        buttonsPanel.add(new JButton(agregarAction));
        buttonsPanel.add(new JButton(modificarAction));
        buttonsPanel.add(new JButton(eliminarAction));
        buttonsPanel.add(new JButton(consultarAction));
        
        content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
        content.add(scrollPane, BorderLayout.CENTER);
        content.add(buttonsPanel, BorderLayout.PAGE_END);
    }
    
    private void initComponents() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                initActions();
                initTableAndModel();
                initContent();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    
    public JComponent getUIComponent() {
        if (content == null) {
            initComponents();
        }
        return content;
    }

    @Override
    public void doAgregarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.agregarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getNuevoFabricante() {
        return nuevoFabricante;
    }

    @Override
    public void notificarFabricanteAgregado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.addRow(nuevoFabricante);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al agregar un nuevo fabricante.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                nuevoFabricante = null;
            }
        };

        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doModificarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.modificarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getFabricanteAModificar() {
        return fabricanteAModificar;
    }

    @Override
    public void notificarFabricanteModificado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.notifyDomainObjectUpdated(fabricanteAModificar);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al modificar el fabricante seleccionado.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                fabricanteAModificar = null;
            }
        };
            
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doEliminarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.eliminarFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public List<Fabricante> getFabricantesAEliminar() {
        return fabricantesAEliminar;
    }

    @Override
    public void notificarFabricantesEliminados(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    
                    for (Fabricante fabricante : fabricantesAEliminar) {
                        tableModel.deleteRow(fabricante);
                    }
                    
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al eliminar los fabricantes seleccionados.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }

                fabricantesAEliminar.clear();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doConsultarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void,Void> () {
            @Override
            protected Void doInBackground() throws Exception {
                controller.getListaFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public void setListaFabricantes(final List<Fabricante> fabricantes) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                tableModel.clearTableModelData();
                tableModel.addRows(fabricantes);
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
}

Uniendo las piezas…

Ahora que tenemos una implementación para cada una de las capas, ha llegado el momento de ver cómo encajan las piezas del rompecabezas y cómo podemos lanzar nuestra aplicación. Para ello he creado un package llamado example.mvc.main el cual contiene una sola clase con un método main llamada ModelViewController, la cual se encargará de ejecutar nuestra aplicación.

ModelViewController

package example.mvc.main;

import example.mvc.controller.FabricantesControllerImp;
import example.mvc.controller.IFabricantesController;
import example.mvc.model.EntityManagerProvider;
import example.mvc.model.FabricantesModelJpa;
import example.mvc.model.IFabricantesModel;
import example.mvc.view.FabricantesViewSwing;
import example.mvc.view.IFabricantesView;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.EntityManager;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

/**
 * @author Delcio Amarillo.
 */
public class ModelViewController {
    
    public static void main(String[] args) {
        
        EntityManager entityManager = EntityManagerProvider.getProvider().getEntityManager();
        IFabricantesModel fabricantesModel = new FabricantesModelJpa(entityManager);
        final IFabricantesController controller = new FabricantesControllerImp(fabricantesModel);
        
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                    Logger.getLogger(ModelViewController.class.getName()).log(Level.SEVERE, null, ex);
                }
                
                IFabricantesView view = new FabricantesViewSwing(controller);
                JComponent component = ((FabricantesViewSwing)view).getUIComponent();
                
                JFrame frame = new JFrame("ABMC Fabricantes");
                frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
                
                frame.addWindowListener(new WindowAdapter() {
                    @Override
                    public void windowClosing(final WindowEvent e) {
                        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
                            @Override
                            protected Void doInBackground() throws Exception {
                                EntityManagerProvider.getProvider().closeResources();
                                return null;
                            }

                            @Override
                            protected void done() {
                                e.getWindow().dispose();
                            }
                        };
                        worker.execute();
                    }
                });
                
                frame.add(component);
                frame.pack();
                frame.setLocationByPlatform(true);
                frame.setVisible(true);
            }
        });
    }
}

Capturas de pantalla

A continuación algunas capturas de pantalla de la aplicación ejecutándose.

Nuevo fabricanteModificar fabricanteFabricantes seleccionadosFabricantes eliminados

Conclusión

A lo largo de las últimas 4 entradas hemos visto un ejemplo de diseño por capas siguiendo el patrón MVC, además de un ejemplo de implementación para cada capa. Hemos visto además la ventaja de utilizar distintos patrones de diseño para cada capa, por ejemplo el patrón DAO y Singleton en la capa de Modelo, y cómo sacar provecho del concepto Event-driven UI de las interfaces de usuario para diseñar la Vista de nuestra aplicación.

Finalmente, como último comentario, podríamos decir que el nivel de desagregación de cada capa puede variar. Si tomamos la Vista como ejemplo, la misma podría desagregarse en 4 interfaces, una para cada caso de uso planteado. De este modo podríamos implementar cada caso de uso de manera diferente, a nivel de Vista. En todo caso, el análisis es clave para determinar si vale la pena. En líneas generales, como base para la toma de decisiones siempre debemos preguntarnos qué tan sencillo es cambiar de implementación de alguna de las capas, qué tan profundo es el impacto para el resto de las capas y qué nivel de acoplamiento es tolerable.

Código fuente

Pueden encontrar el código del ejemplo DemoModelViewController en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Mejorando la presentación de nuestras tablas con SwingX y SwingBits

En el tema anterior, creamos una implementación reutilizable de la interfaz TableModel para poder trabajar de manera más sencilla con nuestras tablas en Swing. En esta nueva entrega nos enfocaremos en mejorar la experiencia de usuario al trabajar con tablas.

Si bien la clase JTable nos ofrece el componente visual necesario para mostrar, agregar y editar información, lo cierto es que nuestra experiencia se reduce a poder reordenar filas y columnas, seleccionar filas y editar celdas. Dada la evolución de las interfaces de usuario que nos permiten hacer cada vez más cosas, el componente JTable resulta, si se quiere, un tanto “primitivo” en lo que respecta a interacción con el usuario.

Qué útil sería poder:

  • No solo reordenar las columnas, sino también elegir cuáles queremos mostrar y cuáles ocultar con solo un par de clicks.
  • Compactar las columnas de acuerdo a su contenido con un solo click.
  • Contar con algún panel de búsqueda rápida que nos permita buscar información en nuestra tabla.
  • Poder filtrar filas en nuestra tabla de acuerdo a su contenido.

La realidad es que nuestra tabla así como es no nos permite hacer nada de esto. Sin embargo hay dos buenas noticias:

  1. Swing ha demostrado ser sumamante extensible y nos permite implementar todo esto.
  2. Mejor aún, ya existen extensiones o librerías basadas en Swing que nos brindan estas funcionalidades.

Estamos hablando concretamente de dos proyectos open source que ofrecen mejoras de los componentes Swing: por un lado SwingX del equipo Swinglabs, y por otro lado SwingBits de Eugene Ryzhikov.

En esta entrada comenzaremos con un ejemplo sencillo e iremos agregando funcionalidades de a una a la vez. Al final hay un ejemplo completo con todo lo expuesto.

Antes de comenzar

Como primera medida, definamos una clase de dominio con la cual trabajar. A partir de esta clase podremos implementar nuestro propio TableModel. Tenemos entonces:

public class Procesador {

    private String fabricante;
    private String denominacion;
    private Integer numeroNucleos;
    private Double frecuenciaCpu;
    private Double cache;

    public Procesador() {
        super();
    }
    // Getters y Setters aquí
}

Una tabla sencilla, común y corriente

El código necesario para generar una tabla estándar de Swing es más o menos como el que sigue (ver entrada anterior):

TableModel model = new GenericDomainTableModel<Procesador>() {
    // Implementación de los métodos abstractos aquí...
};

JTable table = new JTable(model);
table.setAutoCreateRowSorter(true);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);

Básicamente definimos nuestro TableModel, instanciamos nuestro JTable  y le decimos que genere los row sorters automáticamente, además de decirle que permitiremos la selección múltiple de las filas.

Nuestra tabla se verá así:

 simple-table

Nada del otro mundo, a lo sumo podemos ordenar las filas haciendo click en el header de la tabla, editar alguna celda o reorganizar las columnas, pero nada más.

Agregando selección y autoajuste de columnas: JXTable

Para poder contar con un botón que nos permita elegir qué columnas mostrar y cuales ocultar, haremos uso del componente JXTable de SwingX, el cual extiende de JTable y nos ofre esta característica out of the box:

JXTable table = new JXTable(model);
table.setAutoCreateRowSorter(true);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
/*
 * Agregamos el control de columnas a nuestra tabla:
 */
table.setColumnControlVisible(true); // Así es, esto es todo!

Vemos que simplemente llamando al método setColumnControlVisible() nuestra tabla contará con un botón situado en la esquina superior derecha que nos permitirá seleccionar las columnas a visualizar, ajustar las columnas de acuerdo a su contenido y habilitar/deshabilitar el scroll horizontal de nuestra tabla.

column-control-table

Agregando un panel de búsqueda rápida

Hoy en día es muy común que al presionar la combinación de teclas CTRL + F en algún lugar de la pantalla aparezca un cuadro de diálogo que nos permita buscar contenido ya sea en un archivo PDF, una hoja de cálculos, un procesador de textos, el navegador web, etc. Sería excelente contar con lo misma herramienta en nuestra tabla.

Bueno, sucede que por defecto el componente JXTable ya tiene esta característica implementada, sólo tenemos que posicionarnos sobre la tabla, presionar CRTL + F y el siguiente diálogo se hará visible:

find-dialog-table

Increíble, ¿verdad? Esta funcionalidad no es exclusiva de las tablas en SwingX, sino que es posible en otros componentes gracias a la interfaz Searchable provista en esta librería. Esta interfaz tiene una implementación por defecto para las tablas, llamada TableSearchable. Habiendo dicho esto, el componente JXTable tiene un método getSearchable() que devuelve un objeto que implementa la interfaz Searchable, más concretamente una instancia de la clase TableSearchabe.

Sumado a esto, SwingX provee un componente llamado JXFindPanel, el cual consiste en un panel con los controles (text field y check boxes) necesarios para efectuar una búsqueda y hace uso de la interfaz Searchable. Esto quiere decir que si queremos implementar nuestro propio dialogo o panel de búsqueda, también podemos hacerlo.

Por si esto fuera poco, también disponemos de un componente llamado JXFindBar el cual extiende de JXFindPanel, y como su nombre lo indica es una barra de búsqueda rápida. Lo mejor de todo es que sólo necesitamos un par de líneas para hacer que funcione:

JXTable table = new JXTable(model);
...
JXFindBar findBar = new JXFindBar(table.getSearchable()); // esto es suficiente!

Como JXFindBar es un componente como cualquier otro, podemos mostrarlo en un díalogo o agregarlo en un panel u otro componente. Por ejemplo, si definimos un panel como el que sigue:

JPanel content = new JPanel(new BorderLayout());
content.setBorder(new EmptyBorder(8,8,8,8));
content.add(findBar, BorderLayout.NORTH);
content.add(new JScrollPane(table));

Veremos la siguiente barra de búsqueda en la parte superior de nuestro panel:

quick-search-table

Agregando la capacidad de filtrar filas: TableRowFilterSupport

Finalmente, el broche de oro viene de la mano de la clase TableRowFilterSupport provista por SwingBits y que nos permite dotar a nuestra tabla con la capacidad de filtrar filas de acuerdo al contenido de cada columna, de manera dinámica e incremental, es decir, aplicando más de un filtro a la vez. Lo mejor de todo es que con una sola línea de código podemos disponer de esta característica:

JTable table = new JTable(model);
...
/*
 * Agregamos la funcionalidad de filtrar en nuestra tabla:
 */
TableRowFilterSupport.forTable(table).searchable(true).apply();

Al hacer click con el botón secundario del mouse, se desplegará una lista con todos los valores (sin duplicados) de nuestra tabla, permitiéndonos seleccionar cuáles mostrar:

non-filtered-table

filtered-table

double-filtered-table

Conclusión

En esta entrega hemos visto que con dos extensiones open source de los componentes Swing, podemos mejorar significativamente la experiencia del usuario al trabajar con tablas. En mi experiencia, el proyecto SwingX es realmente muy potente y ofrece muchas funcionalidades acordes a las nuevas interfaces de usuario que se han ido desarrollando, con lo cual sin lugar a dudas es un aliado invaluable al momento de desarrollar la GUI de nuestra aplicación.

Ejemplo completo

A continuación y a modo de bonus, un ejemplo completo con todo lo expuesto en este artículo. Cualquier interesado puede copiar y pegar el código y utilizarlo libremente, siempre respetando la licencia GPL v3.

/*
 * Copyright (C) 2014 Delcio Amarillo
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see http://www.gnu.org/licenses/.
 */

import com.ezware.oxbow.swingbits.table.filter.TableRowFilterSupport;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.event.ActionEvent;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import org.jdesktop.swingx.JXFindBar;
import org.jdesktop.swingx.JXTable;

public class DemoAdvancedTable {
    
    private GenericDomainTableModel<Procesador> procesadoresTableModel;
    private JXTable table;
    private Action nuevoProcesadorAction, eliminarProcesadorAction;
    
    
    private void createAndShowGui() {        
        JPanel content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(new EmptyBorder(12, 12, 12, 12));
        content.add(getCenterPanel(), BorderLayout.CENTER);
        content.add(getTopPanel(), BorderLayout.NORTH);
        
        JFrame frame = new JFrame("Bienvenido!");
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(content);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
    
    private JPanel getTopPanel() {
        JXFindBar findBar = new JXFindBar(table.getSearchable());
        JPanel topPanel = new JPanel(new BorderLayout());
        topPanel.setBorder(new TitledBorder("Búsqueda rápida:"));
        topPanel.add(findBar);
        return topPanel;
    }
    
    private JPanel getCenterPanel() {        
        Object[] columnIdentifiers = new Object[]{
            "Fabricante",
            "Denominación", 
            "Núcleos", 
            "Frecuencia CPU @GHz", 
            "Cache MB"
        };
        
        procesadoresTableModel = new GenericDomainTableModel<Procesador>(Arrays.asList(columnIdentifiers)) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch (columnIndex) {
                    case 0: return String.class;
                    case 1: return String.class;
                    case 2: return Integer.class;
                    case 3: return Double.class;
                    case 4: return Double.class;
                        default: throw new ArrayIndexOutOfBoundsException(columnIndex);
                }
            }
            
            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Procesador procesador = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: return procesador.getFabricante();
                    case 1: return procesador.getDenominacion();
                    case 2: return procesador.getNumeroNucleos();
                    case 3: return procesador.getFrecuenciaCpu();
                    case 4: return procesador.getCache();
                        default: throw new ArrayIndexOutOfBoundsException(columnIndex);
                }
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
                Procesador procesador = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: procesador.setFabricante((String)aValue); break;
                    case 1: procesador.setDenominacion((String)aValue); break;
                    case 2: procesador.setNumeroNucleos((Integer)aValue); break;
                    case 3: procesador.setFrecuenciaCpu((Double)aValue); break;
                    case 4: procesador.setCache((Double)aValue); break;
                        default: throw new ArrayIndexOutOfBoundsException(columnIndex);
                }
            }
        };
        
        table = new JXTable(procesadoresTableModel);
        table.setPreferredScrollableViewportSize(new Dimension(600, 300));
        table.setAutoCreateRowSorter(true);
        table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        
        /*
         * Agregamos el control de columnas a nuestra tabla
         */
        table.setColumnControlVisible(true);
        
        /*
         * Agregamos la funcionalidad de filtrar en nuestra tabla
         */
        TableRowFilterSupport.forTable(table).searchable(true).apply();
        
        eliminarProcesadorAction = new AbstractAction("-") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] selectedRows = table.getSelectedRows();
                for (int row = selectedRows.length - 1; row >= 0; row--) {
                    int viewIndex = selectedRows[row];
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    procesadoresTableModel.deleteRow(modelIndex);
                }
                setEnabled(procesadoresTableModel.getRowCount() > 0);
            }
        };        
        eliminarProcesadorAction.setEnabled(false);
        
        nuevoProcesadorAction = new AbstractAction("+") {
            @Override
            public void actionPerformed(ActionEvent e) {
                procesadoresTableModel.addRow(new Procesador());
                eliminarProcesadorAction.setEnabled(true);
            }
        };
        
        JPanel buttonsPanel = new JPanel();
        buttonsPanel.add(new JButton(nuevoProcesadorAction));
        buttonsPanel.add(new JButton(eliminarProcesadorAction));
        
        JPanel centerPanel = new JPanel(new BorderLayout(8,8));
        CompoundBorder border = BorderFactory.createCompoundBorder(new TitledBorder("Procesadores:"), new EmptyBorder(8,8,8,8));
        centerPanel.setBorder(border);
        centerPanel.add(new JScrollPane(table));
        centerPanel.add(buttonsPanel, BorderLayout.EAST);
        
        return centerPanel;
    }
    
    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                    new DemoAdvancedTable().createAndShowGui();
                } catch (UnsupportedLookAndFeelException | ClassNotFoundException | InstantiationException | IllegalAccessException ex) {
                    Logger.getLogger(DemoAdvancedTable.class.getName()).log(Level.SEVERE, null, ex);
                }
            }
        });
    }
}

Código fuente

Pueden encontrar el código del ejemplo DemoAdvancedTable en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Nota: actualmente no existe un repositorio central en Maven para SwingBits, pero puede descargarse el JAR del repositorio GitHub de SwingBits y agregarlo como artefacto manualmente.

Creando un TableModel reutilizable en Swing (parte III)

En la entrada anterior creamos nuestro propio TableModel reutilizable, a los efectos de contar con una API que nos haga la vida más fácil al momento de trabajar con tablas. Quizás a primera vista nuestra clase pueda parecer compleja debido a la cantidad de líneas de código que hemos generado. Sin embargo todo ese trabajo dará sus frutos, como veremos en esta última entrega.

Para comenzar retomemos aquél ejemplo de la primera entrada, donde teníamos una clase de dominio Persona y vimos que la clase DefaultTableModel resultaba poco práctica. Tenemos entonces nuestra clase de dominio:

public class Persona {

    private String nombre;
    private Date fechaDeNacimiento;
    private Long documento;

    public Persona(String nombre, Date fechaDeNacimiento, Long documento) {
        this.nombre = nombre;
        this.fechaDeNacimiento = fechaDeNacimiento;
        this.documento = documento;
    }

    // Getters y setters aquí
}

Recordemos que nuestro TableModel es una clase abstracta y nos obliga a implementar tres métodos:

Veamos entonces cómo queda una implementación concreta de nuestra clase GenericDomainTableModel:

List columnIdentifiers = Arrays.asList(new Object[]{"Nombre", "Fecha de nacimiento", "Documento"});
GenericDomainTableModel<Persona> model = new GenericDomainTableModel<Persona>(columnIdentifiers) {
    @Override
    public Class<?> getColumnClass(int columnIndex) {
        switch(columnIndex) {
            case 0: return String.class;
            case 1: return Date.class;
            case 2: return Long.class;
        }
        throw new ArrayIndexOutOfBoundsException(columnIndex);
    }

    @Override
    public Object getValueAt(int rowIndex, int columnIndex) {
        Persona persona = getDomainObject(rowIndex);
        switch(columnIndex) {
            case 0: return persona.getNombre();
            case 1: return persona.getFechaDeNacimiento();
            case 2: return persona.getDocumento();
                default: throw new ArrayIndexOutOfBoundsException(columnIndex);
        }
    }

    @Override
    public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
        Persona persona = getDomainObject(rowIndex);
        switch(columnIndex) {
            case 0: persona.setNombre((String)aValue); break;
            case 1: persona.setFechaDeNacimiento((Date)aValue); break;
            case 2: persona.setDocumento((Long)aValue); break;
                default: throw new ArrayIndexOutOfBoundsException(columnIndex);
        }
        notifyTableCellUpdated(rowIndex, columnIndex); // NO olvidar!!!
    }
}; // Fin de la implementación concreta. Sí, eso es todo!

JTable table = new JTable(model); // Vista

Como podemos ver, en menos de 40 líneas de código (incluyendo espacios) tenemos nuestro TableModel perfectamente implementado. Sencillo, ¿verdad? Pero aún hay más.

Obtener el objeto de dominio seleccionado en la tabla

Supongamos que queremos obtener el objeto de dominio correspondiente a la fila seleccionada en la tabla:

int viewIndex = table.getSelectedRow();
int modelIndex = table.convertRowIndexToModel(viewIndex); // NO olvidar convertir el indice de la vista al modelo
Persona personaSeleccionada = model.getDomainObject(modelIndex);

Agregando un nuevo objeto de dominio

Para agregar una nueva persona en nuestra tabla, simplemente hacemos esto:

Persona nuevaPersona = new Persona("John Doe", new Date(), 12345L);
model.addRow(nuevaPersona);

Eso es todo. Como la implementación del método addRow(T domainObject) notifica a los listeners, la tabla (vista) se actualizará automáticamente.

Eliminando un objeto de dominio

Para eliminar una fila seleccionada en la tabla, podemos hacer los siguiente:

int viewIndex = table.getSelectedRow();
int modelIndex = table.convertRowIndexToModel(viewIndex);
model.deleteRow(modelIndex); // elimina sólo una fila

O si queremos eliminar un objeto de dominio dado:

int viewIndex = table.getSelectedRow();
int modelIndex = table.convertRowIndexToModel(viewIndex);
Persona personaAEliminar = model.getDomainObject(modelIndex);
model.deleteRow(personaAEliminar); // elimina todas las ocurrencias de este objeto del TableModel

Nuevamente, como la implementación de los métodos para eliminar filas u objetos de dominio notifica a los listeners suscriptos, entonces la tabla (vista) se actualizará automáticamente.

Eliminando todas las filas del TableModel

Si queremos eliminar todas las filas de nuestro modelo, simplemente llamamos al siguiente método y el mismo se encargará de notificar a los listeners, con lo cual la tabla se vaciará automáticamente:

modelo.clearTableModelData();

Un objeto de dominio ha sido modificado

Supongamos que el estado de un objeto de dominio ha cambiado y queremos actualizar nuestra tabla para reflejar dichos cambios. Por ejemplo, al seleccionar una fila en la tabla podríamos abrir un nuevo diálogo para modificar los datos (estado interno) de la Persona seleccionada, y luego reflejar los cambios en la tabla (vista).

Simplemente llamamos al siguiente método:

int viewIndex = table.getSelectedRow();
int modelIndex = table.convertRowIndexToModel(viewIndex);
Persona personaAModificar = model.getDomainObject(modelIndex);
/*
 * Modificamos los datos aquí...
 * Y finalmente notificamos al modelo que el objeto ha cambiado.
 */
model.notifyDomainObjectUpdated(personaAModificar);

En resumen

Ahora contamos con una API potente para poder trabajar con nuestro modelo de datos. Por si esto fuera poco, nuestra clase no es ni tiene ningún método final y por consiguiente podemos extender de ella, agregar nuevos métodos o sobrescribir los existentes según sea necesario. Sin embargo, nuestra implementación per se es lo suficientemente general como para abarcar un buen número de situaciones comunes.

Ejemplo completo

A continuación y a modo de bonus, el código completo de un ejemplo utilizando nuestro GenericDomainTableModel. En este caso necesitamos dos clases de dominio, cuyo código completo no está incluido para evitar que el post sea excesivamente extenso, pero cuya base es la siguiente:

public class Fabricante {

    private final String nombre;
    private final Date fechaFundación;
    private final List<Procesador> procesadores;

    public Fabricante(String nombre, Date fechaFundación) {
        this.nombre = nombre;
        this.fechaFundación = fechaFundación;
        this.procesadores = new ArrayList<>();
    }

    // Sólo Getters aquí, los Setters no son necesarios

    public void addProcesador(Procesador p) {
        procesadores.add(p);
    }
        
    public void removeProcesador(Procesador p) {
        procesadores.remove(p);
    }
}

public class Procesador {

    private String denominacion;
    private Integer numeroNucleos;
    private Double frecuenciaCpu;
    private Double cache;
    private final Fabricante fabricante;

    public Procesador(Fabricante fabricante) {
        this.fabricante = fabricante;
    }

    // Getters y Setters aquí, excepto para el atributo fabricante
}

Ahora sí, el código del ejemplo. Todo interesado puede copiar y pegar el código y utilizarlo libremente, siempre repetando la licencia GPL v3.

/*
 * Copyright (C) 2014 Delcio Amarillo
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BorderFactory;
import javax.swing.DefaultComboBoxModel;
import javax.swing.DefaultListCellRenderer;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerDateModel;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import javax.swing.text.DateFormatter;

public class Demo {
    
    private GenericDomainTableModel<Procesador> procesadoresTableModel;
    private Fabricante fabricanteSeleccionado;
    
    private Action nuevoProcesadorAction, eliminarProcesadorAction;
    
    private void createAndShowGui() {        
        JPanel content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(new EmptyBorder(12, 12, 12, 12));
        content.add(getTopPanel(), BorderLayout.NORTH);
        content.add(getCenterPanel(), BorderLayout.CENTER);
        
        JFrame frame = new JFrame("Bienvenido!");
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(content);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
    
    private JPanel getTopPanel() {        
        
        final DefaultComboBoxModel<Fabricante> comboBoxModel = new DefaultComboBoxModel<>();
        JComboBox comboBox = new JComboBox(comboBoxModel);
        comboBox.setPrototypeDisplayValue("Por favor seleccione un fabricante");
        
        comboBox.setRenderer(new DefaultListCellRenderer() {
            @Override
            public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
                super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
                if (value instanceof Fabricante) {
                    Fabricante fabricante = (Fabricante)value;
                    setText(fabricante.getNombre());
                }
                return this;
            }
        });
        
        comboBox.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                fabricanteSeleccionado = (Fabricante)comboBoxModel.getSelectedItem();
                procesadoresTableModel.clearTableModelData();
                procesadoresTableModel.addRows(fabricanteSeleccionado.getProcesadores());
                nuevoProcesadorAction.setEnabled(fabricanteSeleccionado != null);
            }
        });
        
        JButton button = new JButton("+");
        button.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                JLabel labelNombre = new JLabel("Nombre:");
                labelNombre.setHorizontalAlignment(JLabel.RIGHT);
                JTextField textField = new JTextField(20);
                
                JLabel labelFechaFundacion = new JLabel("Fecha fundación:");
                labelFechaFundacion.setHorizontalAlignment(JLabel.RIGHT);
                
                JSpinner spinner = new JSpinner(new SpinnerDateModel());
                JSpinner.DateEditor editor = new JSpinner.DateEditor(spinner, "dd/MM/yyyy");
                
                DateFormatter formatter = (DateFormatter)editor.getTextField().getFormatter();
                formatter.setAllowsInvalid(false);
                formatter.setOverwriteMode(true);
                
                spinner.setEditor(editor);
                
                JPanel panel = new JPanel(new GridLayout(0, 2, 8, 8));
                panel.add(labelNombre);
                panel.add(textField);
                panel.add(labelFechaFundacion);
                panel.add(spinner);
                
                int opcion = JOptionPane.showConfirmDialog(null, panel, "Nuevo Fabricante"
                                                        , JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
                if (opcion == JOptionPane.OK_OPTION) {
                    String nombre = textField.getText().trim();
                    Date fechaFundacion = (Date)spinner.getValue();
                    comboBoxModel.addElement(new Fabricante(nombre, fechaFundacion));
                } 
            }
        });
        
        JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEADING));
        topPanel.setBorder(new TitledBorder("Fabricantes:"));
        topPanel.add(new JLabel("Seleccione un fabricante:"));
        topPanel.add(comboBox);
        topPanel.add(button);
        
        return topPanel;
    }
    
    private JPanel getCenterPanel() {        
        Object[] columnIdentifiers = new Object[]{
            "Denominación", 
            "Núcleos", 
            "Frecuencia CPU @GHz", 
            "Cache MB"
        };
        
        procesadoresTableModel = new GenericDomainTableModel<Procesador>(Arrays.asList(columnIdentifiers)) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch(columnIndex) {
                    case 0: return String.class;
                    case 1: return Integer.class;
                    case 2: return Double.class;
                    case 3: return Double.class;
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }
            
            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Procesador procesador = getDomainObject(rowIndex);
                switch(columnIndex) {
                    case 0: return procesador.getDenominacion();
                    case 1: return procesador.getNumeroNucleos();
                    case 2: return procesador.getFrecuenciaCpu();
                    case 3: return procesador.getCache();
                        default: throw new ArrayIndexOutOfBoundsException(columnIndex);
                }
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
                Procesador procesador = getDomainObject(rowIndex);
                switch(columnIndex) {
                    case 0: procesador.setDenominacion((String)aValue); break;
                    case 1: procesador.setNumeroNucleos((Integer)aValue); break;
                    case 2: procesador.setFrecuenciaCpu((Double)aValue); break;
                    case 3: procesador.setCache((Double)aValue); break;
                        default: throw new ArrayIndexOutOfBoundsException(columnIndex);
                }
                notifyTableCellUpdated(rowIndex, columnIndex);
            }
        };
        
        final JTable table = new JTable(procesadoresTableModel);
        table.setPreferredScrollableViewportSize(new Dimension(500, 300));
        table.setAutoCreateRowSorter(true);
        table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        
        eliminarProcesadorAction = new AbstractAction("-") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] selectedRows = table.getSelectedRows();
                for (int row = selectedRows.length - 1; row >= 0; row--) {
                    int viewIndex = selectedRows[row];
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    fabricanteSeleccionado.removeProcesador(procesadoresTableModel.getDomainObject(viewIndex));
                    procesadoresTableModel.deleteRow(modelIndex);
                }
                setEnabled(procesadoresTableModel.getRowCount() > 0);
            }
        };        
        eliminarProcesadorAction.setEnabled(false);
        
        nuevoProcesadorAction = new AbstractAction("+") {
            @Override
            public void actionPerformed(ActionEvent e) {
                Procesador p = new Procesador(fabricanteSeleccionado);
                fabricanteSeleccionado.addProcesador(p);
                procesadoresTableModel.addRow(p);
                eliminarProcesadorAction.setEnabled(true);
            }
        };        
        nuevoProcesadorAction.setEnabled(false);
        
        JPanel buttonsPanel = new JPanel();
        buttonsPanel.add(new JButton(nuevoProcesadorAction));
        buttonsPanel.add(new JButton(eliminarProcesadorAction));
        
        JPanel centerPanel = new JPanel(new BorderLayout(8,8));
        CompoundBorder border = BorderFactory.createCompoundBorder(new TitledBorder("Procesadores:"), new EmptyBorder(8,8,8,8));
        centerPanel.setBorder(border);
        centerPanel.add(new JScrollPane(table));
        centerPanel.add(buttonsPanel, BorderLayout.EAST);
        
        return centerPanel;
    }
    
    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                    new Demo().createAndShowGui();
                } catch (UnsupportedLookAndFeelException | ClassNotFoundException | InstantiationException | IllegalAccessException ex) {
                    Logger.getLogger(Demo.class.getName()).log(Level.SEVERE, null, ex);
                }
            }
        });
    }

} // End of class Demo

Capturas de pantalla

Nótese que al cambiar la selección del combo box, cambia la información en la tabla gracias al código del ActionListener agregado al mismo:

public class Demo {

    private GenericDomainTableModel<Procesador> procesadoresTableModel;
    private Fabricante fabricanteSeleccionado;
    ...
    private JPanel getTopPanel() {
        ...
        comboBox.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                fabricanteSeleccionado = (Fabricante)comboBoxModel.getSelectedItem();
                procesadoresTableModel.clearTableModelData();
                procesadoresTableModel.addRows(fabricanteSeleccionado.getProcesadores());
                ...
            }
        });
        ...
    }
    ...
} // End of class Demo

TableModel-Demo TableModel-Demo-0 TableModel-Demo-2 TableModel-Demo-1

Código fuente

Pueden encontrar el código del ejemplo DemoGenericDomainTableModel en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Creando un TableModel reutilizable en Swing (parte II)

En la entrada anterior vimos algunas dificultades al momento de utilizar las clases que implementan la interfaz TableModel cuando nuestro modelo de dominio toma cierta complejidad. Por lo tanto, vamos a crear nuestra propia implementación de modo tal que sea reutilizable y nos ofrezca una API para poder trabajar de modo sencillo.

Para lograr nuestro objetivo utilizaremos Generics y haremos que nuestra clase sea abstracta, a los efectos de obligar a las subclases concretas a implementar tres métodos fundamentales que definen la interacción entre el modelo y la vista:

Definiendo nuestra clase

Comenzaremos por definir la firma de nuestra clase, los atributos o class members que necesitaremos y sus constructores. Luego nos enfocaremos en la implementación de dos aspectos que define la interfaz: notificación de eventos y manejo listeners, y operaciones de datos. Lo haremos por separado para poder distinguir a qué categoría corresponde cada método, pero al final de la entrada se encuentra todo el código de nuestra clase..

/**
 * Clase abstracta que implementa la interfaz {@code TableModel} y provee una API 
 * para trabajar con objetos de dominio.
 *
 * @author Delcio Amarillo
 * @param <T> El tipo de clase de Dominio manejado por este TableModel.
 */
public abstract class GenericDomainTableModel<T> implements TableModel {

    private EventListenerList listenerList;
    private List columnIdentifiers;
    private final List<T> data;
    /**
     * Crea un nuevo {@code GenericDomainTableModel} vacío, 
     * sin datos ni identificadores para las columnas
     */
    public GenericDomainTableModel() {
        data = new ArrayList<>();
        columnIdentifiers = new ArrayList();
        listenerList = new EventListenerList();
    }

    /**
     * Crea un nuevo {@code GenericDomainTableModel} sin datos
     * con identificadores para las columnas.
     *
     * @param columnIdentifiers Los identificadores para las columnas de la tabla.
     * @throws IllegalArgumentException Si {@code columnIdentifiers} es {@code null}.
     */
    public GenericDomainTableModel(List columnIdentifiers) {
        this();
        if (columnIdentifiers == null) {
            throw new IllegalArgumentException("El parámetro columnIdentifers no puede ser null.");
        } else {
            this.columnIdentifiers.addAll(columnIdentifiers);
        }
    }
}

Vemos que solo necesitaremos tres atributos para nuestra clase: una lista de Objects para los identificadores de las columnas, una lista con objetos T que será nuestra estructura de datos subyacente, y un objeto de la clase EventListenerList para agregar/remover los listeners de nuestro modelo.

Notificación de eventos y manejo de listeners

Con respecto al manejo de listeners, la interfaz TableModel sólo define dos métodos: uno para agregar y otro para remover los TableModelListeners de nuestro modelo, respectivamente:

public abstract class GenericDomainTableModel<T> implements TableModel {
    ...
    private EventListenerList listenerList;
    ...

    @Override
    public void addTableModelListener(TableModelListener l) {
        listenerList.add(TableModelListener.class, l);
    }

    @Override
    public void removeTableModelListener(TableModelListener l) {
        listenerList.remove(TableModelListener.class, l);
    }

    /**
     * Método conveniente para obtener obtener una lista con 
     * los TableModelListeners suscriptos a nuestro modelo.
     * NO es parte de la interfaz TableModel
     */ 
    public TableModelListener[] getTableModelListeners() {
        return listenerList.getListeners(TableModelListener.class);
    }
}

Sencillo, ¿verdad?  Sin embargo, no basta sólo con mantener una lista con los listeners si no vamos a notificarles nada. Hemos de proveer entonces métodos para notificarles cuando un evento ocurre. Nota: en este punto conviene tener presente la API de la clase TableModelEvent.

public abstract class GenericDomainTableModel<T> implements TableModel {
    ...
    /**
     * Método general para notificar a los {@code TableModelListeners} 
     * que ha ocurrido un evento.
     * Nota: Los listeners son notificados en orden inverso al de suscripción.
     */
    protected void notifyTableChanged(TableModelEvent e) {
        TableModelListener[] listeners = getTableModelListeners();
        for (int i = listeners.length - 1; i >= 0; i--) {
            listeners[i].tableChanged(e);
        }
    }

    /**
     * Notifica que el header de la tabla ha cambiado.
     */
    protected void notifyTableHeaderChanged() {
        TableModelEvent e = new TableModelEvent(this, TableModelEvent.HEADER_ROW);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que han sido insertadas nuevas filas.
     *
     * @param firstRow El índice de la primera fila insertada.
     * @param lastRow El índice de la última fila insertada.
     */    
    protected void notifyTableRowsInserted(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.INSERT);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que una o más filas en un rango han sido modificadas.
     * 
     * @param firstRow El índice de la primera fila en el rango.
     * @param lastRow El índice de la última fila en el rango.
     */    
    protected void notifyTableRowsUpdated(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.UPDATE);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que una o más filas en un rango han sido borradas.
     * 
     * @param firstRow El índice de la primera fila en el rango.
     * @param lastRow El índice de la última fila en el rango.
     */        
    protected void notifyTableRowsDeleted(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que el valor de una celda ha cambiado.
     * 
     * @param row El índice de la fila a la que pertenece la celda.
     * @param column El índice de la columna a la que pertenece la celda.
     */    
    protected void notifyTableCellUpdated(int row, int column) {
        TableModelEvent e = new TableModelEvent(this, row, row, column);
    }
}

Bien, ahora que ya tenemos resuelto el manejo de listeners y la notificación de eventos, podemos comenzar a implementar el manejo de datos de nuestro modelo.

Manejo de datos

Con respecto al manejo de datos estrictamente hablando la interfaz TableModel ofrece sólo dos métodos: uno para actualizar una celda y otro para obtener el valor de una celda. El resto son métodos para brindar información con respecto al modelo, los cuales implementaremos primero para poder salir rápidamente de esta tarea y enfocarnos en la operación con datos, que es lo que más nos interesa.

public abstract class GenericDomainTableModel<T> implements TableModel {
    ...
    private List columnIdentifiers;
    private final List<T> data;
    ...
    @Override
    public int getRowCount() {
        return data.size();
    }

    @Override
    public int getColumnCount() {
        return columnIdentifiers.size();
    }

    @Override
    public String getColumnName(int columnIndex) {
        if (columnIndex < 0 || columnIndex >= getColumnCount()) {
            throw new ArrayIndexOutOfBoundsException(columnIndex);
        } else {
            return columnIdentifiers.get(columnIndex).toString();
        }
    }

    @Override
    public boolean isCellEditable(int rowIndex, int columnIndex) {
        return true;
    }
}

Ahora llegó el momento de proveer métodos para poder agregar, modificar, eliminar u obtener filas de nuestro modelo, notificando a los listeners cuando corresponda, y de un modo totalmente orientado a objetos:

public abstract class GenericDomainTableModel<T> implements TableModel {
    ...
    private List columnIdentifiers;
    private final List<T> data;
    ...
    /**
     * Agrega un nuevo objeto de dominio como fila al final del table model.
     * @param domainObject El objeto de dominio.
     */
    public void addRow(T domainObject) {
        int rowIndex = data.size();
        data.add(domainObject);
        notifyTableRowsInserted(rowIndex, rowIndex);
    }

    /**
     * Agrega varios objetos de dominio como filas al final del table model.
     * @param domainObjects Los objetos de dominio
     */
    public void addRows(List<T> domainObjects) {
        if (!domainObjects.isEmpty()) {
            int firstRow = data.size();
            data.addAll(domainObjects);
            int lastRow = data.size() - 1;
            notifyTableRowsInserted(firstRow, lastRow);
        }
    }

    /**
     * Inserta un objeto de dominio como fila en el table model, en un 
     * número de fila específico.
     * 
     * @param domainObject El objeto de dominio.
     * @param rowIndex El número de fila.
     */
    public void insertRow(T domainObject, int rowIndex) {
        data.add(rowIndex, domainObject);
        notifyTableRowsInserted(rowIndex, rowIndex);
    }

    /**
     * Notifica que un objeto de dominio ha cambiado, causando
     * una notificación en cascada hacia los listeners suscriptos.
     * @param domainObject El objeto de dominio
     */
    public void notifyDomainObjectUpdated(T domainObject) {
        T[] elements = (T[])data.toArray();
        for (int i = 0; i < elements.length; i++) {
            if (elements[i] == domainObject) {
                notifyTableRowsUpdated(i, i);
            }
        }
    }

    /**
     * Elimina un objeto de dominio del table model.
     * @param domainObject El objeto de dominio a eliminar.
     */
    public void deleteRow(T domainObject) {
        int rowIndex = -1;
        while ((rowIndex = data.indexOf(domainObject)) > -1) {
            data.remove(domainObject);
            notifyTableRowsDeleted(rowIndex, rowIndex);
        }
    }

    /**
     * Elimina una fila del table model según un índice.
     * Nota: NO remueve todas las ocurrencias del objeto 
     * de dominio asociado al número de fila, sólo la indicada
     * por el parámetro {@code rowIndex}
     *
     * @param rowIndex El número de fila a eliminar
     */
    public void deleteRow(int rowIndex) {
        if (data.remove(data.get(rowIndex))) {
            notifyTableRowsDeleted(rowIndex, rowIndex);
        }
    }

    /**
     * Elimina las filas dentro del rango {@code [firstRow, lastRow]}.
     *
     * @param firstRow La primera fila a eliminar (inclusive).
     * @param lastRow La última fila a eliminar (inclusive).
     * @throws IllegalArgumentException Si {@code firstRow < 0} ó {@code lastRow < 0} ó {@code firstRow > lastRow}.
     */
    public void deleteRows(int firstRow, int lastRow) {
        if (firstRow < 0 || lastRow < 0 || firstRow > lastRow) {
            throw new IllegalArgumentException("Los parámetros firstRow y lastRow deben ser positivos y firstRow >= lastRow.");
        } else {
            for (int i = firstRow; i <= lastRow; i++) {
                data.remove(i);
            }
            notifyTableRowsDeleted(firstRow, lastRow);
        }
    }

    /**
     * Elimina todas las filas de este table model, notificando a los listeners.
     */
    public void clearTableModelData() {
        if (!data.isEmpty()) {
            int lastRow = data.size() - 1;
            data.clear();
            notifyTableRowsDeleted(0, lastRow);
        }
    }

    /**
     * Devuelve un objeto de dominio basado en un número de fila.
     * @param rowIndex El número de la fila.
     * @return Un objeto de dominio.
     */
    public T getDomainObject(int rowIndex) {
        return data.get(rowIndex);
    }
    
    /**
     * Devuelve una sublista de objetos de dominio comprendida en
     * el rango {@code [firstRow, lastRow]}.
     * 
     * @param firstRow La primera fila del rango (inclusive).
     * @param lastRow La última fila del rango (inclusive).
     * @return Una sublista con objetos de dominio.
     */
    public List<T> getDomainObjects(int firstRow, int lastRow) {
        return Collections.unmodifiableList(data.subList(firstRow, lastRow + 1));
    }

    /**
     * @return Todos los objetos de dominio de este table model.
     */
    public List<T> getDomainObjects() {
        return Collections.unmodifiableList(data);
    }

    /**
     * Establece los identificadores de las columnas para este table model,
     * notificando a los listeners.
     * @param columnIdentifiers Los nuevos identificadores de columnas.
     */
    public void setColumnIdentifiers(List columnIdentifiers) {
        this.columnIdentifiers.clear();
        this.columnIdentifiers.addAll(columnIdentifiers);
        notifyTableHeaderChanged();
    }
}

Como podemos ver, hemos agregado una gran cantidad de métodos que nos facilitarán enormemente nuestro trabajo con tablas en Swing. La principal ventaja de este modelo es que al usar Generics podemos tomar cualquier clase, ya sea estándar o definida por nosotros, y trabajar sin ningún problema. Veremos esto con un ejemplo en la tercera parte de este post.

Clase GenericDomainTableModel

A continuación el código completo de nuestra clase. Cualquier interesado puede simplemente copiar y pegar el código y utilizarlo libremente en su proyecto, siempre repetando la licencia GPL v3.

/*
 * Copyright (C) 2014 Delcio Amarillo
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

/**
 * Clase abstracta que implementa la interfaz {@code TableModel} y provee una API 
 * para trabajar con objetos de dominio.
 *
 * @author Delcio Amarillo
 * @param <T> El tipo de clase de Dominio manejado por este modelo.
 */
public abstract class GenericDomainTableModel<T> implements TableModel {

    private EventListenerList listenerList;
    private List columnIdentifiers;
    private final List<T> data;

    /**
     * Crea un nuevo {@code GenericDomainTableModel} vacío, 
     * sin datos ni identificadores para las columnas
     */
    public GenericDomainTableModel() {
        data = new ArrayList<>();
        columnIdentifiers = new ArrayList();
        listenerList = new EventListenerList();
    }

    /**
     * Crea un nuevo {@code GenericDomainTableModel} sin datos
     * con identificadores para las columnas.
     *
     * @param columnIdentifiers Los identificadores para las columnas de la tabla.
     * @throws IllegalArgumentException Si {@code columnIdentifiers} es {@code null}.
     */
    public GenericDomainTableModel(List columnIdentifiers) {
        this();
        if (columnIdentifiers == null) {
            throw new IllegalArgumentException("El parámetro columnIdentifers no puede ser null.");
        } else {
            this.columnIdentifiers.addAll(columnIdentifiers);
        }
    }

    /* ***************** * 
     * Manejo de eventos *
     * ***************** */

    @Override
    public void addTableModelListener(TableModelListener l) {
        listenerList.add(TableModelListener.class, l);
    }

    @Override
    public void removeTableModelListener(TableModelListener l) {
        listenerList.remove(TableModelListener.class, l);
    }

    /**
     * Método conveniente para obtener obtener una lista con 
     * los TableModelListeners suscriptos a nuestro modelo.
     * NO es parte de la interfaz TableModel
     */ 
    public TableModelListener[] getTableModelListeners() {
        return listenerList.getListeners(TableModelListener.class);
    }

    /**
     * Método general para notificar a los {@code TableModelListeners} 
     * que ha ocurrido un evento.
     * Nota: Los listeners son notificados en orden inverso al de suscripción.
     */
    protected void notifyTableChanged(TableModelEvent e) {
        TableModelListener[] listeners = getTableModelListeners();
        for (int i = listeners.length - 1; i >= 0; i--) {
            listeners[i].tableChanged(e);
        }
    }

    /**
     * Notifica que el header de la tabla ha cambiado.
     */
    protected void notifyTableHeaderChanged() {
        TableModelEvent e = new TableModelEvent(this, TableModelEvent.HEADER_ROW);
        notifyTableChanged(e);
    }    

    /**
     * Notifica que han sido insertadas nuevas filas.
     *
     * @param firstRow El índice de la primera fila insertada.
     * @param lastRow El índice de la última fila insertada.
     */    
    protected void notifyTableRowsInserted(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.INSERT);
        notifyTableChanged(e);
    }    

    /**
     * Notifica que una o más filas en un rango han sido modificadas.
     * 
     * @param firstRow El índice de la primera fila en el rango.
     * @param lastRow El índice de la última fila en el rango.
     */    
    protected void notifyTableRowsUpdated(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.UPDATE);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que una o más filas en un rango han sido borradas.
     * 
     * @param firstRow El índice de la primera fila en el rango.
     * @param lastRow El índice de la última fila en el rango.
     */        
    protected void notifyTableRowsDeleted(int firstRow, int lastRow) {
        TableModelEvent e = new TableModelEvent(this, firstRow, lastRow, TableModelEvent.ALL_COLUMNS, TableModelEvent.DELETE);
        notifyTableChanged(e);
    }
    
    /**
     * Notifica que el valor de una celda ha cambiado.
     * 
     * @param row El índice de la fila a la que pertenece la celda.
     * @param column El índice de la columna a la que pertenece la celda.
     */    
    protected void notifyTableCellUpdated(int row, int column) {
        TableModelEvent e = new TableModelEvent(this, row, row, column);
    }

    /* ***************************************************** * 
     * Información del TableModel para el armado de la tabla *
     * ***************************************************** */

    @Override
    public int getRowCount() {
        return data.size();
    }

    @Override
    public int getColumnCount() {
        return columnIdentifiers.size();
    }

    @Override
    public String getColumnName(int columnIndex) {
        if (columnIndex < 0 || columnIndex >= getColumnCount()) {
            throw new ArrayIndexOutOfBoundsException(columnIndex);
        } else {
            return columnIdentifiers.get(columnIndex).toString();
        }
    }

    @Override
    public boolean isCellEditable(int rowIndex, int columnIndex) {
        return true;
    }

    /* ********************* * 
     * Manipulación de datos *
     * ********************* */ 

    /**
     * Agrega un nuevo objeto de dominio como fila al final del table model.
     * @param domainObject El objeto de dominio.
     */
    public void addRow(T domainObject) {
        int rowIndex = data.size();
        data.add(domainObject);
        notifyTableRowsInserted(rowIndex, rowIndex);
    }

    /**
     * Agrega varios objetos de dominio como filas al final del table model.
     * @param domainObjects Los objetos de dominio
     */
    public void addRows(List<T> domainObjects) {
        if (!domainObjects.isEmpty()) {
            int firstRow = data.size();
            data.addAll(domainObjects);
            int lastRow = data.size() - 1;
            notifyTableRowsInserted(firstRow, lastRow);
        }
    }

    /**
     * Inserta un objeto de dominio como fila en el table model, en un 
     * número de fila específico.
     * 
     * @param domainObject El objeto de dominio.
     * @param rowIndex El número de fila.
     */
    public void insertRow(T domainObject, int rowIndex) {
        data.add(rowIndex, domainObject);
        notifyTableRowsInserted(rowIndex, rowIndex);
    }

    /**
     * Notifica que un objeto de dominio ha cambiado, causando
     * una notificación en cascada hacia los listeners suscriptos.
     * @param domainObject El objeto de dominio
     */
    public void notifyDomainObjectUpdated(T domainObject) {
        T[] elements = (T[])data.toArray();
        for (int i = 0; i < elements.length; i++) {
            if (elements[i] == domainObject) {
                notifyTableRowsUpdated(i, i);
            }
        }
    }

    /**
     * Elimina un objeto de dominio del table model.
     * @param domainObject El objeto de dominio a eliminar.
     */
    public void deleteRow(T domainObject) {
        int rowIndex = -1;
        while ((rowIndex = data.indexOf(domainObject)) > -1) {
            data.remove(domainObject);
            notifyTableRowsDeleted(rowIndex, rowIndex);
        }
    }

    /**
     * Elimina una fila del table model según un índice.
     * Nota: NO remueve todas las ocurrencias del objeto 
     * de dominio asociado al número de fila, sólo la indicada 
     * por el parámetro {@code rowIndex}
     *
     * @param rowIndex El número de fila a eliminar.
     */
    public void deleteRow(int rowIndex) {
        if (data.remove(data.get(rowIndex))) {
            notifyTableRowsDeleted(rowIndex, rowIndex);
        }
    }

    /**
     * Elimina las filas dentro del rango {@code [firstRow, lastRow]}.
     *
     * @param firstRow La primera fila a eliminar (inclusive).
     * @param lastRow La última fila a eliminar (inclusive).
     * @throws IllegalArgumentException Si {@code firstRow < 0} ó {@code lastRow < 0} ó {@code firstRow > lastRow}.
     */
    public void deleteRows(int firstRow, int lastRow) {
        if (firstRow < 0 || lastRow < 0 || firstRow > lastRow) {
            throw new IllegalArgumentException("Los parámetros firstRow y lastRow deben ser positivos y firstRow >= lastRow.");
        } else {
            for (int i = firstRow; i <= lastRow; i++) {
                data.remove(i);
            }
            notifyTableRowsDeleted(firstRow, lastRow);
        }
    }

    /**
     * Elimina todas las filas de este table model, notificando a los listeners.
     */
    public void clearTableModelData() {
        if (!data.isEmpty()) {
            int lastRow = data.size() - 1;
            data.clear();
            notifyTableRowsDeleted(0, lastRow);
        }
    }

    /**
     * Devuelve un objeto de dominio basado en un número de fila.
     * @param rowIndex El número de la fila.
     * @return Un objeto de dominio.
     */
    public T getDomainObject(int rowIndex) {
        return data.get(rowIndex);
    }    

    /**
     * Devuelve una sublista de objetos de dominio comprendida en
     * el rango {@code [firstRow, lastRow]}.
     * 
     * @param firstRow La primera fila del rango (inclusive).
     * @param lastRow La última fila del rango (inclusive).
     * @return Una sublista con objetos de dominio.
     */
    public List<T> getDomainObjects(int firstRow, int lastRow) {
        return Collections.unmodifiableList(data.subList(firstRow, lastRow + 1));
    }

    /**
     * @return Todos los objetos de dominio de este table model.
     */
    public List<T> getDomainObjects() {
        return Collections.unmodifiableList(data);
    }

    /**
     * Establece los identificadores de las columnas para este table model, 
     * notificando a los listeners.
     * @param columnIdentifiers Los nuevos identificadores de columnas.
     */
    public void setColumnIdentifiers(List columnIdentifiers) {
        this.columnIdentifiers.clear();
        this.columnIdentifiers.addAll(columnIdentifiers);
        notifyTableHeaderChanged();
    }

} // End of class GenericDomainTableModel

Creando un TableModel reutilizable en Swing (parte I)

Cuando comenzamos a desarrollar aplicaciones de escritorio en Swing que requieren cierta complejidad de datos, tarde o temprano llegamos a la conclusión que una de las mejores herramientas para mostrar datos de manera organizada son las tablas. En Swing el componente que nos brinda esta funcionalidad es la clase JTable.

Sin embargo, la clave para poder trabajar con este componente es su modelo de datos subyacente, definido por la interfaz TableModel. El objetivo de este post es generar una clase que nos permita disponer de un modelo de datos personalizado y reutilizable en cuestión de minutos.

Llegado este punto, es necesario tener en claro cómo funcionan los componentes en Swing para luego centrarnos en nuestra tabla, y podemos pensar en ellos como un conunto de engranajes que interactúan para brindar una funcionalidad específica:

Componente-Modelo-Listeners

Componente (vista)

Es, valga la redundancia, nuestro componente en sí y de aquí en adelante lo llamaremos vista porque es lo que vemos en pantalla. Por ejemplo un botón, un text field, una tabla, un combo box (lista desplegable), etc.

Modelo

Todo componente tiene un modelo de datos subyacente, con el cual podemos trabajar y relacionar con nuestras clases de dominio. Por ejemplo un text field tiene asociado un documento que representa el modelo de datos de este componente, o un combo box tiene una lista de posibles valores para seleccionar. Los modelos de datos para cada uno de los componentes se definen por medio de interfaces, las cuales tienen una o más implementaciones por defecto, además de permitirnos crear nuestra propia implementación.

Listeners

Cuando el modelo de datos es modificado ya sea por interacción del usuario con la vista, por ejemplo al clickear un botón o ingresar texto en un text field, o por código el mismo debe notificar que ha ocurrido un evento y su estado ha cambiado. Para eso existen los listeners, los cuales se definen a través de interfaces, y son los encargados de procesar dichos eventos. Por ejemplo, los mismos componentes implementan la interfaz que corresponde al listener y se suscriben a su propio modelo, para de este modo poder saber si el modelo subyacente ha cambiado y actualizarse o redibujarse en consecuencia para reflejar los cambios.

Ahora que hemos definido los engranajes generales de todo componente Swing, veamos la correlación para nuestra tabla:

JTable-TableModel-TableModelListenerVemos entonces que la clase JTable es nuestro componente, la interfaz TableModel define nuestro modelo de datos y la interfaz TableModelListener define el contrato que los listeners suscriptos a nuestro modelo deben cumplir.

¿Por qué no utilizar DefaultTableModel?

Swing nos ofrece por defecto una implementación de la interfaz TableModel que es la clase DefaultTableModel. No obstante, este modelo trabaja internamente con un array bidimensional o matriz donde cada elemento es el valor de una de las celdas de nuestra tabla, y provee una API propia para trabajar con esta estructura de datos. Si bien al principio resulta sencillo asociar cada elemento de la matriz con una celda, al momento de trabajar con datos complejos tendremos que estar convirtiendo datos en vectores para poder, por ejemplo, agregar una fila a nuestra table.

Supongamos que tenemos la siguiente clase de dominio:

public class Persona {

    private String nombre;
    private Date fechaDeNacimiento;
    private Long documento;

    public Persona(String nombre, Date fechaDeNacimiento, Long documento) {
        this.nombre = nombre;
        this.fechaDeNacimiento = fechaDeNacimiento;
        this.documento = documento;
    }

    // Getters y setters aquí
}

Si quisieramos mostrar los datos de un objeto Persona en nuestra tabla utilizando DefaultTableModel deberíamos hacer lo siguiente:

Object[] header = new Object[]{"Nombre", "Fecha de nacimiento", "Documento"};
DefaultTableModel model = new DefaultTableModel(header, 0);
model.addRow(new Object[]{persona.getNombre(), persona.getFechaDeNacimiento(), persona.getDocumento()});

Como vemos, resulta engorroso convertir nuestro objeto de dominio en un vector para mostrar sus datos. A esto sumémosle la dificultad de querer  por ejemplo, recuperar el objeto Persona que representa la fila seleccionada en nuestra tabla: ni siquiera disponemos de un método para obtener una fila:

public Object[] getRowAt(int rowIndex) {...} // Esto no existe en la API !

Demasiado trabajo y limitaciones para dos operaciones básicas que utilizamos todo el tiempo.

¿Por qué no utilizar AbstractTableModel?

Si bien el tutorial oficial sugiere utilizar la clase abstracta AbstractTableModel como base para implementar nuestro propio TableModel, la única ventaja real que obtenemos es que el manejo de los eventos y la notificación a los listeners ya se encuentra implementada. Por otra parte, desde mi punto de vista, esta clase ofrece una pobre implementación de métodos que resultan sumamente importantes para la clase JTable:

  • getColumnClass(): esencial para que la clase JTable pueda seleccionar el mejor renderer y editor para cada celda. Ver Editors and Renderers para más detalles.
  • getColumnName(): esencial en el armado del encabezado de la tabla, su implementación por defecto nombra cada columna con letras al estilo hoja de cálculos: A, B, C, etc. Muy probablemente sobreescribiremos este método, por lo que en sí no nos brinda ninguna ventaja.

 Continuará…

En la siguiente entrada implementaremos nuestro propio TableModel de modo que podamos sortear los problemas descriptos y trabajar con nuestra tabla en forma Orientada a Objetos. Una vez finalizada nuestra clase veremos un ejemplo de cómo utilizarla en una aplicación.