Utilizando FXMLLoader

Obtén mayor flexibilidad para definir interfaces JavaFX de manera declarativa a través del mecanismo de carga estándar del FXMLLoader.

La interfaz gráfica JavaFX ofrece un moderna y refrescante perspectiva dep APIs que pueden usarse para crear aplicaciones móviles y de escritorio en la JVM. Similarmente como lo hizo su predecesor, Swing, JavaFX proporciona un conjunto de widgets estándar, así como los medios para extender este conjunto de widgets con componentes personalizados y nuevo comportamiento.

JavaFX también agrega nuevas capacidades como enlaces de propiedades (bindings), soporte de estilos a través de CSS y un formato de descripción de UI llamado FXML. Como su nombre lo indica, FXML es un formato basado en XML que permite a los desarrolladores definir interfaces de usuario de manera declarativa, en lugar de definir interfaces de manera programática, es decir, mediante el uso directo de las API de JavaFX.
Las siguientes son algunas de las ventajas de elegir FXML sobre la API programática:

FXML es un formato jerárquico. El SceneGraph también es una estructura jerárquica dado que representa los elementos UI como una estructura de datos en forma de árbol. Cada nodo en el SceneGraph se relaciona con un elemento gráfico, como lo es un botón, etiqueta o campo de texto. Como resultado, es más fácil visualizar la jerarquía de componentes en FXML que en código simple. FXML se puede crear al al instante, de manera dinámica si es necesario, permitiendo agregar/crear elementos de UI dinámicos en puntos específicos durante el tiempo de ejecución de la aplicación. En muchos casos, al escribir FXML se obtienen definiciones de interfaz de usuario más cortas.

Probablemente hayas visto FXML en una ocasión previa, pero en caso de que no lo hayas hecho, aquí hay una muestra rápida. El siguiente fragmento de código define una cuadrícula en la que seis elementos son posicionados en un diseño de dos columnas:

sample/app.fxml

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.GridPane?>
<GridPane>
  <Label text="Username:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
  <TextField              GridPane.columnIndex="1" GridPane.rowIndex="0"/>
  <Label text="Password:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
  <PasswordField          GridPane.columnIndex="1" GridPane.rowIndex="1"/>
  <Button text="Cancel"   GridPane.columnIndex="0" GridPane.rowIndex="2"/>
  <Button text="Login"    GridPane.columnIndex="1" GridPane.rowIndex="2"/>
</GridPane>

La Figura 1 muestra cómo se ve la aplicación cuando es es ejecutada.

Figura 1. Pantalla de acceso definida con FXML

Ten en cuenta que asi como en el código Java, se debe especificar declaraciones de importación que son usados en el archivo FXML. Estas declaraciones de importación sirven como pista para el mecanismo de carga que se encarga de interpretar la descripción declarativa de la IU y transformarla en un SceneGraph apropiado con los elementos de la UI. Este mecanismo de carga se conoce como FXMLLoader. El uso de FXMLLoader es sencillo, como se muestra en el siguiente código:

sample/App.java

package sample;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.net.URL;

public class App extends Application {
  @Override
  public void start(Stage stage) throws Exception {
    URL fxml = getClass().getClassLoader()
                 .getResource("sample/app.fxml");
    FXMLLoader fxmlLoader = new FXMLLoader(fxml);
    stage.setScene(new Scene(fxmlLoader.load()));
    stage.sizeToScene();
    stage.show();
  }
}

Por el momento solo es requerido indicarle a la instancia de FXMLLoader cual es la ubicación del recurso FXML que deseas cargar. Es importante recordar que la acción de crear elementos de UI y agregarlos al SceneGraph debe ocurrir dentro del hilo de UI (conocido como el hilo de la aplicación). Pueden suceder cosas malas cuando no se sigue esta regla. Afortunadamente el tipo base javax.aplication.Application proporciona un ciclo de vida básico que garantiza la invocación el código dentro de dicho hilo específico. En nuestro caso particular, se garantiza que el método de inicio (start) se llamará dentro del hilo de UI, lo que significa que todo está en orden.

Propiedades de Nodos

Al observar detenidamente el fragmento de FXML en el primer ejemplo, descubrirás que los nodos de FXML de tipo etiqueta y botón definen un atributo de texto. El valor de dicho atributo as asignado a una propiedad JavaBean que se encuentra definida en el clase Java correspondiente. Por lo tanto cuando la instance de FXMLLoader crea la primera etiqueta que éste encuentra, establece la propiedad de texto de la etiqueta con el valor exacto del atributo de texto. En otras palabras, es como si FXMLLoader invocara el siguiente código:

Label label = new Label();
label.setText("Username:");
// inserta la etiqueta en el SceneGraph

Es un fragmento bastante breve y esta funcionalidad no parece otorgar una gran ventaja en este momento; sin embargo, ten en cuenta que los elementos de la interfaz de usuario pueden tener varias propiedades que quizás quieras definir. A medida que continues definiendo más nodos y atributos los beneficios de FXML se harán evidentes. Vale la pena señalar que el tipo de nodo (Label, en este caso) debe definir las propiedades que coincidan con la convención JavaBeans, lo que da como resultado un par de métodos que se pueden usar para obtener (getter) y cambiar (setter) el valor la propiedad. La propiedad se torna invisible para FXMLLoader si falta alguno de estos métodos. Esta es la primera pista para entender por qué un fragmento de FXML en particular no funciona de la manera que esperaba. Podría ser que la propiedad que estás intentando establecer no sea una propiedad JavaBean definida correctamente.

También podrías preguntanrte sobre las propiedades adicionales en los seis elementos que deben especificarse para hacer uso de GridPane como espacio de nombres (namespace). Estas propiedades no existen en los nodos de destino en sí, sin embargo afectan a los nodos. Puedes verificar la declaración anterior cambiando los valores de columnIndex y rowIndex; dependiendo de los nuevos valores puedes obtener un diseño divertido o disfuncional.

¿Qué está pasando entonces? Estas propiedades están definidas por la clase GridPane y no por los nodos de destino. Esta es la razón por la cual el nombre de la clase aparece como un espacio de nombres. Las propiedades colIndex y rowIndex se definen como métodos estáticos en la clase GridPane, lo que significa que no son propiedades de Java Beans per se, sino parte de una convención que FXML define. Como vimos antes, cada propiedad debe tener métodos getter y setter. Además cada método debe tener el modificador estático. Una última parte de la convención es que los métodos deben tomar al nodo de destino como su primer argumento. Esto da como resultado las siguientes definiciones de método en GridPane:

public static void setColumnIndex(Node node, int index);
public static int getColumnIndex(Node node);
public static void setRowIndex(Node node, int index);
public static int getRowIndex(Node node);

Puedes hacer uso del tipo de Nodo como primer argumento porque este es el supertipo de todos los elementos de la UI enJavaFX. Esto permite que GridPane funcione con cualquier tipo de elemento de UI, tanto los que provienen del conjunto de widgets estándar como auellos proporcionados por bibliotecas de terceros. Observa que las definiciones de métodos anteriores toman un número entero como segundo argumento; sin embargo el archivo FXML define los valores correspondientes como literales. A pesar la discrepancia de tipos la apliación funciona porque FXMLLoader es capaz de aplicar conversiones de tipo. Lo hace para todos los tipos básicos y enums. Una preocupación adicional es almacenar el valor en relación con el nodo suministrado. De nuevo el API de JavaFX acude en n uestraayuda porque la clase base Node provee un mapa de propiedades que es usado para almacenar cualquier tipo de valor, y eso es precisamente lo que hacen estos métodos.

Armado con este nuevo conocimiento, puedes crear tus propias propiedades sintéticas. Las propiedades sintéticas son propiedades que no están explícitamente definidas en el tipo de destino; más bien, se relacionan con el tipo objetivo por medios externos, como si se tratare de un diccionario contextual. Supongamos que tenemos el requisito de limitar el número de caracteres que un usuario puede escribir en el campo Nombre o Contraseña. Puedes crear una nueva clase de utilería que define un par de métodos que toman un Nodo y un número, usando el número provisto como límite. La clase de utilería podría verse así:

sample/InputControlUtils.java

package sample;

import javafx.scene.control.TextInputControl;

public final class InputControlUtils {
  private static final String LIMIT = " limit";

  public static void setMaxTextLimit(TextInputControl control, int maxLength) {
    control.getProperties().put(LIMIT, maxLength);
    control.textProperty()
        .addListener((ov, oldValue, newValue) -> {
          if (control.getText().length() > maxLength) {
              String s = control.getText()
                         .substring(0, maxLength);
              control.setText(s);
          }
    }); 
  }
  
  public static int getMaxTextLimit(TextInputControl control) {
    Object value = control.getProperties().get(LIMIT);
    if (value instanceof Number) {
      return ((Number) value).intValue();
    }
    return -1; 
  }
}

Observa que este código no tiene en cuenta ningún valor inválido (por ejemplo cuando maxLength sea igual o menor que cero), ni proporciona los medios para anular el registro del listener si se llama al método setMaxTestLimit más de una vez con el mismo nodo de destino. Este código es para fines ilustrativos y no debe utilizarse "como tal" en producción sin ajustes adicionales. Continuando, hacer uso de esta nueva capacidad solo requiere actualizar las definiciones de FXML de la siguiente manera:

sample/app.fxml

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.GridPane?>
<?import sample.InputControlUtils?>
<GridPane>
  <Label text="Username:" GridPane.columnIndex="0" GridPane.rowIndex="0"
                          InputControlUtils.maxTextLimit="10"/>
  <TextField              GridPane.columnIndex="1" GridPane.rowIndex="0"/>
  <Label text="Password:" GridPane.columnIndex="0" GridPane.rowIndex="1"
                          InputControlUtils.maxTextLimit="10"/>
  <PasswordField          GridPane.columnIndex="1" GridPane.rowIndex="1"/>
  <Button text="Cancel"   GridPane.columnIndex="0" GridPane.rowIndex="2"/>
  <Button text="Login"    GridPane.columnIndex="1" GridPane.rowIndex="2"/>
</GridPane>

Hay otras dos características que se pueden mencionar cuando se trata de propiedades en FXML. Lo primero es la capacidad de definir el nombre de una propiedad predeterminada que te permite omitir el nombre de la propiedad al definir la jerarquía. El nombre de la propiedad se debe definir en código aplicando la anotación @javafx.beans.DefaultProperty en la clase del componente destino. Muchos de los elementos de UI en el conjunto estándar de widgets JavaFX usan esta anotación. Por ejemplo, el tipo javafx.scene.layout.Pane, que es el tipo base para muchos contenedores de widgets, contiene la anotación con @DefaultProperty("children"). Esto permite escribir FXML como se muestra en el ejemplo anterior en lugar de lo siguiente:

sample/app.fxml

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.GridPane?>
<?import sample.InputControlUtils?>
<GridPane>
  <children>
    <Label text="Username:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
    <!-- elementos adicionales -->
  </children>
</GridPane>

El soporte de FXML en IDEs está tan bien integrado que los IDEs pueden señalar si hay una propiedad predeterminada que se puede usar en lugar de tener que detallarse en el archivo FXML. Hablando de herramientas, vale la pena mencionar que puedes escribir FXML de manera manual, o puedes usar una herramienta visual gratuita llamada Scene Builder. También debo mencionar que tienes la opción de suministrar información adicional al FXMLLoader cuando éste cree una instancia de un tipo particular. Hasta ahora, he mostrado solo tipos que siguen las convenciones de JavaBeans al proporcionar un constructor sin argumentos. Esto es lo que FXMLLoader espera de manera predeterminada. Sin embargo, a veces uno puede encontrar un nodo que define un constructor que requiere argumentos. Sin la capacidad de proporcionar pistas adicionales FXMLLoader simplemente no crearía una instancia del nodo. Puedes ayudarlo proporcionando un nombre para los argumentos del constructor como si fueran propiedades regulares. Para hacer dicho fin se usa la anotación @javafx.beans.NamedArg. Debes definir un nombre para el argumento, y puede definir un valor predeterminado si el atributo se omite en el archivo FXML. Aquí hay un ejemplo concreto encontrado en la API JavaFX estándar: la clase Insets define los siguientes constructores:

public Insets(@NamedArg("top") double top,
              @NamedArg("right") double right,
              @NamedArg("bottom") double bottom,
              @NamedArg("left") double left) {
  this.top = top;
  this.right = right;
  this.bottom = bottom;
  this.left = left;
}
public Insets(@NamedArg("topRightBottomLeft")
              double topRightBottomLeft) {
  this.top = topRightBottomLeft;
  this.right = topRightBottomLeft;
  this.bottom = topRightBottomLeft;
  this.left = topRightBottomLeft;
}

El uso de ésta anotación permite definir instancias en cualquiera de las siguientes formas:

<Insets top="10" right="10" bottom="10" left="10"/>

o

<Insets topRightBottomLeft="10"/>

Eso es todo lo que puedo decir sobre el soporte de propiedades en FXML por el momento. A continuación, examinemos cómo configurar una instancia de FXMLLoader, utilizando internacionalización como ejemplo.

Configurando FXMLLoader para Internacionalización

Podemos encontrar una gran cantidad de lenguajes en el mundo actual. Como desarrollador, debes tener en cuenta la audiencia objetivo de las aplicaciones que desarrollas. En ocasiones, los usuarios hablan un idioma diferente al que hablas, por lo que debes estar preparado para proporcionar una versión adecuada a sus necesidades.

Los widgets de JavaFX estaán predispuestos a aceptar la configuración regional y otras configuraciones regionales expuestas por JVM, lo que da como resultado que los widgets se procesen de derecha a izquierda o de izquierda a derecha dependiendo de la situación por ejemplo. Pero no se puede decir lo mismo sobre el texto que es definido a la aplicación. Sería genial si pudieras de alguna manera externalizar estos textos y hacer que los archivos FXML se refieran a ellos. Afortunadamente, FXMLLoader tiene justo lo que necesitas para resolver este problema. Puedes proporcionarle un ResourceBundle en el momento de la construcción o establecer el ResourceBundle como una propiedad antes de cargar archivos FXML. Puedes crear este ResourceBundle de cualquier manera que determines adecuada. El ejemplo que hemos utilizado hasta el momento, sample/App.java, puede ser actualizado de la siguiente manera:

ClassLoader cl = getClass().getClassLoader();
URL fxml = cl.getResource("sample/app.fxml");
String resource = "sample/messages_en.properties"
InputStream is = cl.getResourceAsStream(resource);
ResourceBundle bundle = new PropertyResourceBundle(is);
FXMLLoader fxmlLoader = new FXMLLoader(fxml, bundle);

También define el siguiente par de archivos de propiedades: uno que usa Inglés (en) como configuración predeterminada regional y otro que usa Alemán (de):

sample/messages_en.properties

username.label=Username:
password.label=Password:
action.cancel.label=Cancel
action.login.label=Login

sample/messages_de.properties

username.label=Benutzername:
password.label=Passwort:
action.cancel.label=Abbrechen
action.login.label=Anmeldung

Debes alterar el archivo FXML para hacer uso de las nuevas claves de propiedades que acabas de definir. La convención es usar % como prefijo para distinguir un valor clave de un valor literal, incluso el IDE puede sugerirte las claves a completar.

sample/app.fxml

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.GridPane?>
<?import sample.InputControlUtils?>
<GridPane>
  <Label text="%username.label"
                 GridPane.columnIndex="0" GridPane.rowIndex="0"
                 InputControlUtils.maxTextLimit="10"/>
  <TextField     GridPane.columnIndex="1" GridPane.rowIndex="0"/>
  <Label text="%password.label"
                 GridPane.columnIndex="0" GridPane.rowIndex="1"
                 InputControlUtils.maxTextLimit="10"/>
  <PasswordField GridPane.columnIndex="1" GridPane.rowIndex="1"/>
  <Button text="%action.cancel.label"
                 GridPane.columnIndex="0" GridPane.rowIndex="2"/>
  <Button text="%action.login.label"
                 GridPane.columnIndex="1" GridPane.rowIndex="2"/>
</GridPane>

Al ejecutar de nuevo la aplicación, deberás ver el mismo resultado mostrado en la Figura 1. Ahora, cambia el código para cargar la versión alemana del archivo de propiedades y verás que la aplicación se parece a la Figura 2.

Figura 2. Pantalla de acceso localizada en Alemán

¡Estupendo! Estos son los primeros pasos para obtener soporte de localización en una aplicación. Como se mencionó anteriormente, puedes crear el ResourceBundle de muchas formas que se usan comúnmente en la programación regular de Java. Un paso más sería hacer que la aplicación reaccione a los cambios de Locale al tiempo de ejecución. Si optas por hacer esto, tendrás que encontrar la forma en que los widgets sean notificados del cambio en la configuración regional y que los valores de las nuevas claves se deben recuperar de ueva cuentan. Me temo que FXMLLoader por sí solo no es suficiente, pero tienes todos los bloques básicos para comenzar.

Conclusión

FXML proporciona una manera rápida y fácil de definir UIs de manera declarativa. El soporte de herramientas (IDEs y SceneBuilder) es suficientemente bueno como para comenzar a escribir FXML con facilidad. Muchos de los tipos de nodos que puedes usar dentro de FXML son sencillos, porque siguen las convenciones de JavaBeans. Pero cuando no lo hacen, todavía hay una manera de usarlos con FXML dado que el formato proporciona extensiones útiles y sugerencias para su mecanismo de carga, FXMLLoader. También puedes configurar localización en widgets a través de FXML, permitiendo un público más amplio para tus aplicaciones. En un próximo artículo, examinaré el mecanismo expuesto por FXMLLoader y FXML para personalizar aún más y unir los widgets definidos en el archivo de FXML, por ejemplo, características como el atributo fx:controller y la anotación @FXML.

La versión original de este artículo fue publicada en Oracle Java Magazine, Marzo 2017.

ˆ Back To Top