Conversor de Unidades Offline e Interactivo en JavaFX
Construye un conversor de unidades con múltiples categorías (Longitud, Peso, Temperatura), animaciones dinámicas al cambiar de pestaña y diseño sin bordes.
3 de marzo de 2026 • 15 min de lectura

Siguiendo con nuestra serie de interfaces modernas y sin bordes en JavaFX, hoy traemos un proyecto muy práctico: un Conversor de Unidades Offline.
A diferencia de los conversores web que dependen de una API para monedas (cuyos valores fluctúan constantemente), nosotros crearemos una herramienta que soporta medidas físicas y digitales universales. Es decir, funcionará perfectamente sin internet.
Implementaremos las siguientes categorías funcionales:
- 📏 Longitud (Metros, Kilómetros, Millas...)
- ⚖️ Peso (Gramos, Kilogramos, Libras...)
- 🌡️ Temperatura (Celsius, Fahrenheit, Kelvin)
- 💾 Datos (Megabytes, Gigabytes...)
- 🚀 Velocidad (km/h, nudos, mph)
Estructura del Proyecto
Trabajaremos enteramente en código puro sin .fxml. Dividiremos la responsabilidades en clases muy claras:
Unit.java: Nuestro modelo de datos básico.ConverterLogic.java: El motor offline de conversiones matemáticas.ConverterUI.java: Aquí programaremos la vista, la interactividad de losComboBoxy nuestras increíbles animaciones.App.java: Clase principal para montar la escena flotante sin bordes.style.css: Estiloz avanzados incluyendo soporte para Modo Oscuro global.
1. Modelo de Datos y Motor Lógico
Primero necesitamos abstraer nuestras unidades. Usaremos un sistema de conversión proporcional basado en una unidad base por categoría (ej. gramos para Peso).
Unit.java
package com.conversor;
public class Unit {
private String name;
private String symbol;
private double rateToBase;
public Unit(String name, String symbol, double rateToBase) {
this.name = name;
this.symbol = symbol;
this.rateToBase = rateToBase;
}
public String getName() { return name; }
public String getSymbol() { return symbol; }
public double getRateToBase() { return rateToBase; }
@Override
public String toString() {
// Obliga al ComboBox a renderizar unicamente el nombre
return name;
}
}
ConverterLogic.java
Manejar la conversión es sencillo. Transformamos el valor original a su "Unidad Base" (multiplicando), y luego lo transformamos a la "Unidad Destino" (dividiendo). El único módulo que rompe esta regla proporcional es la Temperatura, por lo que agregamos una lógica condicional especial.
package com.conversor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class ConverterLogic {
// Categorías
public static final String CATEGORY_LENGTH = "Longitud";
public static final String CATEGORY_WEIGHT = "Peso";
public static final String CATEGORY_TEMP = "Temperatura";
public static final String CATEGORY_DATA = "Datos";
public static final String CATEGORY_SPEED = "Velocidad";
private Map<String, List<Unit>> unitsByCategory;
public ConverterLogic() {
unitsByCategory = new HashMap<>();
initializeData();
}
private void initializeData() {
// --- Longitud (Base: Metros) ---
List<Unit> length = new ArrayList<>();
length.add(new Unit("Milimetros", "mm", 0.001));
length.add(new Unit("Centimetros", "cm", 0.01));
length.add(new Unit("Metros", "m", 1.0));
length.add(new Unit("Kilometros", "km", 1000.0));
length.add(new Unit("Millas", "mi", 1609.34));
unitsByCategory.put(CATEGORY_LENGTH, length);
// --- Temperatura (Caso especial NO proporcional) ---
List<Unit> temp = new ArrayList<>();
temp.add(new Unit("Celsius", "°C", 1.0));
temp.add(new Unit("Fahrenheit", "°F", 1.0));
temp.add(new Unit("Kelvin", "K", 1.0));
unitsByCategory.put(CATEGORY_TEMP, temp);
// (En el código completo verás las inicializaciones para Datos, Peso y Velocidad...)
}
public List<Unit> getUnitsForCategory(String category) {
return unitsByCategory.getOrDefault(category, new ArrayList<>());
}
public double convert(double value, Unit from, Unit to, String currentCategory) {
if (from == null || to == null) return 0.0;
if (currentCategory.equals(CATEGORY_TEMP)) {
return convertTemperature(value, from.getName(), to.getName());
}
// Lógica Proporcional general
double valueInBase = value * from.getRateToBase();
return valueInBase / to.getRateToBase();
}
private double convertTemperature(double value, String fromName, String toName) {
if (fromName.equals(toName)) return value;
double celsius = value;
// Covertir a Celsius primero
if (fromName.equals("Fahrenheit")) celsius = (value - 32) * 5 / 9;
else if (fromName.equals("Kelvin")) celsius = value - 273.15;
// Celsius a Destino
if (toName.equals("Fahrenheit")) return (celsius * 9 / 5) + 32;
else if (toName.equals("Kelvin")) return celsius + 273.15;
return celsius;
}
}
2. Hoja de Estilos Dinámica e Inteligente
Al igual que en tutoriales previos, declaramos nuestras variables en :root. Esta vez nos aseguraremos de que la regla de clase .dark-theme esté configurada para apuntar al nodo principal y sobreescribir dichos colores con las equivalencias para la noche.
style.css
/* Variables Globales */
.root {
-bg-color: #f0f2f5;
-card-bg: #ffffff;
-text-main: #202124;
-text-secondary: #5f6368;
-accent-color: #1a73e8;
-input-bg: #f8f9fa;
-border-color: #dadce0;
-shadow-color: rgba(0, 0, 0, 0.1);
-fx-font-family: 'Segoe UI', sans-serif;
-fx-background-color: transparent;
}
/* Variables Modo Oscuro */
.dark-theme {
-bg-color: #202124;
-card-bg: #2d2e30;
-text-main: #ffffff;
-text-secondary: #9aa0a6;
-accent-color: #8ab4f8;
-input-bg: #3c4043;
-border-color: #5f6368;
-shadow-color: rgba(0, 0, 0, 0.4);
}
/* Wrapper general de ventana */
.app-container {
-fx-padding: 20px;
-fx-alignment: center;
}
/* Tarjeta Principal */
.converter-card {
-fx-background-color: -card-bg;
-fx-background-radius: 20px;
-fx-padding: 30px;
-fx-effect: dropshadow(three-pass-box, -shadow-color, 20, 0, 0, 10);
}
/* Inputs y Selectores */
.input-field {
-fx-background-color: -input-bg;
-fx-text-fill: -text-main;
-fx-font-size: 28px;
-fx-font-weight: bold;
-fx-background-radius: 12px;
-fx-border-color: -border-color;
-fx-border-radius: 12px;
-fx-padding: 15px;
}
.input-field:focused {
-fx-border-color: -accent-color;
-fx-border-width: 2px;
}
.result-field {
-fx-text-fill: -accent-color;
-fx-font-size: 28px;
-fx-font-weight: bold;
-fx-padding: 15px;
}
3. Ensamblando la Interfaz Animada: ConverterUI.java
Aquí viene lo divertido. Observa cómo amarramos listeners interactivos a los campos texto y cómo hacemos uso de Hbox.setHgrow para evitar pestañas colapsadas en pantallas pequeñas. Y presta especial atención al método switchCategory, el cual orquesta una fluida transición de desvanecido (Fade Out + Fade In) acoplada a un pequeño rebote visual (ScaleTransition).
package com.conversor;
import javafx.animation.*;
import javafx.geometry.*;
import javafx.scene.Parent;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.*;
import javafx.scene.text.Text;
import javafx.util.Duration;
import javafx.stage.Stage;
import javafx.application.Platform;
import java.util.List;
public class ConverterUI {
private VBox rootPane;
private ConverterLogic logic;
private boolean isDarkMode = false;
// Componentes que animaremos
private HBox categoryTabs;
private VBox converterArea;
private ComboBox<Unit> fromCombo;
private ComboBox<Unit> toCombo;
private TextField fromField;
private Label toLabel;
private String currentCategory = ConverterLogic.CATEGORY_LENGTH;
public ConverterUI() {
logic = new ConverterLogic();
buildUI();
loadCategory(currentCategory);
}
private void buildUI() {
VBox appContainer = new VBox(20);
appContainer.getStyleClass().add("app-container");
VBox card = new VBox(25);
card.getStyleClass().add("converter-card");
// Creamos la barra superior (Top Bar con titulo y botón Cerrar)
// ... [Ver código completo en descargas] ...
// Pestañas de categorías dinámicas
categoryTabs = new HBox(10);
String[] categories = { ConverterLogic.CATEGORY_LENGTH, ConverterLogic.CATEGORY_WEIGHT, ConverterLogic.CATEGORY_TEMP, ConverterLogic.CATEGORY_DATA, ConverterLogic.CATEGORY_SPEED };
String[] icons = { "📏", "⚖️", "🌡️", "💾", "🚀" };
for (int i = 0; i < categories.length; i++) {
final String cat = categories[i];
Button btn = new Button(icons[i] + " " + cat);
btn.getStyleClass().add("category-btn");
if (cat.equals(currentCategory)) btn.getStyleClass().add("category-btn-active");
// Forzamos el ensanchamiento fluido de pestañas
btn.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(btn, Priority.ALWAYS);
btn.setOnAction(e -> switchCategory(cat, btn));
categoryTabs.getChildren().add(btn);
}
// Area de datos interactiva
converterArea = new VBox(20);
fromCombo = new ComboBox<>();
toCombo = new ComboBox<>();
fromField = new TextField("1");
fromField.getStyleClass().add("input-field");
// Escuchamos el teclado en todo momento
fromField.textProperty().addListener((obs, oldV, newV) -> performConversion());
// (Agregas los controles a la vista con sus íconos de Swap)
card.getChildren().addAll(topBar, categoryTabs, converterArea);
appContainer.getChildren().add(card);
rootPane = new VBox(appContainer);
rootPane.getStyleClass().add("light-theme");
}
// --- ANIMACIONES INCREIBLES EN JAVAFX ---
private void switchCategory(String newCategory, Button clickedBtn) {
if (currentCategory.equals(newCategory)) return;
// Desvanecer capa anterior
FadeTransition ftOut = new FadeTransition(Duration.millis(150), converterArea);
ftOut.setToValue(0.0);
ftOut.setOnFinished(e -> {
updateCategoryUI(newCategory, clickedBtn);
// Aparecer nueva capa inmediatamente
FadeTransition ftIn = new FadeTransition(Duration.millis(150), converterArea);
ftIn.setToValue(1.0);
ftIn.play();
});
ftOut.play();
}
private void updateCategoryUI(String newCategory, Button clickedBtn) {
currentCategory = newCategory;
for (javafx.scene.Node node : categoryTabs.getChildren()) {
node.getStyleClass().remove("category-btn-active");
}
clickedBtn.getStyleClass().add("category-btn-active");
loadCategory(currentCategory);
// Efecto físico "rebote"
ScaleTransition st = new ScaleTransition(Duration.millis(200), converterArea);
st.setFromY(0.95);
st.setToY(1.0);
st.play();
}
private void performConversion() {
Unit from = fromCombo.getValue();
Unit to = toCombo.getValue();
String textVal = fromField.getText().trim();
if (textVal.isEmpty() || from == null || to == null) {
toLabel.setText("0");
return;
}
try {
double value = Double.parseDouble(textVal.replace(",", "."));
double result = logic.convert(value, from, to, currentCategory);
// Format to 6 decimales máximo
String formatted = String.format("%.6f", result).replaceAll("0*$", "").replaceAll("\\.$", "");
toLabel.setText(formatted.replace(",", "."));
// Animación al escribir para confirmar respuesta
ScaleTransition st = new ScaleTransition(Duration.millis(150), toLabel);
st.setFromX(0.98); st.setFromY(0.98);
st.setToX(1.0); st.setToY(1.0);
st.play();
} catch (NumberFormatException ex) {
toLabel.setText("Inválido");
}
}
}
4. Mostrando la Ventana: App.java
¡Un detalle final importantísimo! Para que tu sombra visual y tus bordes curvos se dibujen correctamente fuera del marco de la app, requerimos que el ecosistema subyacente (Scene) sea de dimensiones superiores (ejemplo 650x550) y configurarlo Color.TRANSPARENT.
package com.conversor;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.scene.paint.Color;
public class App extends Application {
@Override
public void start(Stage primaryStage) {
ConverterUI converterUI = new ConverterUI();
// Ventana amplia para evitar recortar la sombra de la tarjeta
Scene scene = new Scene(converterUI.getView(), 650, 550);
scene.setFill(Color.TRANSPARENT);
scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm());
primaryStage.initStyle(StageStyle.TRANSPARENT);
converterUI.setupDraggableWindow(primaryStage);
primaryStage.setScene(scene);
primaryStage.show();
}
}
Si deseas explorar las variables de sombra, las fuentes o entender con mayor profundidad cómo interactúa el modelo Unit con nuestro JavaFX, puedes descargar el proyecto empaquetado y listo para compilar justo debajo: